From 4b7351700f643bc2d47b73ddf89f84ef727ff5f1 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 16 Jun 2026 13:19:41 -0700 Subject: [PATCH 01/70] [codex] re-enable absolute workdir integration test (#28581) ## Why In #28146 I missed the invariant that an absolute `exec_command` workdir must override the environment cwd. The existing integration test would have caught that regression, but it was ignored as flaky. ## What Re-enable `unified_exec_respects_workdir_override`. ## Validation `just test -p codex-core unified_exec_respects_workdir_override` --- codex-rs/core/tests/suite/unified_exec.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 8ef694a408..7ac0774316 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -521,7 +521,6 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "flaky"] async fn unified_exec_respects_workdir_override() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); From e93516e25983e115fe39162cfa7912ca374c43eb Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Tue, 16 Jun 2026 13:34:16 -0700 Subject: [PATCH 02/70] code-mode: extend test coverage to lock in cell lifecycle (#28468) This PR establishes the intended behavior as an executable contract before a refactor of the cell runtime begins. It also fixes cases where a second observer or termination request could replace an existing response channel and leave the original caller unresolved. ### Behavior codified - A cell can yield output and subsequently resume to completion. - A caller can run a cell until it has no immediately runnable work, receive its accumulated output and outstanding tool-call IDs, and then resume the same cell when the awaited work is available. - Each cell admits one active observer: - a second observer receives an explicit busy error - the existing observer remains registered and is not displaced - A natural result (conclusion of the js module) that has already reached the cell controller wins over a later termination request. - Otherwise, termination preempts execution and resolves both: - the active observer, if present - the caller requesting termination - Repeated termination requests are rejected while termination is already in progress. - Terminal responses are sent only after outstanding callback work has been handled: - natural completion drains notifications and cancels outstanding tool calls - termination cancels and drains both notification and tool callbacks. - Cell removal and cell_closed notification happen after callback cleanup --- codex-rs/code-mode/src/service.rs | 391 +++++++---- .../code-mode/src/service_contract_tests.rs | 634 ++++++++++++++++++ codex-rs/core/tests/suite/code_mode.rs | 15 +- 3 files changed, 903 insertions(+), 137 deletions(-) create mode 100644 codex-rs/code-mode/src/service_contract_tests.rs diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs index 6eb084ea5a..db5cb55a63 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode/src/service.rs @@ -86,6 +86,7 @@ struct CellHandle { control_tx: mpsc::UnboundedSender, runtime_tx: std::sync::mpsc::Sender, cancellation_token: CancellationToken, + termination_requested: Arc, } struct Inner { @@ -142,7 +143,7 @@ impl CodeModeService { ) .await?; - Ok(StartedCell::new(cell_id, response_rx)) + Ok(StartedCell::from_result_receiver(cell_id, response_rx)) } pub async fn execute_to_pending( @@ -162,7 +163,7 @@ impl CodeModeService { response_rx .await - .map_err(|_| "exec runtime ended unexpectedly".to_string()) + .map_err(|_| "exec runtime ended unexpectedly".to_string())? } async fn start_cell( @@ -195,6 +196,7 @@ impl CodeModeService { control_tx, runtime_tx: runtime_tx.clone(), cancellation_token: cancellation_token.clone(), + termination_requested: Arc::new(AtomicBool::new(false)), }, ); (runtime_tx, runtime_control_tx, runtime_terminate_handle) @@ -220,13 +222,20 @@ impl CodeModeService { } pub async fn wait(&self, request: WaitRequest) -> Result { + self.begin_wait(request).await.await + } + + async fn begin_wait( + &self, + request: WaitRequest, + ) -> CodeModeSessionResultFuture<'static, WaitOutcome> { let WaitRequest { cell_id, yield_time_ms, } = request; let handle = self.inner.cells.lock().await.get(&cell_id).cloned(); let Some(handle) = handle else { - return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); + return missing_wait(cell_id); }; let (response_tx, response_rx) = oneshot::channel(); let control_message = CellControlCommand::Poll { @@ -234,12 +243,9 @@ impl CodeModeService { response_tx, }; if handle.control_tx.send(control_message).is_err() { - return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); - } - match response_rx.await { - Ok(response) => Ok(WaitOutcome::LiveCell(response)), - Err(_) => Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))), + return missing_wait(cell_id); } + wait_for_response(cell_id, response_rx) } pub async fn terminate(&self, cell_id: CellId) -> Result { @@ -247,16 +253,25 @@ impl CodeModeService { let Some(handle) = handle else { return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); }; + if handle + .termination_requested + .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) + .is_err() + { + return Err(already_terminating_error(&cell_id)); + } let (response_tx, response_rx) = oneshot::channel(); if handle .control_tx .send(CellControlCommand::Terminate { response_tx }) .is_err() { + handle.termination_requested.store(false, Ordering::Relaxed); return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); } match response_rx.await { - Ok(response) => Ok(WaitOutcome::LiveCell(response)), + Ok(Ok(response)) => Ok(WaitOutcome::LiveCell(response)), + Ok(Err(error_text)) => Err(error_text), Err(_) => Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))), } } @@ -283,7 +298,8 @@ impl CodeModeService { ))); } match response_rx.await { - Ok(response) => Ok(WaitToPendingOutcome::LiveCell(response)), + Ok(Ok(response)) => Ok(WaitToPendingOutcome::LiveCell(response)), + Ok(Err(error_text)) => Err(error_text), Err(_) => Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( cell_id, ))), @@ -365,19 +381,19 @@ impl CodeModeSession for CodeModeService { enum CellControlCommand { Poll { yield_time_ms: u64, - response_tx: oneshot::Sender, + response_tx: oneshot::Sender>, }, PollToPending { - response_tx: oneshot::Sender, + response_tx: oneshot::Sender>, }, Terminate { - response_tx: oneshot::Sender, + response_tx: oneshot::Sender>, }, } enum CellResponseSender { - Runtime(oneshot::Sender), - ExecuteToPending(oneshot::Sender), + Runtime(oneshot::Sender>), + ExecuteToPending(oneshot::Sender>), } struct PendingResult { @@ -402,6 +418,31 @@ fn missing_cell_response(cell_id: CellId) -> RuntimeResponse { } } +fn missing_wait(cell_id: CellId) -> CodeModeSessionResultFuture<'static, WaitOutcome> { + Box::pin(async move { Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))) }) +} + +fn wait_for_response( + cell_id: CellId, + response_rx: oneshot::Receiver>, +) -> CodeModeSessionResultFuture<'static, WaitOutcome> { + Box::pin(async move { + match response_rx.await { + Ok(Ok(response)) => Ok(WaitOutcome::LiveCell(response)), + Ok(Err(error_text)) => Err(error_text), + Err(_) => Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))), + } + }) +} + +fn busy_observer_error(cell_id: &CellId) -> String { + format!("exec cell {cell_id} already has an active observer") +} + +fn already_terminating_error(cell_id: &CellId) -> String { + format!("exec cell {cell_id} is already terminating") +} + fn pending_result_response(cell_id: &CellId, result: PendingResult) -> RuntimeResponse { RuntimeResponse::Result { cell_id: cell_id.clone(), @@ -413,14 +454,27 @@ fn pending_result_response(cell_id: &CellId, result: PendingResult) -> RuntimeRe fn send_terminal_response(response_tx: CellResponseSender, response: RuntimeResponse) { match response_tx { CellResponseSender::Runtime(response_tx) => { - let _ = response_tx.send(response); + let _ = response_tx.send(Ok(response)); } CellResponseSender::ExecuteToPending(response_tx) => { - let _ = response_tx.send(ExecuteToPendingOutcome::Completed(response)); + let _ = response_tx.send(Ok(ExecuteToPendingOutcome::Completed(response))); } } } +fn send_termination_responses( + response_tx: Option, + termination_response_tx: Option>>, + response: RuntimeResponse, +) { + if let Some(response_tx) = response_tx { + send_terminal_response(response_tx, response.clone()); + } + if let Some(termination_response_tx) = termination_response_tx { + let _ = termination_response_tx.send(Ok(response)); + } +} + fn send_or_buffer_result( cell_id: &CellId, result: PendingResult, @@ -447,10 +501,10 @@ fn send_yield_response( }; match current_response_tx { CellResponseSender::Runtime(response_tx) => { - let _ = response_tx.send(RuntimeResponse::Yielded { + let _ = response_tx.send(Ok(RuntimeResponse::Yielded { cell_id: cell_id.clone(), content_items: std::mem::take(content_items), - }); + })); } CellResponseSender::ExecuteToPending(execute_to_pending_tx) => { *response_tx = Some(CellResponseSender::ExecuteToPending(execute_to_pending_tx)); @@ -478,30 +532,134 @@ async fn run_cell_control( let mut pending_tool_call_ids = Vec::new(); let mut pending_result: Option = None; let mut response_tx = Some(initial_response_tx); + let mut termination_response_tx = None; let mut termination_requested = false; let mut runtime_closed = false; let mut yield_timer: Option>> = None; let mut notification_tasks = JoinSet::new(); + let mut tool_tasks = JoinSet::new(); loop { + let yield_deadline_elapsed = yield_timer + .as_ref() + .is_some_and(|yield_timer| yield_timer.deadline() <= tokio::time::Instant::now()); tokio::select! { + biased; + maybe_command = control_rx.recv() => { + let Some(command) = maybe_command else { + break; + }; + match command { + CellControlCommand::Poll { + yield_time_ms, + response_tx: next_response_tx, + } => { + if let Some(result) = pending_result.take() { + let _ = next_response_tx.send(Ok(pending_result_response(&cell_id, result))); + break; + } + if response_tx.is_some() || termination_response_tx.is_some() { + let _ = next_response_tx.send(Err(busy_observer_error(&cell_id))); + continue; + } + response_tx = Some(CellResponseSender::Runtime(next_response_tx)); + yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(yield_time_ms)))); + resume_paused_runtime(&runtime_control_tx, pending_mode); + } + CellControlCommand::PollToPending { + response_tx: next_response_tx, + } => { + if let Some(result) = pending_result.take() { + let response = pending_result_response(&cell_id, result); + let _ = next_response_tx + .send(Ok(ExecuteToPendingOutcome::Completed(response))); + break; + } + if response_tx.is_some() || termination_response_tx.is_some() { + let _ = next_response_tx.send(Err(busy_observer_error(&cell_id))); + continue; + } + response_tx = + Some(CellResponseSender::ExecuteToPending(next_response_tx)); + yield_timer = None; + resume_paused_runtime(&runtime_control_tx, pending_mode); + } + CellControlCommand::Terminate { response_tx: next_response_tx } => { + if let Some(result) = pending_result.take() { + let _ = next_response_tx.send(Ok(pending_result_response(&cell_id, result))); + break; + } + + if termination_response_tx.is_some() { + let _ = next_response_tx.send(Err(already_terminating_error(&cell_id))); + continue; + } + + termination_response_tx = Some(next_response_tx); + termination_requested = true; + cancellation_token.cancel(); + yield_timer = None; + let _ = runtime_tx.send(RuntimeCommand::Terminate); + terminate_paused_runtime(&runtime_control_tx, pending_mode); + let _ = runtime_terminate_handle.terminate_execution(); + if runtime_closed { + finish_callbacks( + &cancellation_token, + &mut notification_tasks, + &mut tool_tasks, + CallbackCompletion::Cancel, + ).await; + let response = RuntimeResponse::Terminated { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }; + send_termination_responses( + response_tx.take(), + termination_response_tx.take(), + response, + ); + break; + } else { + continue; + } + } + } + } + _ = async { + if let Some(yield_timer) = yield_timer.as_mut() { + yield_timer.await; + } else { + std::future::pending::<()>().await; + } + } => { + yield_timer = None; + send_yield_response(&cell_id, &mut content_items, &mut response_tx); + } maybe_event = async { if runtime_closed { std::future::pending::>().await } else { event_rx.recv().await } - } => { + }, if !yield_deadline_elapsed => { let Some(event) = maybe_event else { runtime_closed = true; if termination_requested { - if let Some(response_tx) = response_tx.take() { - let response = RuntimeResponse::Terminated { - cell_id: cell_id.clone(), - content_items: std::mem::take(&mut content_items), - }; - send_terminal_response(response_tx, response); - } + finish_callbacks( + &cancellation_token, + &mut notification_tasks, + &mut tool_tasks, + CallbackCompletion::Cancel, + ).await; + let response = RuntimeResponse::Terminated { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }; + send_termination_responses( + response_tx.take(), + termination_response_tx.take(), + response, + ); break; } if pending_result.is_none() { @@ -534,13 +692,13 @@ async fn run_cell_control( Some(CellResponseSender::Runtime(runtime_response_tx)); } CellResponseSender::ExecuteToPending(response_tx) => { - let _ = response_tx.send(ExecuteToPendingOutcome::Pending { + let _ = response_tx.send(Ok(ExecuteToPendingOutcome::Pending { cell_id: cell_id.clone(), content_items: std::mem::take(&mut content_items), pending_tool_call_ids: std::mem::take( &mut pending_tool_call_ids, ), - }); + })); } } } @@ -557,20 +715,13 @@ async fn run_cell_control( let cell_id = cell_id.clone(); let cancellation_token = cancellation_token.child_token(); notification_tasks.spawn(async move { - tokio::select! { - result = delegate.notify( - call_id, - cell_id.clone(), - text, - cancellation_token.clone(), - ) => { - if let Err(err) = result { - warn!( - "failed to deliver code mode notification for cell {cell_id}: {err}" - ); - } - } - _ = cancellation_token.cancelled() => {} + if let Err(err) = delegate + .notify(call_id, cell_id.clone(), text, cancellation_token) + .await + { + warn!( + "failed to deliver code mode notification for cell {cell_id}: {err}" + ); } }); } @@ -593,11 +744,8 @@ async fn run_cell_control( let delegate = Arc::clone(&inner.delegate); let runtime_tx = runtime_tx.clone(); let cancellation_token = cancellation_token.child_token(); - tokio::spawn(async move { - let response = tokio::select! { - response = delegate.invoke_tool(tool_call, cancellation_token.clone()) => response, - _ = cancellation_token.cancelled() => return, - }; + tool_tasks.spawn(async move { + let response = delegate.invoke_tool(tool_call, cancellation_token).await; let command = match response { Ok(result) => RuntimeCommand::ToolResponse { id, result }, Err(error_text) => RuntimeCommand::ToolError { id, error_text }, @@ -611,16 +759,29 @@ async fn run_cell_control( } => { yield_timer = None; if termination_requested { - if let Some(response_tx) = response_tx.take() { - let response = RuntimeResponse::Terminated { - cell_id: cell_id.clone(), - content_items: std::mem::take(&mut content_items), - }; - send_terminal_response(response_tx, response); - } + finish_callbacks( + &cancellation_token, + &mut notification_tasks, + &mut tool_tasks, + CallbackCompletion::Cancel, + ).await; + let response = RuntimeResponse::Terminated { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }; + send_termination_responses( + response_tx.take(), + termination_response_tx.take(), + response, + ); break; } - drain_notification_tasks(&mut notification_tasks).await; + finish_callbacks( + &cancellation_token, + &mut notification_tasks, + &mut tool_tasks, + CallbackCompletion::DrainNotifications, + ).await; inner .stored_values .lock() @@ -648,92 +809,56 @@ async fn run_cell_control( warn!("code mode notification task failed: {err}"); } } - maybe_command = control_rx.recv() => { - let Some(command) = maybe_command else { - break; - }; - match command { - CellControlCommand::Poll { - yield_time_ms, - response_tx: next_response_tx, - } => { - if let Some(result) = pending_result.take() { - let _ = next_response_tx.send(pending_result_response(&cell_id, result)); - break; - } - response_tx = Some(CellResponseSender::Runtime(next_response_tx)); - yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(yield_time_ms)))); - resume_paused_runtime(&runtime_control_tx, pending_mode); - } - CellControlCommand::PollToPending { - response_tx: next_response_tx, - } => { - if let Some(result) = pending_result.take() { - let response = pending_result_response(&cell_id, result); - let _ = next_response_tx - .send(ExecuteToPendingOutcome::Completed(response)); - break; - } - response_tx = - Some(CellResponseSender::ExecuteToPending(next_response_tx)); - yield_timer = None; - resume_paused_runtime(&runtime_control_tx, pending_mode); - } - CellControlCommand::Terminate { response_tx: next_response_tx } => { - if let Some(result) = pending_result.take() { - let _ = next_response_tx.send(pending_result_response(&cell_id, result)); - break; - } - - response_tx = Some(CellResponseSender::Runtime(next_response_tx)); - termination_requested = true; - cancellation_token.cancel(); - yield_timer = None; - let _ = runtime_tx.send(RuntimeCommand::Terminate); - terminate_paused_runtime(&runtime_control_tx, pending_mode); - let _ = runtime_terminate_handle.terminate_execution(); - if runtime_closed { - if let Some(response_tx) = response_tx.take() { - let response = RuntimeResponse::Terminated { - cell_id: cell_id.clone(), - content_items: std::mem::take(&mut content_items), - }; - send_terminal_response(response_tx, response); - } - break; - } else { - continue; - } - } + task_result = tool_tasks.join_next(), if !tool_tasks.is_empty() => { + if let Some(Err(err)) = task_result + && !err.is_cancelled() + { + warn!("code mode tool task failed: {err}"); } } - _ = async { - if let Some(yield_timer) = yield_timer.as_mut() { - yield_timer.await; - } else { - std::future::pending::<()>().await; - } - } => { - yield_timer = None; - send_yield_response(&cell_id, &mut content_items, &mut response_tx); - } } } let _ = runtime_tx.send(RuntimeCommand::Terminate); cancellation_token.cancel(); - drain_notification_tasks(&mut notification_tasks).await; + finish_callbacks( + &cancellation_token, + &mut notification_tasks, + &mut tool_tasks, + CallbackCompletion::Cancel, + ) + .await; terminate_paused_runtime(&runtime_control_tx, pending_mode); inner.cells.lock().await.remove(&cell_id); inner.delegate.cell_closed(&cell_id); } -async fn drain_notification_tasks(notification_tasks: &mut JoinSet<()>) { - while let Some(result) = notification_tasks.join_next().await { +#[derive(Clone, Copy)] +enum CallbackCompletion { + DrainNotifications, + Cancel, +} + +async fn finish_callbacks( + cancellation_token: &CancellationToken, + notification_tasks: &mut JoinSet<()>, + tool_tasks: &mut JoinSet<()>, + completion: CallbackCompletion, +) { + if matches!(completion, CallbackCompletion::Cancel) { + cancellation_token.cancel(); + } + drain_tasks(notification_tasks, "notification").await; + cancellation_token.cancel(); + drain_tasks(tool_tasks, "tool").await; +} + +async fn drain_tasks(tasks: &mut JoinSet<()>, description: &str) { + while let Some(result) = tasks.join_next().await { if let Err(err) = result && !err.is_cancelled() { - warn!("code mode notification task failed: {err}"); + warn!("code mode {description} task failed: {err}"); } } } @@ -1786,10 +1911,10 @@ image({ event_tx.send(RuntimeEvent::YieldRequested).unwrap(); assert_eq!( initial_response_rx.await.unwrap(), - RuntimeResponse::Yielded { + Ok(RuntimeResponse::Yielded { cell_id: cell_id("cell-1"), content_items: Vec::new(), - } + }) ); let (terminate_response_tx, terminate_response_rx) = oneshot::channel(); @@ -1810,12 +1935,16 @@ image({ assert_eq!( terminate_response.await, - RuntimeResponse::Terminated { + Ok(RuntimeResponse::Terminated { cell_id: cell_id("cell-1"), content_items: Vec::new(), - } + }) ); let _ = runtime_tx.send(RuntimeCommand::Terminate); } } + +#[cfg(test)] +#[path = "service_contract_tests.rs"] +mod contract_tests; diff --git a/codex-rs/code-mode/src/service_contract_tests.rs b/codex-rs/code-mode/src/service_contract_tests.rs new file mode 100644 index 0000000000..752c6cc6ce --- /dev/null +++ b/codex-rs/code-mode/src/service_contract_tests.rs @@ -0,0 +1,634 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use codex_protocol::ToolName; +use pretty_assertions::assert_eq; +use tokio::sync::Notify; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio_util::sync::CancellationToken; + +use super::*; +use crate::CodeModeToolKind; +use crate::ToolDefinition; + +#[derive(Debug, PartialEq)] +enum DelegateEvent { + NotificationStarted, + NotificationCancelled, + ToolStarted, + ToolCancelled, + CellClosed(CellId), +} + +struct BlockingDelegate { + events_tx: mpsc::UnboundedSender, + notification_finished: AtomicBool, + tool_finished: AtomicBool, +} + +struct HeldNotificationDelegate { + events_tx: mpsc::UnboundedSender, + notification_release: Notify, +} + +impl HeldNotificationDelegate { + fn new() -> (Arc, mpsc::UnboundedReceiver) { + let (events_tx, events_rx) = mpsc::unbounded_channel(); + ( + Arc::new(Self { + events_tx, + notification_release: Notify::new(), + }), + events_rx, + ) + } + + fn release_notification(&self) { + self.notification_release.notify_one(); + } +} + +impl CodeModeSessionDelegate for HeldNotificationDelegate { + fn invoke_tool<'a>( + &'a self, + _invocation: CodeModeNestedToolCall, + cancellation_token: CancellationToken, + ) -> ToolInvocationFuture<'a> { + Box::pin(async move { + cancellation_token.cancelled().await; + Err("cancelled".to_string()) + }) + } + + fn notify<'a>( + &'a self, + _call_id: String, + _cell_id: CellId, + _text: String, + cancellation_token: CancellationToken, + ) -> NotificationFuture<'a> { + Box::pin(async move { + let _ = self.events_tx.send(DelegateEvent::NotificationStarted); + cancellation_token.cancelled().await; + let _ = self.events_tx.send(DelegateEvent::NotificationCancelled); + self.notification_release.notified().await; + Ok(()) + }) + } + + fn cell_closed(&self, cell_id: &CellId) { + let _ = self + .events_tx + .send(DelegateEvent::CellClosed(cell_id.clone())); + } +} + +struct CellControlHarness { + event_tx: mpsc::UnboundedSender, + control_tx: mpsc::UnboundedSender, + initial_response_rx: oneshot::Receiver>, + task: tokio::task::JoinHandle<()>, + _runtime_event_rx: mpsc::UnboundedReceiver, +} + +fn spawn_cell_control_harness( + initial_yield_time_ms: Option, + delegate: Arc, +) -> CellControlHarness { + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let (control_tx, control_rx) = mpsc::unbounded_channel(); + let (initial_response_tx, initial_response_rx) = oneshot::channel(); + let (runtime_event_tx, runtime_event_rx) = mpsc::unbounded_channel(); + let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = spawn_runtime( + HashMap::new(), + execute_request("await new Promise(() => {});"), + runtime_event_tx, + PendingRuntimeMode::Continue, + ) + .unwrap(); + let inner = Arc::new(Inner { + stored_values: Mutex::new(HashMap::new()), + cells: Mutex::new(HashMap::new()), + delegate, + shutting_down: AtomicBool::new(false), + next_cell_id: AtomicU64::new(1), + }); + let task = tokio::spawn(run_cell_control( + inner, + CellControlContext { + cell_id: cell_id("1"), + runtime_tx, + runtime_control_tx, + pending_mode: PendingRuntimeMode::Continue, + runtime_terminate_handle, + cancellation_token: CancellationToken::new(), + }, + event_rx, + control_rx, + CellResponseSender::Runtime(initial_response_tx), + initial_yield_time_ms, + )); + + CellControlHarness { + event_tx, + control_tx, + initial_response_rx, + task, + _runtime_event_rx: runtime_event_rx, + } +} + +impl BlockingDelegate { + fn new() -> (Arc, mpsc::UnboundedReceiver) { + let (events_tx, events_rx) = mpsc::unbounded_channel(); + ( + Arc::new(Self { + events_tx, + notification_finished: AtomicBool::new(false), + tool_finished: AtomicBool::new(false), + }), + events_rx, + ) + } +} + +impl CodeModeSessionDelegate for BlockingDelegate { + fn invoke_tool<'a>( + &'a self, + _invocation: CodeModeNestedToolCall, + cancellation_token: CancellationToken, + ) -> ToolInvocationFuture<'a> { + Box::pin(async move { + let _ = self.events_tx.send(DelegateEvent::ToolStarted); + cancellation_token.cancelled().await; + self.tool_finished.store(true, Ordering::Release); + let _ = self.events_tx.send(DelegateEvent::ToolCancelled); + Err("cancelled".to_string()) + }) + } + + fn notify<'a>( + &'a self, + _call_id: String, + _cell_id: CellId, + _text: String, + cancellation_token: CancellationToken, + ) -> NotificationFuture<'a> { + Box::pin(async move { + let _ = self.events_tx.send(DelegateEvent::NotificationStarted); + cancellation_token.cancelled().await; + self.notification_finished.store(true, Ordering::Release); + let _ = self.events_tx.send(DelegateEvent::NotificationCancelled); + Err("cancelled".to_string()) + }) + } + + fn cell_closed(&self, cell_id: &CellId) { + let _ = self + .events_tx + .send(DelegateEvent::CellClosed(cell_id.clone())); + } +} + +fn cell_id(value: &str) -> CellId { + CellId::new(value.to_string()) +} + +fn execute_request(source: &str) -> ExecuteRequest { + ExecuteRequest { + tool_call_id: "call-1".to_string(), + enabled_tools: Vec::new(), + source: source.to_string(), + yield_time_ms: Some(1), + max_output_tokens: None, + } +} + +fn blocking_tool() -> ToolDefinition { + ToolDefinition { + name: "block".to_string(), + tool_name: ToolName::plain("block"), + description: String::new(), + kind: CodeModeToolKind::Function, + input_schema: None, + output_schema: None, + } +} + +async fn next_event(events_rx: &mut mpsc::UnboundedReceiver) -> DelegateEvent { + tokio::time::timeout(Duration::from_secs(2), events_rx.recv()) + .await + .expect("delegate event timeout") + .expect("delegate event channel closed") +} + +#[tokio::test] +async fn yield_timer_preempts_buffered_runtime_output() { + let harness = spawn_cell_control_harness( + Some(/*initial_yield_time_ms*/ 0), + Arc::new(NoopCodeModeSessionDelegate), + ); + harness.event_tx.send(RuntimeEvent::Started).unwrap(); + harness + .event_tx + .send(RuntimeEvent::ContentItem( + FunctionCallOutputContentItem::InputText { + text: "queued output".to_string(), + }, + )) + .unwrap(); + + assert_eq!( + harness.initial_response_rx.await.unwrap(), + Ok(RuntimeResponse::Yielded { + cell_id: cell_id("1"), + content_items: Vec::new(), + }) + ); + + let (termination_tx, termination_rx) = oneshot::channel(); + harness + .control_tx + .send(CellControlCommand::Terminate { + response_tx: termination_tx, + }) + .unwrap(); + drop(harness.event_tx); + assert_eq!( + termination_rx.await.unwrap(), + Ok(RuntimeResponse::Terminated { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "queued output".to_string(), + }], + }) + ); + harness.task.await.unwrap(); +} + +#[tokio::test] +async fn queued_termination_preempts_unobserved_runtime_completion() { + let harness = spawn_cell_control_harness( + Some(/*initial_yield_time_ms*/ 60_000), + Arc::new(NoopCodeModeSessionDelegate), + ); + harness + .event_tx + .send(RuntimeEvent::Result { + stored_value_writes: HashMap::new(), + error_text: None, + }) + .unwrap(); + let (termination_tx, termination_rx) = oneshot::channel(); + harness + .control_tx + .send(CellControlCommand::Terminate { + response_tx: termination_tx, + }) + .unwrap(); + + let terminated = Ok(RuntimeResponse::Terminated { + cell_id: cell_id("1"), + content_items: Vec::new(), + }); + assert_eq!(termination_rx.await.unwrap(), terminated.clone()); + assert_eq!(harness.initial_response_rx.await.unwrap(), terminated); + harness.task.await.unwrap(); +} + +#[tokio::test] +async fn yields_and_resumes() { + let service = CodeModeService::new(); + let cell = service + .execute(execute_request( + r#"text("before"); yield_control(); text("after");"#, + )) + .await + .unwrap(); + + assert_eq!( + cell.initial_response().await.unwrap(), + RuntimeResponse::Yielded { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "before".to_string(), + }], + } + ); + assert_eq!( + service + .wait(WaitRequest { + cell_id: cell_id("1"), + yield_time_ms: 1, + }) + .await + .unwrap(), + WaitOutcome::LiveCell(RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "after".to_string(), + }], + error_text: None, + }) + ); +} + +#[tokio::test] +async fn returns_and_resumes_from_the_pending_frontier() { + let service = CodeModeService::new(); + + assert_eq!( + service + .execute_to_pending(execute_request( + r#" +await new Promise((resolve) => setTimeout(resolve, 60_000)); +text("after"); +"#, + )) + .await + .unwrap(), + ExecuteToPendingOutcome::Pending { + cell_id: cell_id("1"), + content_items: Vec::new(), + pending_tool_call_ids: Vec::new(), + } + ); + + service + .inner + .cells + .lock() + .await + .get(&cell_id("1")) + .unwrap() + .runtime_tx + .send(RuntimeCommand::TimeoutFired { id: 1 }) + .unwrap(); + + assert_eq!( + service + .wait_to_pending(WaitToPendingRequest { + cell_id: cell_id("1"), + }) + .await + .unwrap(), + WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Completed( + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "after".to_string(), + }], + error_text: None, + } + )) + ); +} + +#[tokio::test] +async fn observed_natural_completion_wins_over_termination() { + let (delegate, mut events_rx) = BlockingDelegate::new(); + let harness = + spawn_cell_control_harness(Some(/*initial_yield_time_ms*/ 60_000), delegate.clone()); + harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap(); + + assert_eq!( + harness.initial_response_rx.await.unwrap(), + Ok(RuntimeResponse::Yielded { + cell_id: cell_id("1"), + content_items: Vec::new(), + }) + ); + harness + .event_tx + .send(RuntimeEvent::ContentItem( + FunctionCallOutputContentItem::InputText { + text: "done".to_string(), + }, + )) + .unwrap(); + harness + .event_tx + .send(RuntimeEvent::Result { + stored_value_writes: HashMap::new(), + error_text: None, + }) + .unwrap(); + harness + .event_tx + .send(RuntimeEvent::Notify { + call_id: "notify-1".to_string(), + text: "completion observed".to_string(), + }) + .unwrap(); + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::NotificationStarted + ); + + let (termination_tx, termination_rx) = oneshot::channel(); + harness + .control_tx + .send(CellControlCommand::Terminate { + response_tx: termination_tx, + }) + .unwrap(); + assert_eq!( + termination_rx.await.unwrap(), + Ok(RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "done".to_string(), + }], + error_text: None, + }) + ); + harness.task.await.unwrap(); + assert!(delegate.notification_finished.load(Ordering::Acquire)); + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::NotificationCancelled + ); + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::CellClosed(cell_id("1")) + ); +} + +#[tokio::test] +async fn termination_cancels_pending_callbacks_before_responding() { + let (delegate, mut events_rx) = BlockingDelegate::new(); + let service = CodeModeService::with_delegate(delegate.clone()); + let cell = service + .execute(execute_request( + r#"notify("pending"); await new Promise(() => {});"#, + )) + .await + .unwrap(); + + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::NotificationStarted + ); + assert_eq!( + cell.initial_response().await.unwrap(), + RuntimeResponse::Yielded { + cell_id: cell_id("1"), + content_items: Vec::new(), + } + ); + assert_eq!( + service.terminate(cell_id("1")).await.unwrap(), + WaitOutcome::LiveCell(RuntimeResponse::Terminated { + cell_id: cell_id("1"), + content_items: Vec::new(), + }) + ); + assert!(delegate.notification_finished.load(Ordering::Acquire)); + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::NotificationCancelled + ); + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::CellClosed(cell_id("1")) + ); +} + +#[tokio::test] +async fn repeated_termination_is_rejected_while_callback_cleanup_is_pending() { + let (delegate, mut events_rx) = HeldNotificationDelegate::new(); + let service = Arc::new(CodeModeService::with_delegate(delegate.clone())); + let cell = service + .execute(execute_request( + r#"notify("pending"); await new Promise(() => {});"#, + )) + .await + .unwrap(); + + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::NotificationStarted + ); + assert_eq!( + cell.initial_response().await.unwrap(), + RuntimeResponse::Yielded { + cell_id: cell_id("1"), + content_items: Vec::new(), + } + ); + + let terminating_service = Arc::clone(&service); + let first_termination = + tokio::spawn(async move { terminating_service.terminate(cell_id("1")).await }); + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::NotificationCancelled + ); + + let repeated_termination = service.terminate(cell_id("1")).await; + delegate.release_notification(); + + assert_eq!( + repeated_termination.unwrap_err(), + "exec cell 1 is already terminating" + ); + assert_eq!( + first_termination.await.unwrap().unwrap(), + WaitOutcome::LiveCell(RuntimeResponse::Terminated { + cell_id: cell_id("1"), + content_items: Vec::new(), + }) + ); + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::CellClosed(cell_id("1")) + ); +} + +#[tokio::test] +async fn second_observer_is_rejected_without_displacing_the_first() { + let service = CodeModeService::new(); + let cell = service + .execute(execute_request("await new Promise(() => {});")) + .await + .unwrap(); + + assert_eq!( + cell.initial_response().await.unwrap(), + RuntimeResponse::Yielded { + cell_id: cell_id("1"), + content_items: Vec::new(), + } + ); + + let first_observer = service + .begin_wait(WaitRequest { + cell_id: cell_id("1"), + yield_time_ms: 60_000, + }) + .await; + assert_eq!( + service + .wait(WaitRequest { + cell_id: cell_id("1"), + yield_time_ms: 60_000, + }) + .await + .unwrap_err(), + "exec cell 1 already has an active observer" + ); + + let terminated = RuntimeResponse::Terminated { + cell_id: cell_id("1"), + content_items: Vec::new(), + }; + assert_eq!( + service.terminate(cell_id("1")).await.unwrap(), + WaitOutcome::LiveCell(terminated.clone()) + ); + assert_eq!( + first_observer.await.unwrap(), + WaitOutcome::LiveCell(terminated) + ); +} + +#[tokio::test] +async fn natural_completion_cleans_up_callbacks_before_responding() { + let (delegate, mut events_rx) = BlockingDelegate::new(); + let service = CodeModeService::with_delegate(delegate.clone()); + let cell = service + .execute(ExecuteRequest { + enabled_tools: vec![blocking_tool()], + source: r#"tools.block({}); text("done");"#.to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!(next_event(&mut events_rx).await, DelegateEvent::ToolStarted); + assert_eq!( + cell.initial_response().await.unwrap(), + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "done".to_string(), + }], + error_text: None, + } + ); + assert!(delegate.tool_finished.load(Ordering::Acquire)); + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::ToolCancelled + ); + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::CellClosed(cell_id("1")) + ); +} diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 25fc3ccf2b..6a4121cc5c 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -1411,7 +1411,7 @@ text("phase 3"); #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_yield_timeout_works_for_busy_loop() -> Result<()> { +async fn code_mode_yield_and_termination_are_not_starved_by_runtime_output() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -1420,8 +1420,12 @@ async fn code_mode_yield_timeout_works_for_busy_loop() -> Result<()> { }); let test = builder.build(&server).await?; - let code = r#"// @exec: {"yield_time_ms": 100} -text("phase 1"); + // Exact controller arbitration is covered by deterministic code-mode contract tests. Keep + // this end-to-end load bounded while exercising a substantial runtime output backlog. + let code = r#"// @exec: {"yield_time_ms": 0, "max_output_tokens": 16} +for (let index = 0; index < 16_384; index++) { + text(`event ${index}`); +} while (true) {} "#; @@ -1451,7 +1455,7 @@ while (true) {} let first_request = first_completion.single_request(); let first_items = custom_tool_output_items(&first_request, "call-1"); - assert_eq!(first_items.len(), 2); + assert_eq!(first_items.len(), 1); assert_regex_match( concat!( r"(?s)\A", @@ -1459,7 +1463,6 @@ while (true) {} ), text_item(&first_items, /*index*/ 0), ); - assert_eq!(text_item(&first_items, /*index*/ 1), "phase 1"); let cell_id = extract_running_cell_id(text_item(&first_items, /*index*/ 0)); responses::mount_sse_once( @@ -1491,7 +1494,7 @@ while (true) {} let second_request = second_completion.single_request(); let second_items = function_tool_output_items(&second_request, "call-2"); - assert_eq!(second_items.len(), 1); + assert!(!second_items.is_empty()); assert_regex_match( concat!( r"(?s)\A", From a50671e7488f7e3ec67ed68ef5a62a57adae9310 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 16 Jun 2026 13:45:57 -0700 Subject: [PATCH 03/70] [codex] test exec relative additional permissions (#28587) ## Why Review caught some would-be regressions in changes to unified_exec that weren't surfaced in CI. ## What Add coverage for requesting permissions through unified exec when there are additional permissions. Previously this flow was only tested against shell_command. --- .../core/tests/suite/request_permissions.rs | 67 +++++++++++-------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 1b04582847..afa8e2194d 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -41,6 +41,7 @@ use serde_json::Value; use serde_json::json; use std::fs; use std::path::Path; +use test_case::test_case; fn absolute_path(path: &Path) -> AbsolutePathBuf { AbsolutePathBuf::try_from(path).expect("absolute path") @@ -164,23 +165,6 @@ fn exec_command_event_with_missing_additional_permissions( Ok(ev_function_call(call_id, "exec_command", &args_str)) } -fn shell_event_with_raw_request_permissions( - call_id: &str, - command: &str, - workdir: Option<&str>, - additional_permissions: Value, -) -> Result { - let args = json!({ - "command": command, - "workdir": workdir, - "timeout_ms": 1_000_u64, - "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, - "additional_permissions": additional_permissions, - }); - let args_str = serde_json::to_string(&args)?; - Ok(ev_function_call(call_id, "shell_command", &args_str)) -} - async fn submit_turn( test: &TestCodex, prompt: &str, @@ -507,8 +491,18 @@ async fn request_permissions_tool_is_auto_denied_when_granular_request_permissio Ok(()) } +#[derive(Clone, Copy, Debug)] +enum AdditionalPermissionsCommandTool { + ShellCommand, + ExecCommand, +} + +#[test_case(AdditionalPermissionsCommandTool::ShellCommand ; "shell_command")] +#[test_case(AdditionalPermissionsCommandTool::ExecCommand ; "exec_command")] #[tokio::test(flavor = "current_thread")] -async fn relative_additional_permissions_resolve_against_tool_workdir() -> Result<()> { +async fn relative_additional_permissions_resolve_against_tool_workdir( + command_tool: AdditionalPermissionsCommandTool, +) -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -542,6 +536,12 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul let call_id = "request_permissions_relative_workdir"; let command = "touch relative-write.txt"; + let workdir = "nested"; + let additional_permissions = json!({ + "file_system": { + "write": ["."], + }, + }); let expected_permissions = PermissionProfile { file_system: Some(FileSystemPermissions::from_read_write_roots( /*read*/ None, @@ -549,16 +549,27 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul )), ..Default::default() }; - let event = shell_event_with_raw_request_permissions( - call_id, - command, - Some("nested"), - json!({ - "file_system": { - "write": ["."], - }, - }), - )?; + let (tool_name, arguments) = match command_tool { + AdditionalPermissionsCommandTool::ShellCommand => ( + "shell_command", + json!({ + "command": command, + "workdir": workdir, + "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, + "additional_permissions": additional_permissions, + }), + ), + AdditionalPermissionsCommandTool::ExecCommand => ( + "exec_command", + json!({ + "cmd": command, + "workdir": workdir, + "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, + "additional_permissions": additional_permissions, + }), + ), + }; + let event = ev_function_call(call_id, tool_name, &serde_json::to_string(&arguments)?); let _ = mount_sse_once( &server, From 322b83de5e7cdbc543f5563c1456b0e5a1496c1e Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 16 Jun 2026 13:47:43 -0700 Subject: [PATCH 04/70] Clarify model-generated and legacy app path types (#28577) ## Why `ApiPathString` kind of implies that it can be used anywhere we pull a path out of JSON, but it's not really appropriate for tool arguments when the model might generate relative paths. Prefer `String` for model-generated paths and we can handle the conversion per feature for now and define a shared abstraction later if it makes sense. # What Rename `ApiPathString` to `AppLegacyPathString` to clarify its role. Expand the `path-types` skill to tell the model to leave tool args as bare strings. --- .codex/skills/path-types/SKILL.md | 4 +- ...CommandExecutionRequestApprovalParams.json | 12 ++--- .../PermissionsRequestApprovalParams.json | 12 ++--- .../PermissionsRequestApprovalResponse.json | 12 ++--- .../schema/json/ServerNotification.json | 12 ++--- .../schema/json/ServerRequest.json | 12 ++--- .../codex_app_server_protocol.schemas.json | 12 ++--- .../codex_app_server_protocol.v2.schemas.json | 12 ++--- ...anApprovalReviewCompletedNotification.json | 12 ++--- ...dianApprovalReviewStartedNotification.json | 12 ++--- ...piPathString.ts => LegacyAppPathString.ts} | 2 +- .../schema/typescript/index.ts | 2 +- .../v2/AdditionalFileSystemPermissions.ts | 6 +-- .../schema/typescript/v2/FileSystemPath.ts | 4 +- .../src/protocol/v2/permissions.rs | 28 ++++++---- .../src/protocol/v2/tests.rs | 6 +-- .../core/tests/common/test_environment.rs | 6 +-- codex-rs/tui/src/chatwidget/tests.rs | 2 +- .../src/chatwidget/tests/approval_requests.rs | 8 +-- .../utils/path-uri/src/api_path_string.rs | 37 +++++++------ .../path-uri/src/api_path_string_tests.rs | 53 ++++++++++--------- codex-rs/utils/path-uri/src/lib.rs | 4 +- 22 files changed, 144 insertions(+), 126 deletions(-) rename codex-rs/app-server-protocol/schema/typescript/{ApiPathString.ts => LegacyAppPathString.ts} (96%) diff --git a/.codex/skills/path-types/SKILL.md b/.codex/skills/path-types/SKILL.md index e604ce79bf..bc3809d3d2 100644 --- a/.codex/skills/path-types/SKILL.md +++ b/.codex/skills/path-types/SKILL.md @@ -9,10 +9,12 @@ Apply this guidance when defining new types. Change existing code only when expl and keep edits minimal and proportional. Treat these rules as the target state of an ongoing migration; if compliance is difficult, ask the user how to proceed. -- In app-server protocol types, use `ApiPathString` for backwards compatibility during the URI +- In app-server protocol types, use `LegacyAppPathString` for backwards compatibility during the URI migration. At the protocol boundary, convert it to `PathUri` and use `PathUri` internally. For host-local logic, such as some config values, use `AbsolutePathBuf` or `PathBuf` instead. - In exec-server protocol types, use `PathUri`. Internally, use `PathUri` or `AbsolutePathBuf` as appropriate. - In dependencies shared by both servers, use `PathUri` or separate APIs that decouple their use cases. +- Tool call arguments that the model is expected to generate should be deserialized as regular + `String`s with feature-specific path handling code. diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index c59141103c..323247e16c 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -27,7 +27,7 @@ "read": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -37,7 +37,7 @@ "write": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -84,9 +84,6 @@ }, "type": "object" }, - "ApiPathString": { - "type": "string" - }, "CommandAction": { "oneOf": [ { @@ -289,7 +286,7 @@ { "properties": { "path": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -472,6 +469,9 @@ } ] }, + "LegacyAppPathString": { + "type": "string" + }, "NetworkApprovalContext": { "properties": { "host": { diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json index 4d7eff3eb5..29b71c8912 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json @@ -27,7 +27,7 @@ "read": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -37,7 +37,7 @@ "write": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -58,9 +58,6 @@ }, "type": "object" }, - "ApiPathString": { - "type": "string" - }, "FileSystemAccessMode": { "enum": [ "read", @@ -74,7 +71,7 @@ { "properties": { "path": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -257,6 +254,9 @@ } ] }, + "LegacyAppPathString": { + "type": "string" + }, "RequestPermissionProfile": { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json index e0bfd161f2..909cb5f670 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json @@ -23,7 +23,7 @@ "read": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -33,7 +33,7 @@ "write": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -54,9 +54,6 @@ }, "type": "object" }, - "ApiPathString": { - "type": "string" - }, "FileSystemAccessMode": { "enum": [ "read", @@ -70,7 +67,7 @@ { "properties": { "path": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -278,6 +275,9 @@ }, "type": "object" }, + "LegacyAppPathString": { + "type": "string" + }, "PermissionGrantScope": { "enum": [ "turn", diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 1cf3d96c81..d64befa0a0 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -107,7 +107,7 @@ "read": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -117,7 +117,7 @@ "write": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -164,9 +164,6 @@ "AgentPath": { "type": "string" }, - "ApiPathString": { - "type": "string" - }, "AppBranding": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { @@ -1345,7 +1342,7 @@ { "properties": { "path": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -2339,6 +2336,9 @@ ], "type": "object" }, + "LegacyAppPathString": { + "type": "string" + }, "McpServerOauthLoginCompletedNotification": { "properties": { "error": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 34ccaa47e9..70f54f35ce 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -27,7 +27,7 @@ "read": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -37,7 +37,7 @@ "write": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -84,9 +84,6 @@ }, "type": "object" }, - "ApiPathString": { - "type": "string" - }, "ApplyPatchApprovalParams": { "properties": { "callId": { @@ -643,7 +640,7 @@ { "properties": { "path": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -826,6 +823,9 @@ } ] }, + "LegacyAppPathString": { + "type": "string" + }, "McpElicitationArrayType": { "enum": [ "array" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index eb659fda3b..97c4eee3cf 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -6071,7 +6071,7 @@ "read": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/v2/ApiPathString" + "$ref": "#/definitions/v2/LegacyAppPathString" }, "type": [ "array", @@ -6081,7 +6081,7 @@ "write": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/v2/ApiPathString" + "$ref": "#/definitions/v2/LegacyAppPathString" }, "type": [ "array", @@ -6193,9 +6193,6 @@ }, "type": "object" }, - "ApiPathString": { - "type": "string" - }, "AppBranding": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { @@ -9505,7 +9502,7 @@ { "properties": { "path": { - "$ref": "#/definitions/v2/ApiPathString" + "$ref": "#/definitions/v2/LegacyAppPathString" }, "type": { "enum": [ @@ -11184,6 +11181,9 @@ "title": "ItemStartedNotification", "type": "object" }, + "LegacyAppPathString": { + "type": "string" + }, "ListMcpServerStatusParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e0562760f6..1f5e7ac2bb 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -273,7 +273,7 @@ "read": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -283,7 +283,7 @@ "write": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -395,9 +395,6 @@ }, "type": "object" }, - "ApiPathString": { - "type": "string" - }, "AppBranding": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { @@ -5798,7 +5795,7 @@ { "properties": { "path": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -7637,6 +7634,9 @@ "title": "ItemStartedNotification", "type": "object" }, + "LegacyAppPathString": { + "type": "string" + }, "ListMcpServerStatusParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json index 717c4c987a..48a3193cc7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json @@ -27,7 +27,7 @@ "read": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -37,7 +37,7 @@ "write": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -58,9 +58,6 @@ }, "type": "object" }, - "ApiPathString": { - "type": "string" - }, "AutoReviewDecisionSource": { "description": "[UNSTABLE] Source that produced a terminal approval auto-review decision.", "enum": [ @@ -81,7 +78,7 @@ { "properties": { "path": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -536,6 +533,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "NetworkApprovalProtocol": { "enum": [ "http", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json index 28fec1275b..9f534e517d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json @@ -27,7 +27,7 @@ "read": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -37,7 +37,7 @@ "write": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -58,9 +58,6 @@ }, "type": "object" }, - "ApiPathString": { - "type": "string" - }, "FileSystemAccessMode": { "enum": [ "read", @@ -74,7 +71,7 @@ { "properties": { "path": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -529,6 +526,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "NetworkApprovalProtocol": { "enum": [ "http", diff --git a/codex-rs/app-server-protocol/schema/typescript/ApiPathString.ts b/codex-rs/app-server-protocol/schema/typescript/LegacyAppPathString.ts similarity index 96% rename from codex-rs/app-server-protocol/schema/typescript/ApiPathString.ts rename to codex-rs/app-server-protocol/schema/typescript/LegacyAppPathString.ts index ba5a3a961c..04e465b8a2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ApiPathString.ts +++ b/codex-rs/app-server-protocol/schema/typescript/LegacyAppPathString.ts @@ -23,4 +23,4 @@ * [`AbsolutePathBuf`]. Relative path text remains valid until an operation * such as [`Self::to_path_uri`] requires an absolute path. */ -export type ApiPathString = string; +export type LegacyAppPathString = string; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index c3bc3ec761..786d4c72a8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -4,7 +4,6 @@ export type { AbsolutePathBuf } from "./AbsolutePathBuf"; export type { AgentMessageInputContent } from "./AgentMessageInputContent"; export type { AgentPath } from "./AgentPath"; export type { AmazonBedrockCredentialSource } from "./AmazonBedrockCredentialSource"; -export type { ApiPathString } from "./ApiPathString"; export type { ApplyPatchApprovalParams } from "./ApplyPatchApprovalParams"; export type { ApplyPatchApprovalResponse } from "./ApplyPatchApprovalResponse"; export type { AuthMode } from "./AuthMode"; @@ -43,6 +42,7 @@ export type { InitializeParams } from "./InitializeParams"; export type { InitializeResponse } from "./InitializeResponse"; export type { InputModality } from "./InputModality"; export type { InternalSessionSource } from "./InternalSessionSource"; +export type { LegacyAppPathString } from "./LegacyAppPathString"; export type { LocalShellAction } from "./LocalShellAction"; export type { LocalShellExecAction } from "./LocalShellExecAction"; export type { LocalShellStatus } from "./LocalShellStatus"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalFileSystemPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalFileSystemPermissions.ts index 68b1fb6603..f4ca94efd3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalFileSystemPermissions.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalFileSystemPermissions.ts @@ -1,15 +1,15 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ApiPathString } from "../ApiPathString"; +import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { FileSystemSandboxEntry } from "./FileSystemSandboxEntry"; export type AdditionalFileSystemPermissions = { /** * This will be removed in favor of `entries`. */ -read: Array | null, +read: Array | null, /** * This will be removed in favor of `entries`. */ -write: Array | null, globScanMaxDepth?: number, entries?: Array, }; +write: Array | null, globScanMaxDepth?: number, entries?: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FileSystemPath.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FileSystemPath.ts index 0733c02040..cf391512bb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FileSystemPath.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FileSystemPath.ts @@ -1,7 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ApiPathString } from "../ApiPathString"; +import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { FileSystemSpecialPath } from "./FileSystemSpecialPath"; -export type FileSystemPath = { "type": "path", path: ApiPathString, } | { "type": "glob_pattern", pattern: string, } | { "type": "special", value: FileSystemSpecialPath, }; +export type FileSystemPath = { "type": "path", path: LegacyAppPathString, } | { "type": "glob_pattern", pattern: string, } | { "type": "special", value: FileSystemSpecialPath, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index cc93e3a6c9..7434fe8653 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -16,7 +16,7 @@ use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_path_uri::ApiPathString; +use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathConvention; use schemars::JsonSchema; use serde::Deserialize; @@ -57,9 +57,9 @@ impl From for NetworkApprovalContext { #[ts(export_to = "v2/")] pub struct AdditionalFileSystemPermissions { /// This will be removed in favor of `entries`. - pub read: Option>, + pub read: Option>, /// This will be removed in favor of `entries`. - pub write: Option>, + pub write: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub glob_scan_max_depth: Option, @@ -78,7 +78,7 @@ impl From> for AdditionalFileSystemPe if let Some(paths) = read.as_ref() { entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { path: FileSystemPath::Path { - path: ApiPathString::from_abs_path(path), + path: LegacyAppPathString::from_abs_path(path), }, access: FileSystemAccessMode::Read, })); @@ -86,14 +86,24 @@ impl From> for AdditionalFileSystemPe if let Some(paths) = write.as_ref() { entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { path: FileSystemPath::Path { - path: ApiPathString::from_abs_path(path), + path: LegacyAppPathString::from_abs_path(path), }, access: FileSystemAccessMode::Write, })); } Self { - read: read.map(|paths| paths.iter().map(ApiPathString::from_abs_path).collect()), - write: write.map(|paths| paths.iter().map(ApiPathString::from_abs_path).collect()), + read: read.map(|paths| { + paths + .iter() + .map(LegacyAppPathString::from_abs_path) + .collect() + }), + write: write.map(|paths| { + paths + .iter() + .map(LegacyAppPathString::from_abs_path) + .collect() + }), glob_scan_max_depth: None, entries: Some(entries), } @@ -276,7 +286,7 @@ impl From for CoreFileSystemSpecialPath { #[ts(export_to = "v2/")] // TODO(anp): Rename this type to distinguish it from the generic protocol FileSystemPath. pub enum FileSystemPath { - Path { path: ApiPathString }, + Path { path: LegacyAppPathString }, GlobPattern { pattern: String }, Special { value: FileSystemSpecialPath }, } @@ -286,7 +296,7 @@ impl From> for FileSystemPath { fn from(value: CoreFileSystemPath) -> Self { match value { CoreFileSystemPath::Path { path } => Self::Path { - path: ApiPathString::from_abs_path(&path), + path: LegacyAppPathString::from_abs_path(&path), }, CoreFileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern }, CoreFileSystemPath::Special { value } => Self::Special { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 2567e386c8..c690871689 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -35,7 +35,7 @@ use codex_protocol::user_input::UserInput as CoreUserInput; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; -use codex_utils_path_uri::ApiPathString; +use codex_utils_path_uri::LegacyAppPathString; use pretty_assertions::assert_eq; use serde_json::Value as JsonValue; use serde_json::json; @@ -579,8 +579,8 @@ fn additional_file_system_permissions_populates_entries_for_legacy_roots() { ); let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone()); - let read_only_api_path = ApiPathString::from_abs_path(&read_only_path); - let read_write_api_path = ApiPathString::from_abs_path(&read_write_path); + let read_only_api_path = LegacyAppPathString::from_abs_path(&read_only_path); + let read_write_api_path = LegacyAppPathString::from_abs_path(&read_write_path); assert_eq!( permissions, diff --git a/codex-rs/core/tests/common/test_environment.rs b/codex-rs/core/tests/common/test_environment.rs index 386e4a0eeb..ad2a335f56 100644 --- a/codex-rs/core/tests/common/test_environment.rs +++ b/codex-rs/core/tests/common/test_environment.rs @@ -1,7 +1,7 @@ use std::ffi::OsStr; use anyhow::Result; -use codex_utils_path_uri::ApiPathString; +use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; @@ -28,7 +28,7 @@ impl TestEnvironment { } } - pub(crate) fn remote_cwd(&self, instance_id: &str) -> Result> { + pub(crate) fn remote_cwd(&self, instance_id: &str) -> Result> { let path_uri = match self { Self::Local => return Ok(None), Self::Docker { .. } => { @@ -40,7 +40,7 @@ impl TestEnvironment { PathUri::parse(&format!("file:///C:/codex-core-test-cwd-{instance_id}"))? } }; - Ok(Some(ApiPathString::from_path_uri( + Ok(Some(LegacyAppPathString::from_path_uri( &path_uri, self.path_convention(), )?)) diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ab9fa2a79f..e4196224dd 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -164,7 +164,7 @@ pub(super) use codex_terminal_detection::TerminalInfo; pub(super) use codex_terminal_detection::TerminalName; pub(super) use codex_utils_absolute_path::AbsolutePathBuf; pub(super) use codex_utils_approval_presets::builtin_approval_presets; -pub(super) use codex_utils_path_uri::ApiPathString; +pub(super) use codex_utils_path_uri::LegacyAppPathString; pub(super) use crossterm::event::KeyCode; pub(super) use crossterm::event::KeyEvent; pub(super) use crossterm::event::KeyModifiers; diff --git a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs index 69d75ddc67..bde795ca95 100644 --- a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs +++ b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs @@ -89,8 +89,8 @@ fn app_server_exec_approval_request_preserves_permissions_context() { .expect("absolute read path"); let write_path = AbsolutePathBuf::try_from(PathBuf::from(test_path_display("/tmp/write"))) .expect("absolute write path"); - let read_api_path = ApiPathString::from_abs_path(&read_path); - let write_api_path = ApiPathString::from_abs_path(&write_path); + let read_api_path = LegacyAppPathString::from_abs_path(&read_path); + let write_api_path = LegacyAppPathString::from_abs_path(&write_path); let request = exec_approval_request_from_params( AppServerCommandExecutionRequestApprovalParams { thread_id: "thread-1".to_string(), @@ -276,8 +276,8 @@ fn app_server_request_permissions_preserves_file_system_permissions() { .expect("absolute read path"); let write_path = AbsolutePathBuf::try_from(PathBuf::from(test_path_display("/tmp/write"))) .expect("absolute write path"); - let read_api_path = ApiPathString::from_abs_path(&read_path); - let write_api_path = ApiPathString::from_abs_path(&write_path); + let read_api_path = LegacyAppPathString::from_abs_path(&read_path); + let write_api_path = LegacyAppPathString::from_abs_path(&write_path); let cwd = AbsolutePathBuf::try_from(PathBuf::from(test_path_display("/tmp"))).expect("absolute cwd"); diff --git a/codex-rs/utils/path-uri/src/api_path_string.rs b/codex-rs/utils/path-uri/src/api_path_string.rs index 0304777769..f9ae8690c8 100644 --- a/codex-rs/utils/path-uri/src/api_path_string.rs +++ b/codex-rs/utils/path-uri/src/api_path_string.rs @@ -30,9 +30,9 @@ use ts_rs::TS; #[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize, TS)] #[serde(transparent)] #[ts(type = "string")] -pub struct ApiPathString(String); +pub struct LegacyAppPathString(String); -impl ApiPathString { +impl LegacyAppPathString { /// Renders an absolute path using the current host's path convention. pub fn from_abs_path(path: &AbsolutePathBuf) -> Self { Self(path.to_string_lossy().into_owned()) @@ -48,7 +48,7 @@ impl ApiPathString { pub fn from_path_uri( path: &PathUri, convention: PathConvention, - ) -> Result { + ) -> Result { if let Some(path_bytes) = path.opaque_fallback_bytes() { return render_opaque_fallback(path, &path_bytes, convention).map(Self); } @@ -61,12 +61,15 @@ impl ApiPathString { /// Parses this API string as an absolute path using the requested native /// path convention and returns its canonical path URI. - pub fn to_path_uri(&self, convention: PathConvention) -> Result { + pub fn to_path_uri( + &self, + convention: PathConvention, + ) -> Result { let path = match convention { PathConvention::Posix => parse_posix_path(&self.0), PathConvention::Windows => parse_windows_path(&self.0), }; - path.ok_or_else(|| ApiPathStringError::InvalidNativePath { + path.ok_or_else(|| LegacyAppPathStringError::InvalidNativePath { path: self.0.clone(), convention, }) @@ -102,7 +105,7 @@ impl ApiPathString { } } -impl From for ApiPathString { +impl From for LegacyAppPathString { fn from(path: AbsolutePathBuf) -> Self { Self::from_abs_path(&path) } @@ -194,7 +197,7 @@ fn render_opaque_fallback( path: &PathUri, path_bytes: &[u8], convention: PathConvention, -) -> Result { +) -> Result { let rendered = match convention { PathConvention::Posix if path_bytes.starts_with(b"/") => { Some(String::from_utf8_lossy(path_bytes).into_owned()) @@ -202,7 +205,7 @@ fn render_opaque_fallback( PathConvention::Windows => render_windows_opaque_fallback(path_bytes), PathConvention::Posix => None, }; - rendered.ok_or_else(|| ApiPathStringError::OpaqueFallback { + rendered.ok_or_else(|| LegacyAppPathStringError::OpaqueFallback { path: path.to_string(), }) } @@ -238,13 +241,13 @@ fn is_windows_separator(character: u16) -> bool { character == u16::from(b'\\') || character == u16::from(b'/') } -impl fmt::Display for ApiPathString { +impl fmt::Display for LegacyAppPathString { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } -impl Serialize for ApiPathString { +impl Serialize for LegacyAppPathString { fn serialize(&self, serializer: S) -> Result where S: Serializer, @@ -253,9 +256,9 @@ impl Serialize for ApiPathString { } } -impl JsonSchema for ApiPathString { +impl JsonSchema for LegacyAppPathString { fn schema_name() -> String { - "ApiPathString".to_string() + "LegacyAppPathString".to_string() } fn json_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { @@ -263,7 +266,7 @@ impl JsonSchema for ApiPathString { } } -fn render_posix_path(path: &PathUri) -> Result { +fn render_posix_path(path: &PathUri) -> Result { let url = path.to_url(); // POSIX file paths do not have a UNC authority, so `file://server/share` // cannot be represented as `/share` without losing the server identity. @@ -281,7 +284,7 @@ fn render_posix_path(path: &PathUri) -> Result { Ok(rendered) } -fn render_windows_path(path: &PathUri) -> Result { +fn render_windows_path(path: &PathUri) -> Result { let url = path.to_url(); let mut segments = path_segments(&url); let mut rendered = String::new(); @@ -342,15 +345,15 @@ fn decode_native_segment(segment: &str) -> String { String::from_utf8_lossy(&bytes).into_owned() } -fn incompatible_convention(path: &PathUri, convention: PathConvention) -> ApiPathStringError { - ApiPathStringError::IncompatibleConvention { +fn incompatible_convention(path: &PathUri, convention: PathConvention) -> LegacyAppPathStringError { + LegacyAppPathStringError::IncompatibleConvention { path: path.to_string(), convention, } } #[derive(Debug, Error, PartialEq, Eq)] -pub enum ApiPathStringError { +pub enum LegacyAppPathStringError { #[error("opaque fallback path URI `{path}` cannot be recovered as a native path")] OpaqueFallback { path: String }, #[error("path URI `{path}` cannot be rendered using {convention} path syntax")] diff --git a/codex-rs/utils/path-uri/src/api_path_string_tests.rs b/codex-rs/utils/path-uri/src/api_path_string_tests.rs index 3fbdb3dec0..42829a783e 100644 --- a/codex-rs/utils/path-uri/src/api_path_string_tests.rs +++ b/codex-rs/utils/path-uri/src/api_path_string_tests.rs @@ -301,21 +301,23 @@ fn renders_native_paths_from_shared_cases() { for case in RENDER_CASES { let path = PathUri::parse(case.uri).expect("valid file URI"); let expected = match case.expected { - RenderExpectation::RoundTrip(rendered) => Ok(ApiPathString(rendered.to_string())), - RenderExpectation::RenderOnly(rendered) => Ok(ApiPathString(rendered.to_string())), + RenderExpectation::RoundTrip(rendered) => Ok(LegacyAppPathString(rendered.to_string())), + RenderExpectation::RenderOnly(rendered) => { + Ok(LegacyAppPathString(rendered.to_string())) + } RenderExpectation::Error(ExpectedError::OpaqueFallback) => { - Err(ApiPathStringError::OpaqueFallback { + Err(LegacyAppPathStringError::OpaqueFallback { path: path.to_string(), }) } RenderExpectation::Error(ExpectedError::IncompatibleConvention) => { - Err(ApiPathStringError::IncompatibleConvention { + Err(LegacyAppPathStringError::IncompatibleConvention { path: path.to_string(), convention: case.convention, }) } }; - let actual = ApiPathString::from_path_uri(&path, case.convention); + let actual = LegacyAppPathString::from_path_uri(&path, case.convention); assert_eq!(actual, expected, "rendering {case:?}"); if let Ok(rendered) = &actual { @@ -327,14 +329,15 @@ fn renders_native_paths_from_shared_cases() { } if let RenderExpectation::RoundTrip(rendered) = case.expected { - let api_path = serde_json::from_value::(serde_json::json!(rendered)) - .expect("native path should deserialize from API text"); + let api_path = + serde_json::from_value::(serde_json::json!(rendered)) + .expect("native path should deserialize from API text"); let reparsed = api_path .to_path_uri(case.convention) .expect("native path should parse using its convention"); assert_eq!(reparsed, path, "parsing {case:?}"); assert_eq!( - ApiPathString::from_path_uri(&reparsed, case.convention), + LegacyAppPathString::from_path_uri(&reparsed, case.convention), Ok(api_path), "round-tripping {case:?}" ); @@ -345,7 +348,7 @@ fn renders_native_paths_from_shared_cases() { #[test] fn relative_api_path_serializes_and_deserializes_unchanged() { for raw_path in [".", "subdir", "subdir/file.rs"] { - let path = serde_json::from_value::(serde_json::json!(raw_path)) + let path = serde_json::from_value::(serde_json::json!(raw_path)) .expect("relative API path should deserialize"); assert_eq!( @@ -358,13 +361,13 @@ fn relative_api_path_serializes_and_deserializes_unchanged() { #[test] fn relative_api_path_is_invalid_when_converted_to_a_path_uri() { let raw_path = "subdir"; - let path = serde_json::from_value::(serde_json::json!(raw_path)) + let path = serde_json::from_value::(serde_json::json!(raw_path)) .expect("relative API path should deserialize"); assert_eq!(path.infer_absolute_path_convention(), None); assert_eq!( path.to_path_uri(PathConvention::Posix), - Err(ApiPathStringError::InvalidNativePath { + Err(LegacyAppPathStringError::InvalidNativePath { path: raw_path.to_string(), convention: PathConvention::Posix, }) @@ -377,13 +380,13 @@ fn other_non_absolute_api_paths_cannot_be_converted_to_path_uris() { (r"workspace\file.rs", PathConvention::Windows), (r"C:file.rs", PathConvention::Windows), ] { - let path = serde_json::from_value::(serde_json::json!(raw_path)) + let path = serde_json::from_value::(serde_json::json!(raw_path)) .expect("API path should deserialize without validation"); assert_eq!(path.infer_absolute_path_convention(), None); assert_eq!( path.to_path_uri(convention), - Err(ApiPathStringError::InvalidNativePath { + Err(LegacyAppPathStringError::InvalidNativePath { path: raw_path.to_string(), convention, }) @@ -409,7 +412,7 @@ fn infers_absolute_path_conventions_from_api_text() { (r"C:file.rs", None), (r"\rooted-without-drive", None), ] { - let path = serde_json::from_value::(serde_json::json!(raw_path)) + let path = serde_json::from_value::(serde_json::json!(raw_path)) .expect("API path should deserialize without validation"); assert_eq!( @@ -426,7 +429,7 @@ fn foreign_absolute_syntax_deserializes_without_host_interpretation() { (r"C:\workspace\file.rs", PathConvention::Windows), ("/workspace/file.rs", PathConvention::Posix), ] { - let path = serde_json::from_value::(serde_json::json!(raw_path)) + let path = serde_json::from_value::(serde_json::json!(raw_path)) .expect("foreign API path should deserialize"); assert_eq!(path.as_str(), raw_path); @@ -444,8 +447,8 @@ fn renders_an_absolute_path_using_the_host_convention() { .expect("native path should be absolute"); assert_eq!( - ApiPathString::from(path), - ApiPathString(native_path.to_string()) + LegacyAppPathString::from(path), + LegacyAppPathString(native_path.to_string()) ); } @@ -464,19 +467,19 @@ fn renders_native_non_unicode_windows_fallback_lossily() { AbsolutePathBuf::from_absolute_path_checked(native_path).expect("absolute native path"); assert_eq!( - ApiPathString::from_abs_path(&native_path), - ApiPathString(r"C:\bad\�".to_string()) + LegacyAppPathString::from_abs_path(&native_path), + LegacyAppPathString(r"C:\bad\�".to_string()) ); let path = PathUri::from_abs_path(&native_path); assert_eq!( - ApiPathString::from_path_uri(&path, PathConvention::Windows), - Ok(ApiPathString(r"C:\bad\�".to_string())) + LegacyAppPathString::from_path_uri(&path, PathConvention::Windows), + Ok(LegacyAppPathString(r"C:\bad\�".to_string())) ); assert_eq!( - ApiPathString::from_path_uri(&path, PathConvention::Posix), - Err(ApiPathStringError::OpaqueFallback { + LegacyAppPathString::from_path_uri(&path, PathConvention::Posix), + Err(LegacyAppPathStringError::OpaqueFallback { path: path.to_string(), }) ); @@ -485,13 +488,13 @@ fn renders_native_non_unicode_windows_fallback_lossily() { #[test] fn serializes_and_deserializes_as_a_string() { let path = PathUri::parse("file:///workspace/src/lib.rs").expect("valid file URI"); - let rendered = ApiPathString::from_path_uri(&path, PathConvention::Posix) + let rendered = LegacyAppPathString::from_path_uri(&path, PathConvention::Posix) .expect("POSIX URI should render"); let json = serde_json::to_string(&rendered).expect("rendered path should serialize"); assert_eq!(json, r#""/workspace/src/lib.rs""#); assert_eq!( - serde_json::from_str::(&json) + serde_json::from_str::(&json) .expect("rendered path should deserialize from a string"), rendered ); diff --git a/codex-rs/utils/path-uri/src/lib.rs b/codex-rs/utils/path-uri/src/lib.rs index c37ad31b45..b835530d5b 100644 --- a/codex-rs/utils/path-uri/src/lib.rs +++ b/codex-rs/utils/path-uri/src/lib.rs @@ -19,8 +19,8 @@ use url::Url; mod api_path_string; -pub use api_path_string::ApiPathString; -pub use api_path_string::ApiPathStringError; +pub use api_path_string::LegacyAppPathString; +pub use api_path_string::LegacyAppPathStringError; pub use api_path_string::PathConvention; pub const FILE_SCHEME: &str = "file"; From 33d50234a8ceaeea9d6bd86b3dd8e4cf59e3bdf7 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 16 Jun 2026 14:05:32 -0700 Subject: [PATCH 05/70] Record invariants for path migration. (#28589) ## Why Help Codex understand how to execute the migration to support cross-OS paths. ## What Expand the path-types skill with our goals and constraints. --- .codex/skills/path-types/SKILL.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.codex/skills/path-types/SKILL.md b/.codex/skills/path-types/SKILL.md index bc3809d3d2..ccf1a9f41d 100644 --- a/.codex/skills/path-types/SKILL.md +++ b/.codex/skills/path-types/SKILL.md @@ -18,3 +18,22 @@ migration; if compliance is difficult, ask the user how to proceed. cases. - Tool call arguments that the model is expected to generate should be deserialized as regular `String`s with feature-specific path handling code. + +## Migration requirements + +Keep these requirements in mind while migrating code to conform with the above guidelines: + +* existing app-server clients keep sending and receiving legacy native-path strings +* app-server can retain and manipulate foreign-platform path URIs +* exec-server APIs use file:// URIs +* local-only operation must not change model-visible text +* model tool arguments may contain raw relative or absolute paths for any OS +* path reasoning must work before the related environment has come online +* URIs cannot explicitly encode the executor’s path convention or operating system +* users must not configure the environment’s OS/path convention explicitly + +It is OK if the conversion between paths and URIs is somewhat lossy as long as it will do the right +thing for real users. + +Migrating to URIs should not add significant new failure modes. We will need to surface errors in +some places that were previously infallible but it should be kept to a minimum. From f8850cab1d0f192a799122ff96cb27061b9366eb Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 16 Jun 2026 14:42:28 -0700 Subject: [PATCH 06/70] app-server: preserve target-native environment cwd (#28146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why app-server may run on a different OS from the selected exec-server environment. Parsing that environment’s cwd with the Codex host’s path rules prevents thread startup. ## What Carry environment cwd values as `LegacyAppPathString` at the app-server boundary and `PathUri` internally. Existing tool-call schemas and relative-path behavior stay host-native; remaining local-only consumers convert explicitly and leave follow-up TODOs. The Wine integration test verifies app-server can start a thread and complete an ordinary turn with a Windows environment cwd from Linux. ## Validation - `bazel test //codex-rs/core/tests/remote_env_windows:smoke-test --test_output=errors` - focused app-server environment-selection and protocol schema tests - scoped Clippy for `codex-core` and `codex-app-server-protocol` --- .../schema/json/ClientRequest.json | 5 +- .../codex_app_server_protocol.schemas.json | 2 +- .../codex_app_server_protocol.v2.schemas.json | 2 +- .../schema/json/v2/ThreadStartParams.json | 5 +- .../schema/json/v2/TurnStartParams.json | 5 +- .../typescript/v2/TurnEnvironmentParams.ts | 4 +- .../src/protocol/v2/tests.rs | 30 ++----- .../src/protocol/v2/turn.rs | 3 +- codex-rs/app-server/src/request_processors.rs | 31 +++++++ .../request_processors/thread_processor.rs | 25 +----- .../src/request_processors/turn_processor.rs | 25 +----- .../app-server/tests/suite/v2/thread_start.rs | 39 +++++++- .../app-server/tests/suite/v2/turn_start.rs | 24 +++-- codex-rs/core/src/agents_md.rs | 7 +- codex-rs/core/src/agents_md_tests.rs | 2 +- .../core/src/context/environment_context.rs | 20 +++-- codex-rs/core/src/environment_selection.rs | 23 +++-- codex-rs/core/src/guardian/review_session.rs | 4 +- codex-rs/core/src/mcp_openai_file.rs | 11 ++- codex-rs/core/src/session/mcp.rs | 21 ++--- codex-rs/core/src/session/session.rs | 5 +- codex-rs/core/src/session/tests.rs | 9 +- codex-rs/core/src/session/turn.rs | 29 +++--- codex-rs/core/src/session/turn_context.rs | 28 ++---- codex-rs/core/src/shell_snapshot.rs | 4 +- codex-rs/core/src/tasks/user_shell.rs | 14 ++- codex-rs/core/src/thread_manager_tests.rs | 8 +- .../agent_jobs/spawn_agents_on_csv.rs | 11 ++- .../core/src/tools/handlers/apply_patch.rs | 11 ++- .../src/tools/handlers/extension_tools.rs | 13 ++- .../src/tools/handlers/request_permissions.rs | 10 ++- .../handlers/unified_exec/exec_command.rs | 14 ++- .../core/src/tools/handlers/view_image.rs | 14 ++- .../src/tools/runtimes/apply_patch_tests.rs | 2 +- .../core/src/tools/runtimes/unified_exec.rs | 3 +- .../core/tests/remote_env_windows/BUILD.bazel | 1 + .../remote_env_windows_test.rs | 90 ++++++++++++++----- 37 files changed, 346 insertions(+), 208 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 3ed2791c85..3358ea9d63 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1303,6 +1303,9 @@ ], "type": "object" }, + "LegacyAppPathString": { + "type": "string" + }, "ListMcpServerStatusParams": { "properties": { "cursor": { @@ -4331,7 +4334,7 @@ "TurnEnvironmentParams": { "properties": { "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "environmentId": { "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 97c4eee3cf..1e11c76ae4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -19517,7 +19517,7 @@ "TurnEnvironmentParams": { "properties": { "cwd": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "$ref": "#/definitions/v2/LegacyAppPathString" }, "environmentId": { "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 1f5e7ac2bb..326bfdfc9a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -17325,7 +17325,7 @@ "TurnEnvironmentParams": { "properties": { "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "environmentId": { "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index 59e05c773b..d4291f6dc1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -191,6 +191,9 @@ } ] }, + "LegacyAppPathString": { + "type": "string" + }, "Personality": { "enum": [ "none", @@ -242,7 +245,7 @@ "TurnEnvironmentParams": { "properties": { "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "environmentId": { "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 070944a28b..b1c72bb1f2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -130,6 +130,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "ModeKind": { "description": "Initial collaboration mode to use when the TUI starts.", "enum": [ @@ -331,7 +334,7 @@ "TurnEnvironmentParams": { "properties": { "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "environmentId": { "type": "string" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts index bb981b0ac9..cb93ba3964 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts @@ -1,6 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { LegacyAppPathString } from "../LegacyAppPathString"; -export type TurnEnvironmentParams = { environmentId: string, cwd: AbsolutePathBuf, }; +export type TurnEnvironmentParams = { environmentId: string, cwd: LegacyAppPathString, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index c690871689..de138d5c2e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -3723,7 +3723,14 @@ fn thread_settings_update_params_preserve_field_level_experimental_gates() { #[test] fn turn_start_params_round_trip_environments() { - let cwd = test_absolute_path(); + // Use a path foreign to the test host so this exercises syntax preservation instead of the + // host-native conversion performed by test_absolute_path(). + #[cfg(windows)] + let raw_cwd = "/workspace"; + #[cfg(not(windows))] + let raw_cwd = r"C:\workspace"; + let cwd: LegacyAppPathString = + serde_json::from_value(json!(raw_cwd)).expect("API path should deserialize"); let params: TurnStartParams = serde_json::from_value(json!({ "threadId": "thread_123", "input": [], @@ -3805,27 +3812,6 @@ fn turn_start_params_treat_null_or_omitted_environments_as_default() { ); } -#[test] -fn turn_start_params_reject_relative_environment_cwd() { - let err = serde_json::from_value::(json!({ - "threadId": "thread_123", - "input": [], - "environments": [ - { - "environmentId": "local", - "cwd": "relative" - } - ], - })) - .expect_err("relative environment cwd should fail"); - - assert!( - err.to_string() - .contains("AbsolutePathBuf deserialized without a base path"), - "unexpected error: {err}" - ); -} - #[test] fn realtime_append_text_defaults_role_to_user() { let params = serde_json::from_value::(json!({ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index 836c6d4e96..77da0ea8ad 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -14,6 +14,7 @@ use codex_protocol::user_input::ByteRange as CoreByteRange; use codex_protocol::user_input::TextElement as CoreTextElement; use codex_protocol::user_input::UserInput as CoreUserInput; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -38,7 +39,7 @@ pub enum TurnStatus { #[ts(export_to = "v2/")] pub struct TurnEnvironmentParams { pub environment_id: String, - pub cwd: AbsolutePathBuf, + pub cwd: LegacyAppPathString, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index cfc411e130..b5295b9b74 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -541,6 +541,37 @@ fn resolve_request_cwd(cwd: Option) -> Result, .transpose() } +fn resolve_turn_environment_selections( + thread_manager: &ThreadManager, + environments: Option>, +) -> Result>, JSONRPCErrorError> { + let Some(environments) = environments else { + return Ok(None); + }; + let mut selections = Vec::with_capacity(environments.len()); + for environment in environments { + let environment_id = environment.environment_id; + let cwd = environment + .cwd + .infer_absolute_path_convention() + .and_then(|convention| environment.cwd.to_path_uri(convention).ok()) + .ok_or_else(|| { + invalid_request(format!( + "invalid cwd for environment `{environment_id}`: path `{}` does not use absolute POSIX or Windows path syntax", + environment.cwd + )) + })?; + selections.push(TurnEnvironmentSelection { + environment_id, + cwd, + }); + } + thread_manager + .validate_environment_selections(&selections) + .map_err(environment_selection_error)?; + Ok(Some(selections)) +} + fn resolve_runtime_workspace_roots(workspace_roots: Vec) -> Vec { let mut resolved_roots = Vec::new(); for root in workspace_roots { diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 09ff0dd698..a5299f2c72 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -4,7 +4,6 @@ use codex_app_server_protocol::SelectedCapabilityRoot; use codex_extension_api::ExtensionDataInit; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; -use codex_utils_path_uri::PathUri; const THREAD_LIST_DEFAULT_LIMIT: usize = 25; const THREAD_LIST_MAX_LIMIT: usize = 100; @@ -906,7 +905,8 @@ impl ThreadRequestProcessor { "`permissions` cannot be combined with `sandbox`", )); } - let environment_selections = self.parse_environment_selections(environments)?; + let environment_selections = + resolve_turn_environment_selections(self.thread_manager.as_ref(), environments)?; let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots); let mut typesafe_overrides = self.build_thread_config_overrides( model, @@ -1311,27 +1311,6 @@ impl ThreadRequestProcessor { } } - fn parse_environment_selections( - &self, - environments: Option>, - ) -> Result>, JSONRPCErrorError> { - let environment_selections = environments.map(|environments| { - environments - .into_iter() - .map(|environment| TurnEnvironmentSelection { - environment_id: environment.environment_id, - cwd: PathUri::from_abs_path(&environment.cwd), - }) - .collect::>() - }); - if let Some(environment_selections) = environment_selections.as_ref() { - self.thread_manager - .validate_environment_selections(environment_selections) - .map_err(environment_selection_error)?; - } - Ok(environment_selections) - } - async fn thread_archive_inner( &self, params: ThreadArchiveParams, diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 8d1810d87f..d48e8c04b5 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -4,7 +4,6 @@ use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; -use codex_utils_path_uri::PathUri; const DIRECT_INPUT_TO_MULTI_AGENT_V2_SUBAGENT_ERROR: &str = "direct app-server input is not allowed for multi-agent v2 sub-agents"; @@ -341,27 +340,6 @@ impl TurnRequestProcessor { Ok((review_request, hint)) } - fn parse_environment_selections( - &self, - environments: Option>, - ) -> Result>, JSONRPCErrorError> { - let environment_selections = environments.map(|environments| { - environments - .into_iter() - .map(|environment| TurnEnvironmentSelection { - environment_id: environment.environment_id, - cwd: PathUri::from_abs_path(&environment.cwd), - }) - .collect::>() - }); - if let Some(environment_selections) = environment_selections.as_ref() { - self.thread_manager - .validate_environment_selections(environment_selections) - .map_err(environment_selection_error)?; - } - Ok(environment_selections) - } - async fn request_trace_context( &self, request_id: &ConnectionRequestId, @@ -433,7 +411,8 @@ impl TurnRequestProcessor { self.track_error_response(&request_id, error, /*error_type*/ None); })?; - let environment_selections = self.parse_environment_selections(params.environments)?; + let environment_selections = + resolve_turn_environment_selections(self.thread_manager.as_ref(), params.environments)?; // Map v2 input items to core input items. let mapped_items: Vec = params diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 82a2b6b2c0..c0946ba679 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -293,7 +293,10 @@ async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result .send_thread_start_request(ThreadStartParams { environments: Some(vec![TurnEnvironmentParams { environment_id: "missing".to_string(), - cwd: codex_home.path().to_path_buf().try_into()?, + cwd: codex_utils_absolute_path::AbsolutePathBuf::try_from( + codex_home.path().to_path_buf(), + )? + .into(), }]), ..Default::default() }) @@ -312,6 +315,40 @@ async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result Ok(()) } +#[tokio::test] +async fn thread_start_rejects_relative_environment_cwd_as_invalid_request() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + environments: Some(vec![TurnEnvironmentParams { + environment_id: "local".to_string(), + cwd: serde_json::from_value(json!("relative"))?, + }]), + ..Default::default() + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + error.error.message, + "invalid cwd for environment `local`: path `relative` does not use absolute POSIX or Windows path syntax" + ); + + Ok(()) +} + #[tokio::test] async fn thread_start_response_includes_loaded_instruction_sources() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").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 6567b06be1..76b02a6d6e 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -76,6 +76,7 @@ use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::ImageDetail; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; +use codex_utils_absolute_path::test_support::PathExt; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -1326,7 +1327,10 @@ async fn turn_start_rejects_unknown_environment_before_starting_turn() -> Result }], environments: Some(vec![TurnEnvironmentParams { environment_id: "missing".to_string(), - cwd: codex_home.path().to_path_buf().try_into()?, + cwd: codex_utils_absolute_path::AbsolutePathBuf::try_from( + codex_home.path().to_path_buf(), + )? + .into(), }]), ..Default::default() }) @@ -2669,7 +2673,7 @@ async fn run_environment_selection_case( .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), cwd: Some(workspace.to_string_lossy().into_owned()), - environments: environment_params(case.sticky, workspace)?, + environments: environment_params(case.sticky, workspace), ..Default::default() }) .await?; @@ -2688,7 +2692,7 @@ async fn run_environment_selection_case( text: format!("run {}", case.name), text_elements: Vec::new(), }], - environments: environment_params(case.turn, workspace)?, + environments: environment_params(case.turn, workspace), cwd: Some(workspace.to_path_buf()), model: Some("mock-model".to_string()), ..Default::default() @@ -2735,21 +2739,15 @@ async fn run_environment_selection_case( Ok(()) } -fn environment_params( - ids: Option<&[&str]>, - cwd: &Path, -) -> Result>> { +fn environment_params(ids: Option<&[&str]>, cwd: &Path) -> Option> { ids.map(|ids| { ids.iter() - .map(|id| { - Ok(TurnEnvironmentParams { - environment_id: (*id).to_string(), - cwd: cwd.to_path_buf().try_into()?, - }) + .map(|id| TurnEnvironmentParams { + environment_id: (*id).to_string(), + cwd: cwd.abs().into(), }) .collect() }) - .transpose() } #[tokio::test] diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 5cb70dc5fc..861e7b4ac7 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -53,11 +53,16 @@ pub(crate) async fn load_project_instructions( let mut loaded = LoadedAgentsMd::from_user_instructions(user_instructions); for turn_environment in &environments.turn_environments { let filesystem = turn_environment.environment.get_filesystem(); + // TODO(anp): Migrate AGENTS.md discovery to PathUri so instructions can be loaded from + // environment-native foreign working directories. + let Ok(cwd) = turn_environment.cwd().to_abs_path() else { + continue; + }; match read_agents_md( config, filesystem.as_ref(), &turn_environment.environment_id, - turn_environment.cwd(), + &cwd, ) .await { diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index ffe12d52cc..42604629a0 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -267,7 +267,7 @@ fn resolved_local_environments( Environment::create_for_tests(/*exec_server_url*/ None) .expect("local environment"), ), - cwd, + PathUri::from_abs_path(&cwd), /*shell*/ None, ) }) diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index 8aa07d1ed5..f0cc9f80bc 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -44,14 +44,18 @@ impl EnvironmentContextEnvironment { fn from_turn_environments(environments: &[TurnEnvironment], shell: &Shell) -> Vec { environments .iter() - .map(|environment| Self { - id: environment.environment_id.clone(), - cwd: environment.cwd().clone(), - shell: environment - .shell - .as_ref() - .map(|shell| shell.name().to_string()) - .unwrap_or_else(|| shell.name().to_string()), + .filter_map(|environment| { + // TODO(anp): Migrate EnvironmentContextEnvironment to PathUri so foreign + // environments remain visible in model context. + Some(Self { + id: environment.environment_id.clone(), + cwd: environment.cwd().to_abs_path().ok()?, + shell: environment + .shell + .as_ref() + .map(|shell| shell.name().to_string()) + .unwrap_or_else(|| shell.name().to_string()), + }) }) .collect() } diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index 8fb59ba925..16b2c47b5d 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -97,7 +97,7 @@ impl ThreadEnvironments { } let turn_environment = match current.turn_environments.iter().find(|environment| { environment.environment_id == selected_environment.environment_id - && environment.cwd_uri() == &selected_environment.cwd + && environment.cwd() == &selected_environment.cwd }) { Some(environment) => environment.clone(), None => match Self::resolve_selection( @@ -157,12 +157,7 @@ impl ThreadEnvironments { let mut turn_environment = TurnEnvironment::new( environment_id, environment, - selected_environment.cwd.to_abs_path().map_err(|err| { - CodexErr::InvalidRequest(format!( - "turn environment cwd `{}` is not valid on this host: {err}", - selected_environment.cwd - )) - })?, + selected_environment.cwd.clone(), shell, ); let task = shell_snapshot @@ -226,8 +221,10 @@ impl TurnEnvironmentSnapshot { (!environment.environment.is_remote()).then_some(environment) } - pub(crate) fn single_local_environment_cwd(&self) -> Option<&AbsolutePathBuf> { - self.single_local_environment().map(TurnEnvironment::cwd) + pub(crate) fn single_local_environment_cwd(&self) -> Option { + // TODO(anp): Migrate local-environment consumers to PathUri so this compatibility + // conversion can be removed. + self.single_local_environment()?.cwd().to_abs_path().ok() } } @@ -560,7 +557,7 @@ url = "ws://127.0.0.1:8765" Arc::clone(&local_manager), &[TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: cwd_uri, + cwd: cwd_uri.clone(), }], ) .await; @@ -573,7 +570,7 @@ url = "ws://127.0.0.1:8765" turn_environments: vec![TurnEnvironment::new( REMOTE_ENVIRONMENT_ID.to_string(), remote_environment.clone(), - cwd.clone(), + cwd_uri.clone(), /*shell*/ None, )], }; @@ -583,13 +580,13 @@ url = "ws://127.0.0.1:8765" TurnEnvironment::new( REMOTE_ENVIRONMENT_ID.to_string(), remote_environment, - cwd.clone(), + cwd_uri, /*shell*/ None, ), ], }; - assert_eq!(local.single_local_environment_cwd(), Some(&cwd)); + assert_eq!(local.single_local_environment_cwd(), Some(cwd)); assert_eq!(remote.single_local_environment_cwd(), None); assert_eq!(multiple.single_local_environment_cwd(), None); } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 9d96a2d60b..7aa007891f 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -750,11 +750,13 @@ async fn run_review_on_session( .unwrap_or_default(); let guardian_permission_profile = PermissionProfile::read_only(); let parent_turn_environments = params.parent_turn.environments.to_selections(); + // TODO(anp): Migrate guardian review thread settings to a PathUri fallback cwd so foreign + // parent environments do not fall back to the host-native config cwd. let parent_turn_legacy_fallback_cwd = params .parent_turn .environments .primary() - .map(|environment| environment.cwd().clone()) + .and_then(|environment| environment.cwd().to_abs_path().ok()) .unwrap_or_else(|| params.parent_turn.config.cwd.clone()); let submit_result = run_before_review_deadline( diff --git a/codex-rs/core/src/mcp_openai_file.rs b/codex-rs/core/src/mcp_openai_file.rs index 7c1e4d7c8d..4dee0da717 100644 --- a/codex-rs/core/src/mcp_openai_file.rs +++ b/codex-rs/core/src/mcp_openai_file.rs @@ -122,7 +122,13 @@ async fn build_uploaded_argument_value( "no primary turn environment is available".to_string(), )); }; - let resolved_path = turn_environment.cwd().join(file_path); + // TODO(anp): Resolve app tool file arguments using the selected environment's native path + // convention so uploads can read relative paths from foreign environments. + let native_environment_cwd = turn_environment + .cwd() + .to_abs_path() + .map_err(|error| contextualize_error(error.to_string()))?; + let resolved_path = native_environment_cwd.join(file_path); let path_uri = PathUri::from_abs_path(&resolved_path); let fs = turn_environment.environment.get_filesystem(); let metadata = fs @@ -178,6 +184,7 @@ mod tests { use crate::session::tests::make_session_and_context; use crate::session::turn_context::TurnEnvironment; use codex_utils_absolute_path::AbsolutePathBuf; + use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use std::path::Path; use std::sync::Arc; @@ -194,7 +201,7 @@ mod tests { *primary = TurnEnvironment::new( primary.environment_id.clone(), Arc::clone(&primary.environment), - cwd, + PathUri::from_abs_path(&cwd), primary.shell.clone(), ); } diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index cc6e42f852..d1b8982223 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -318,17 +318,18 @@ impl Session { ) .await; let environment_manager = self.services.turn_environments.environment_manager(); - let mcp_runtime_context = match turn_context.environments.primary() { - Some(turn_environment) => McpRuntimeContext::new( - Arc::clone(&environment_manager), - turn_environment.cwd().to_path_buf(), - ), - None => McpRuntimeContext::new( - environment_manager, + // TODO(anp): Migrate MCP runtime cwd plumbing to PathUri so foreign environment cwd + // values can be used without falling back to the legacy host cwd. + let cwd = turn_context + .environments + .primary() + .and_then(|turn_environment| turn_environment.cwd().to_abs_path().ok()) + .map(|cwd| cwd.to_path_buf()) + .unwrap_or_else(|| { #[allow(deprecated)] - turn_context.cwd.to_path_buf(), - ), - }; + turn_context.cwd.to_path_buf() + }); + let mcp_runtime_context = McpRuntimeContext::new(environment_manager, cwd); let mcp_startup_cancellation_token = { let mut guard = self.services.mcp_startup_cancellation_token.lock().await; guard.cancel(); diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 46f20eaf53..578152814b 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1119,9 +1119,12 @@ impl Session { }; let mcp_runtime_context = { let turn_environments = sess.services.turn_environments.snapshot().await; + // TODO(anp): Migrate MCP runtime cwd plumbing to PathUri so foreign environment + // cwd values can be used without falling back to the session host cwd. let cwd = turn_environments .primary() - .map(|turn_environment| turn_environment.cwd().to_path_buf()) + .and_then(|turn_environment| turn_environment.cwd().to_abs_path().ok()) + .map(|cwd| cwd.to_path_buf()) .unwrap_or_else(|| session_configuration.cwd().to_path_buf()); McpRuntimeContext::new( sess.services.turn_environments.environment_manager(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 5086fd63bd..b4b786c902 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5697,7 +5697,7 @@ async fn request_permissions_tool_resolves_relative_paths_against_selected_envir turn_context_mut.environments.turn_environments[0] = TurnEnvironment::new( "remote".to_string(), current_environment.environment, - environment_cwd.clone(), + PathUri::from_abs_path(&environment_cwd), current_environment.shell, ); @@ -6316,13 +6316,14 @@ async fn primary_environment_uses_first_turn_environment() { let first_environment = turn_context.environments.turn_environments[0].clone(); #[allow(deprecated)] let second_cwd = turn_context.cwd.join("second"); + let second_cwd_uri = codex_utils_path_uri::PathUri::from_abs_path(&second_cwd); turn_context .environments .turn_environments .push(TurnEnvironment::new( "second".to_string(), Arc::clone(&first_environment.environment), - second_cwd.clone(), + second_cwd_uri.clone(), /*shell*/ None, )); @@ -6342,12 +6343,12 @@ async fn primary_environment_uses_first_turn_environment() { .find(|environment| environment.environment_id == "second") .expect("second environment") .cwd(), - &second_cwd + &second_cwd_uri ); assert_eq!(turn_context.environments.turn_environments.len(), 2); assert_eq!( turn_context.environments.turn_environments[1].cwd(), - &second_cwd + &second_cwd_uri ); } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 3c783eb486..a105aeffee 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -414,13 +414,16 @@ pub(crate) async fn run_turn( async fn turn_diff_display_roots(turn_context: &TurnContext) -> Vec<(String, PathBuf)> { let mut display_roots = Vec::new(); for turn_environment in &turn_context.environments.turn_environments { - let root = get_git_repo_root_with_fs( - turn_environment.environment.get_filesystem().as_ref(), - turn_environment.cwd(), - ) - .await - .unwrap_or_else(|| turn_environment.cwd().clone()) - .into_path_buf(); + // TODO(anp): Migrate git-root discovery and diff display roots to PathUri so foreign + // environment roots can participate without host-native conversion. + let Ok(cwd) = turn_environment.cwd().to_abs_path() else { + continue; + }; + let root = + get_git_repo_root_with_fs(turn_environment.environment.get_filesystem().as_ref(), &cwd) + .await + .unwrap_or(cwd) + .into_path_buf(); display_roots.push((turn_environment.environment_id.clone(), root)); } display_roots @@ -634,10 +637,14 @@ async fn build_extension_turn_input_items( .turn_environments .iter() .enumerate() - .map(|(index, environment)| TurnInputEnvironment { - environment_id: environment.environment_id.clone(), - cwd: environment.cwd().as_path().to_path_buf(), - is_primary: index == 0, + .filter_map(|(index, environment)| { + // TODO(anp): Migrate extension turn-input environments to PathUri so foreign cwd + // values are not omitted from extension context. + Some(TurnInputEnvironment { + environment_id: environment.environment_id.clone(), + cwd: environment.cwd().to_abs_path().ok()?.into_path_buf(), + is_primary: index == 0, + }) }) .collect::>(); diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 042eca8929..aed5fae681 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -3,7 +3,6 @@ use crate::SkillLoadOutcome; use crate::agents_md::LoadedAgentsMd; use crate::config::GhostSnapshotConfig; use crate::environment_selection::TurnEnvironmentSnapshot; -use crate::path_utils; use crate::shell_snapshot::ShellSnapshotFile; use codex_core_skills::HostLoadedSkills; use codex_file_system::FileSystemSandboxContext; @@ -49,13 +48,7 @@ pub(crate) type ShellSnapshotTask = Shared, - // Keep both representations together while cwd consumers migrate to URI semantics. Keeping - // them synchronized means neither representation can be exposed through a mutable reference; - // updates must rebuild the validated pair through `TurnEnvironment::new`. Once - // `TurnEnvironment::cwd` itself becomes a `PathUri`, convert only at native filesystem and - // process-launch boundaries and remove this paired migration state. - cwd: AbsolutePathBuf, - cwd_uri: PathUri, + cwd: PathUri, pub(crate) shell: Option, pub(crate) shell_snapshot: ShellSnapshotTask, } @@ -64,22 +57,20 @@ impl TurnEnvironment { pub(crate) fn new( environment_id: String, environment: Arc, - cwd: AbsolutePathBuf, + cwd: PathUri, shell: Option, ) -> Self { - let cwd_uri = PathUri::from_abs_path(&cwd); Self { environment_id, environment, cwd, - cwd_uri, shell, shell_snapshot: futures::future::ready(None).boxed().shared(), } } pub(crate) fn shell_snapshot(&self, cwd: &AbsolutePathBuf) -> Option { - if !path_utils::paths_match_after_normalization(self.cwd.as_path(), cwd.as_path()) { + if self.cwd != PathUri::from_abs_path(cwd) { return None; } self.shell_snapshot @@ -88,18 +79,14 @@ impl TurnEnvironment { .map(ShellSnapshotFile::path) } - pub(crate) fn cwd(&self) -> &AbsolutePathBuf { + pub(crate) fn cwd(&self) -> &PathUri { &self.cwd } - pub(crate) fn cwd_uri(&self) -> &PathUri { - &self.cwd_uri - } - pub(crate) fn selection(&self) -> TurnEnvironmentSelection { TurnEnvironmentSelection { environment_id: self.environment_id.clone(), - cwd: self.cwd_uri.clone(), + cwd: self.cwd.clone(), } } } @@ -110,7 +97,6 @@ impl std::fmt::Debug for TurnEnvironment { .field("environment_id", &self.environment_id) .field("environment", &self.environment) .field("cwd", &self.cwd) - .field("cwd_uri", &self.cwd_uri) .field("shell", &self.shell) .finish_non_exhaustive() } @@ -755,9 +741,11 @@ impl Session { ) -> Arc { let turn_environments = self.services.turn_environments.snapshot().await; let primary_turn_environment = turn_environments.primary().cloned(); + // TODO(anp): Migrate per-turn config and legacy TurnContext cwd consumers to PathUri so + // a foreign primary environment does not fall back to the session's host cwd. let cwd = primary_turn_environment .as_ref() - .map(|turn_environment| turn_environment.cwd().clone()) + .and_then(|turn_environment| turn_environment.cwd().to_abs_path().ok()) .unwrap_or_else(|| session_configuration.cwd().clone()); let per_turn_config = Self::build_per_turn_config(&session_configuration, cwd.clone()); { diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 6e6c6cd515..6a95093003 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -76,7 +76,9 @@ impl ShellSnapshot { } let shell = environment.shell.clone()?; - let cwd = environment.cwd().clone(); + // TODO(anp): Migrate shell snapshot creation to accept PathUri and defer native + // conversion to the spawned shell process. + let cwd = environment.cwd().to_abs_path().ok()?; Self::build_for_cwd(Arc::clone(config), cwd, shell).await } diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 6a01a87a0e..4c30493517 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -144,7 +144,18 @@ pub(crate) async fn execute_user_shell_command( // We do not source rc files or otherwise reformat the script. let use_login_shell = true; let display_command = environment_shell.derive_exec_args(&command, use_login_shell); - let shell_snapshot_location = turn_environment.shell_snapshot(turn_environment.cwd()); + // TODO(anp): Migrate user-shell events and execution plumbing to PathUri so this local-only + // feature does not need to project the selected environment cwd onto the Codex host. + let Ok(cwd) = turn_environment.cwd().to_abs_path() else { + send_user_shell_error( + &session, + turn_context.as_ref(), + "shell working directory is not native to the Codex host", + ) + .await; + return; + }; + let shell_snapshot_location = turn_environment.shell_snapshot(&cwd); let mut exec_env_map = create_env( &turn_context.shell_environment_policy, Some(session.thread_id), @@ -162,7 +173,6 @@ pub(crate) async fn execute_user_shell_command( let call_id = Uuid::new_v4().to_string(); let raw_command = command; - let cwd = turn_environment.cwd().clone(); let parsed_cmd = parse_command(&display_command); session diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index ddfa5e7011..cec54b5e44 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -606,11 +606,11 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { assert_eq!(resumed_turn.environments.turn_environments.len(), 1); assert_eq!( resumed_turn.environments.turn_environments[0].cwd(), - &default_cwd + &PathUri::from_abs_path(&default_cwd) ); assert_ne!( resumed_turn.environments.turn_environments[0].cwd(), - &selected_cwd + &PathUri::from_abs_path(&selected_cwd) ); let forked = manager @@ -633,11 +633,11 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { assert_eq!(forked_turn.environments.turn_environments.len(), 1); assert_eq!( forked_turn.environments.turn_environments[0].cwd(), - &default_cwd + &PathUri::from_abs_path(&default_cwd) ); assert_ne!( forked_turn.environments.turn_environments[0].cwd(), - &selected_cwd + &PathUri::from_abs_path(&selected_cwd) ); } diff --git a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs index 2556989621..a519e5db1b 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs @@ -299,7 +299,7 @@ pub async fn handle( Ok(FunctionToolOutput::from_text(content, Some(true))) } -fn single_local_environment_cwd(turn: &TurnContext) -> Result<&AbsolutePathBuf, FunctionCallError> { +fn single_local_environment_cwd(turn: &TurnContext) -> Result { let [turn_environment] = turn.environments.turn_environments.as_slice() else { return Err(FunctionCallError::RespondToModel( "spawn_agents_on_csv requires exactly one local environment".to_string(), @@ -312,5 +312,12 @@ fn single_local_environment_cwd(turn: &TurnContext) -> Result<&AbsolutePathBuf, )); } - Ok(turn_environment.cwd()) + // TODO(anp): Migrate spawn_agents_on_csv filesystem access to PathUri before enabling it for + // remote environments. + turn_environment.cwd().to_abs_path().map_err(|err| { + FunctionCallError::RespondToModel(format!( + "spawn_agents_on_csv cwd `{}` is not native to the Codex host: {err}", + turn_environment.cwd() + )) + }) } diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index a719e9acd9..2ce9119e35 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -359,11 +359,18 @@ impl ApplyPatchHandler { "apply_patch is unavailable in this session".to_string(), )); }; - let cwd = turn_environment.cwd().clone(); + // TODO(anp): Migrate apply-patch verification and permission accounting to PathUri so + // patches can target environment-native foreign paths without host projection. + let cwd = turn_environment.cwd().to_abs_path().map_err(|err| { + FunctionCallError::RespondToModel(format!( + "apply_patch cwd `{}` is not native to the Codex host: {err}", + turn_environment.cwd() + )) + })?; let fs = turn_environment.environment.get_filesystem(); let sandbox = turn.file_system_sandbox_context( /*additional_permissions*/ None, - turn_environment.cwd_uri(), + turn_environment.cwd(), ); match codex_apply_patch::verify_apply_patch_args(args, &cwd, fs.as_ref(), Some(&sandbox)) .await diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index f0522c7c17..b59bfaec02 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -114,10 +114,15 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { ConversationHistory::new(invocation.session.clone_history().await.into_raw_items()); let mut environments = Vec::with_capacity(invocation.turn.environments.turn_environments.len()); for environment in &invocation.turn.environments.turn_environments { + // TODO(anp): Migrate extension ToolEnvironment and granted-permission lookup to PathUri + // so extensions can receive foreign environment cwd values. + let Ok(native_cwd) = environment.cwd().to_abs_path() else { + continue; + }; let additional_permissions = apply_granted_turn_permissions( invocation.session.as_ref(), &environment.environment_id, - environment.cwd().as_path(), + native_cwd.as_path(), SandboxPermissions::UseDefault, /*additional_permissions*/ None, ) @@ -125,10 +130,10 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { .additional_permissions; let file_system_sandbox_context = invocation .turn - .file_system_sandbox_context(additional_permissions, environment.cwd_uri()); + .file_system_sandbox_context(additional_permissions, environment.cwd()); environments.push(ToolEnvironment { environment_id: environment.environment_id.clone(), - cwd: environment.cwd().clone(), + cwd: native_cwd, file_system: environment.environment.get_filesystem(), file_system_sandbox_context, }); @@ -315,7 +320,7 @@ mod tests { .environments .turn_environments .iter() - .map(|environment| Some(environment.cwd_uri().clone())) + .map(|environment| Some(environment.cwd().clone())) .collect::>(); let history_item = ResponseItem::Message { id: None, diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 53341b14f5..afc15f9659 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -70,8 +70,16 @@ impl RequestPermissionsHandler { "request_permissions requires a primary environment".to_string(), )); }; + // TODO(anp): Migrate request_permissions parsing and permission profiles to PathUri so + // environment-native foreign paths do not require host conversion. + let native_cwd = turn_environment.cwd().to_abs_path().map_err(|err| { + FunctionCallError::RespondToModel(format!( + "request_permissions cwd `{}` is not native to the Codex host: {err}", + turn_environment.cwd() + )) + })?; let mut args: RequestPermissionsArgs = - parse_arguments_with_base_path(&arguments, turn_environment.cwd())?; + parse_arguments_with_base_path(&arguments, &native_cwd)?; args.permissions = normalize_additional_permissions(args.permissions.into()) .map(codex_protocol::request_permissions::RequestPermissionProfile::from) .map_err(FunctionCallError::RespondToModel)?; diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index eb89e56ef2..996ae9b52b 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -129,13 +129,21 @@ impl ExecCommandHandler { "unified exec is unavailable in this session".to_string(), )); }; + // TODO(anp): Resolve tool paths using the selected environment's native path convention + // so unified exec can support relative paths in foreign environments. + let native_environment_cwd = turn_environment.cwd().to_abs_path().map_err(|err| { + FunctionCallError::RespondToModel(format!( + "environment cwd `{}` is not native to the Codex host: {err}", + turn_environment.cwd() + )) + })?; let cwd = environment_args .workdir .as_deref() .filter(|workdir| !workdir.is_empty()) .map_or_else( - || turn_environment.cwd().clone(), - |workdir| turn_environment.cwd().join(workdir), + || native_environment_cwd.clone(), + |workdir| native_environment_cwd.join(workdir), ); let environment = Arc::clone(&turn_environment.environment); let fs = environment.get_filesystem(); @@ -277,7 +285,7 @@ impl ExecCommandHandler { yield_time_ms, max_output_tokens, cwd, - sandbox_cwd: turn_environment.cwd().clone(), + sandbox_cwd: native_environment_cwd, turn_environment: turn_environment.clone(), shell_mode, network: context.turn.network.clone(), diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index dc52e7e336..45a1adc246 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -143,11 +143,18 @@ impl ViewImageHandler { "view_image is unavailable in this session".to_string(), )); }; - let cwd = turn_environment.cwd().clone(); + // TODO(anp): Resolve tool paths using the selected environment's native path convention + // so view_image can support relative paths in foreign environments. + let cwd = turn_environment.cwd().to_abs_path().map_err(|err| { + FunctionCallError::RespondToModel(format!( + "environment cwd `{}` is not native to the Codex host: {err}", + turn_environment.cwd() + )) + })?; let abs_path = cwd.join(path); let sandbox = turn.file_system_sandbox_context( /*additional_permissions*/ None, - turn_environment.cwd_uri(), + turn_environment.cwd(), ); let fs = turn_environment.environment.get_filesystem(); let path_uri = PathUri::from_abs_path(&abs_path); @@ -272,6 +279,7 @@ mod tests { use crate::turn_diff_tracker::TurnDiffTracker; use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; + use codex_utils_path_uri::PathUri; use core_test_support::TempDirExt; use pretty_assertions::assert_eq; use serde_json::json; @@ -288,7 +296,7 @@ mod tests { turn.environments.turn_environments[0] = TurnEnvironment::new( current.environment_id, current.environment, - cwd, + PathUri::from_abs_path(&cwd), current.shell, ); } diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index 4100b662ee..51ca0b98c8 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -19,7 +19,7 @@ fn test_turn_environment(environment_id: &str) -> crate::session::turn_context:: crate::session::turn_context::TurnEnvironment::new( environment_id.to_string(), std::sync::Arc::new(codex_exec_server::Environment::default_for_tests()), - std::env::temp_dir().abs(), + PathUri::from_abs_path(&std::env::temp_dir().abs()), /*shell*/ None, ) } diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 25a1b932ce..eb23fc0ced 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -427,6 +427,7 @@ mod tests { use codex_exec_server::Environment; use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_tools::ZshForkConfig; + use codex_utils_path_uri::PathUri; use std::sync::Arc; use std::time::Duration; use tempfile::tempdir; @@ -435,7 +436,7 @@ mod tests { TurnEnvironment::new( LOCAL_ENVIRONMENT_ID.to_string(), Arc::new(Environment::default_for_tests()), - cwd, + PathUri::from_abs_path(&cwd), /*shell*/ None, ) } diff --git a/codex-rs/core/tests/remote_env_windows/BUILD.bazel b/codex-rs/core/tests/remote_env_windows/BUILD.bazel index 9b5ce52bd9..5e213ac14b 100644 --- a/codex-rs/core/tests/remote_env_windows/BUILD.bazel +++ b/codex-rs/core/tests/remote_env_windows/BUILD.bazel @@ -22,6 +22,7 @@ wine_rust_test( "//codex-rs/protocol", "//codex-rs/utils/path-uri", "@crates//:anyhow", + "@crates//:base64", "@crates//:pretty_assertions", "@crates//:serde_json", "@crates//:tempfile", diff --git a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs index 24efb8e7ed..208dfa554d 100644 --- a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs +++ b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs @@ -2,9 +2,18 @@ use anyhow::Context; use anyhow::Result; +use app_test_support::PathBufExt; use app_test_support::TestAppServer; -use codex_app_server_protocol::JSONRPCError; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnEnvironmentParams; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; use codex_exec_server::REMOTE_ENVIRONMENT_ID; use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_features::Feature; @@ -26,20 +35,22 @@ use core_test_support::responses::start_mock_server; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; +use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use serde_json::json; +use std::collections::BTreeMap; use tempfile::TempDir; use tokio::time::timeout; use wine_exec_server_test_support::WineExecServer; const APP_SERVER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); -const CALL_ID: &str = "wine-cmd-smoke"; -const COMMAND: &str = r#"if ((Get-Location).Path -ne 'C:\windows') { exit 1 }"#; -const INVALID_REQUEST_ERROR_CODE: i64 = -32600; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { + const CALL_ID: &str = "wine-cmd-smoke"; + const COMMAND: &str = r#"if ((Get-Location).Path -ne 'C:\windows') { exit 1 }"#; + WineExecServer .scope(|exec_server_url| async move { let server = start_mock_server().await; @@ -162,10 +173,20 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn app_server_rejects_windows_environment_cwd() -> Result<()> { +async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Result<()> { WineExecServer .scope(|exec_server_url| async move { let codex_home = TempDir::new()?; + let server = create_mock_responses_server_repeating_assistant("done").await; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::new(), + 100_000, + /*requires_openai_auth*/ None, + "mock", + "compact", + )?; let mut app_server = TestAppServer::new_with_env( codex_home.path(), &[( @@ -177,28 +198,53 @@ async fn app_server_rejects_windows_environment_cwd() -> Result<()> { timeout(APP_SERVER_READ_TIMEOUT, app_server.initialize()).await??; let request_id = app_server - .send_raw_request( - "thread/start", - Some(json!({ - "environments": [{ - "environmentId": REMOTE_ENVIRONMENT_ID, - "cwd": r"C:\windows", - }], - })), - ) + .send_thread_start_request(ThreadStartParams { + environments: Some(vec![TurnEnvironmentParams { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: serde_json::from_value::(json!(r"C:\windows"))?, + }]), + ..Default::default() + }) .await?; - let error: JSONRPCError = timeout( + let response = timeout( APP_SERVER_READ_TIMEOUT, - app_server.read_stream_until_error_message(RequestId::Integer(request_id)), + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; + let response: ThreadStartResponse = to_response(response)?; + assert!(!response.thread.id.is_empty()); + let host_cwd = codex_home.path().to_path_buf().abs(); + // TODO(anp): Return the selected environment's native cwd from thread/start. + assert_eq!(response.cwd, host_cwd); + // TODO(anp): Derive runtime workspace roots from the selected remote environment. + assert_eq!(response.runtime_workspace_roots, vec![host_cwd]); + // TODO(anp): Discover and report instruction sources from the remote filesystem. + assert_eq!(response.instruction_sources, Vec::new()); + // TODO(anp): Report the implicit built-in permission profile instead of None. + assert_eq!(response.active_permission_profile, None); - assert_eq!(error.id, RequestId::Integer(request_id)); - assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); - assert_eq!( - error.error.message, - "Invalid request: AbsolutePathBuf deserialized without a base path" - ); + let turn_request_id = app_server + .send_turn_start_request(TurnStartParams { + thread_id: response.thread.id, + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "say done".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_response = timeout( + APP_SERVER_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(turn_request_id)), + ) + .await??; + let _: TurnStartResponse = to_response(turn_response)?; + timeout( + APP_SERVER_READ_TIMEOUT, + app_server.read_stream_until_notification_message("turn/completed"), + ) + .await??; Ok(()) }) From bd2a78632665006149d6e7df627c52ebc1b3464e Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 16 Jun 2026 15:01:22 -0700 Subject: [PATCH 07/70] Tell codex about PathUri serde compat. (#28595) This addresses another wrinkle I keep having to re-prompt codex about when migrating to cross-OS paths. --- .codex/skills/path-types/SKILL.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.codex/skills/path-types/SKILL.md b/.codex/skills/path-types/SKILL.md index ccf1a9f41d..de35714dd2 100644 --- a/.codex/skills/path-types/SKILL.md +++ b/.codex/skills/path-types/SKILL.md @@ -19,6 +19,13 @@ migration; if compliance is difficult, ask the user how to proceed. - Tool call arguments that the model is expected to generate should be deserialized as regular `String`s with feature-specific path handling code. +## Rollout stability + +Assume that rollouts will be parsed by monotonically increasing versions of Codex. + +You can use `PathUri` in place of `AbsolutePathBuf` for rollout fields without jeopardizing +backwards compatibility since `PathUri` allows deserialization from bare absolute paths. + ## Migration requirements Keep these requirements in mind while migrating code to conform with the above guidelines: From 7e735b59ce28e400d53bc636c2f8111fe6678930 Mon Sep 17 00:00:00 2001 From: Alex Daley Date: Tue, 16 Jun 2026 18:22:21 -0400 Subject: [PATCH 08/70] [codex] [1/4] Add recommended plugin endpoint cache (#28399) Summary - Add authenticated parsing for `/ps/plugins/suggested?scope=GLOBAL`, including remote plugin and connector app identities. - Validate, deduplicate, sort, and cap endpoint candidates before caching them by backend and account identity. - Deduplicate concurrent cache misses and warm recommendations from the existing remote-installed-plugin refresh path used at startup and after account changes. - Keep endpoint results model-invisible in this PR; failures and responses without `enabled: true` resolve to legacy mode. Stack - 1/3. Follow-up: #28400 generalizes plugin suggestion presentation without activating endpoint recommendations. - Final activation: #27704. Validation - `just test -p codex-core-plugins recommended_plugins` - `just fix -p codex-core-plugins` - `just fmt` - `git diff --check` --- .../request_processors/account_processor.rs | 13 +- codex-rs/core-plugins/src/lib.rs | 2 + codex-rs/core-plugins/src/manager.rs | 132 +++++++++- codex-rs/core-plugins/src/manager_tests.rs | 232 ++++++++++++++++++ codex-rs/core-plugins/src/remote.rs | 124 ++++++++++ codex-rs/core-plugins/src/remote_tests.rs | 203 +++++++++++++++ 6 files changed, 695 insertions(+), 11 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 3dbef6773a..1fa0961ddc 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -173,7 +173,7 @@ impl AccountRequestProcessor { } } - async fn maybe_refresh_remote_installed_plugins_cache_for_current_config( + async fn maybe_refresh_plugin_caches_for_current_config( config_manager: &ConfigManager, thread_manager: &Arc, auth: Option, @@ -181,6 +181,9 @@ impl AccountRequestProcessor { thread_manager .plugins_manager() .set_auth_mode(auth.as_ref().map(CodexAuth::api_auth_mode)); + thread_manager + .plugins_manager() + .clear_recommended_plugins_cache(); match config_manager .load_latest_config(/*fallback_cwd*/ None) @@ -191,7 +194,7 @@ impl AccountRequestProcessor { let refresh_config_manager = config_manager.clone(); thread_manager .plugins_manager() - .maybe_start_remote_installed_plugins_cache_refresh( + .maybe_start_remote_plugin_caches_refresh( &config.plugins_config_input(), auth, Some(Arc::new(move || { @@ -618,7 +621,7 @@ impl AccountRequestProcessor { } async fn send_login_success_notifications(&self, login_id: Option) { - Self::maybe_refresh_remote_installed_plugins_cache_for_current_config( + Self::maybe_refresh_plugin_caches_for_current_config( &self.config_manager, &self.thread_manager, self.auth_manager.auth_cached(), @@ -671,7 +674,7 @@ impl AccountRequestProcessor { .await; let auth = auth_manager.auth_cached(); - Self::maybe_refresh_remote_installed_plugins_cache_for_current_config( + Self::maybe_refresh_plugin_caches_for_current_config( &config_manager, &thread_manager, auth.clone(), @@ -703,7 +706,7 @@ impl AccountRequestProcessor { } } - Self::maybe_refresh_remote_installed_plugins_cache_for_current_config( + Self::maybe_refresh_plugin_caches_for_current_config( &self.config_manager, &self.thread_manager, self.auth_manager.auth_cached(), diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 56775d0762..1b0f492231 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -54,3 +54,5 @@ pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarket pub use provider::ExecutorPluginProvider; pub use provider::ExecutorPluginProviderError; pub use provider::ResolvedExecutorPlugin; +pub use remote::RecommendedPlugin; +pub use remote::RecommendedPluginsMode; diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 9ed4d82c5c..e92b8d935b 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -38,6 +38,7 @@ use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeError; use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome; use crate::marketplace_upgrade::configured_git_marketplace_names; use crate::marketplace_upgrade::upgrade_configured_git_marketplaces; +use crate::remote::RecommendedPluginsMode; use crate::remote::RemoteInstalledPlugin; use crate::remote::RemotePluginCatalogError; use crate::remote::RemotePluginServiceConfig; @@ -80,6 +81,7 @@ use std::sync::RwLock; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Instant; +use tokio::sync::OnceCell; use tokio::sync::Semaphore; use tracing::instrument; use tracing::warn; @@ -120,6 +122,11 @@ struct FeaturedPluginIdsCacheKey { is_workspace_account: bool, } +#[derive(Clone, Hash, PartialEq, Eq)] +struct RecommendedPluginsCacheKey { + chatgpt_base_url: String, +} + #[derive(Clone)] struct CachedFeaturedPluginIds { key: FeaturedPluginIdsCacheKey, @@ -209,6 +216,12 @@ fn featured_plugin_ids_cache_key( } } +fn recommended_plugins_cache_key(config: &PluginsConfigInput) -> RecommendedPluginsCacheKey { + RecommendedPluginsCacheKey { + chatgpt_base_url: config.chatgpt_base_url.clone(), + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginInstallRequest { pub plugin_name: String, @@ -318,6 +331,9 @@ pub struct PluginsManager { codex_home: PathBuf, store: PluginStore, featured_plugin_ids_cache: RwLock>, + recommended_plugins_cache: RwLock>, + recommended_plugins_refreshes: + RwLock>>>, configured_marketplace_upgrade_state: RwLock, non_curated_cache_refresh_state: RwLock, // Keep the cache auth-independent so auth changes only need to resolve capabilities again. @@ -371,6 +387,8 @@ impl PluginsManager { codex_home: codex_home.clone(), store: PluginStore::new(codex_home), featured_plugin_ids_cache: RwLock::new(None), + recommended_plugins_cache: RwLock::new(HashMap::new()), + recommended_plugins_refreshes: RwLock::new(HashMap::new()), configured_marketplace_upgrade_state: RwLock::new( ConfiguredMarketplaceUpgradeState::default(), ), @@ -499,6 +517,19 @@ impl PluginsManager { *featured_plugin_ids_cache = None; } + pub fn clear_recommended_plugins_cache(&self) { + let mut refreshes = match self.recommended_plugins_refreshes.write() { + Ok(refreshes) => refreshes, + Err(err) => err.into_inner(), + }; + refreshes.clear(); + let mut cache = match self.recommended_plugins_cache.write() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + cache.clear(); + } + fn clear_loaded_plugins_cache(&self) { let mut cache = match self.loaded_plugins_cache.write() { Ok(cache) => cache, @@ -700,7 +731,7 @@ impl PluginsManager { true } - pub fn maybe_start_remote_installed_plugins_cache_refresh( + pub fn maybe_start_remote_plugin_caches_refresh( self: &Arc, config: &PluginsConfigInput, auth: Option, @@ -708,10 +739,18 @@ impl PluginsManager { ) { self.maybe_start_remote_installed_plugins_cache_refresh_with_notify( config, - auth, + auth.clone(), RemoteInstalledPluginsCacheRefreshNotify::IfCacheChanged, on_effective_plugins_changed, ); + + let manager = Arc::clone(self); + let config = config.clone(); + tokio::spawn(async move { + manager + .recommended_plugins_mode_for_config(&config, auth.as_ref()) + .await; + }); } pub fn maybe_start_remote_installed_plugins_cache_refresh_after_mutation( @@ -805,7 +844,7 @@ impl PluginsManager { if options.refresh_global_remote_catalog_cache { self.maybe_start_global_remote_catalog_cache_refresh(config, auth.clone()); } - self.maybe_start_remote_installed_plugins_cache_refresh( + self.maybe_start_remote_plugin_caches_refresh( config, auth.clone(), on_effective_plugins_changed.clone(), @@ -888,6 +927,87 @@ impl PluginsManager { Ok(featured_plugin_ids) } + pub async fn recommended_plugins_mode_for_config( + &self, + config: &PluginsConfigInput, + auth: Option<&CodexAuth>, + ) -> RecommendedPluginsMode { + if !config.plugins_enabled + || !config.remote_plugin_enabled + || !auth.is_some_and(CodexAuth::uses_codex_backend) + { + return RecommendedPluginsMode::Legacy; + } + + let cache_key = recommended_plugins_cache_key(config); + if let Some(cached) = self.cached_recommended_plugins_mode(&cache_key) { + return cached; + } + + let refresh = { + let mut refreshes = match self.recommended_plugins_refreshes.write() { + Ok(refreshes) => refreshes, + Err(err) => err.into_inner(), + }; + if let Some(cached) = self.cached_recommended_plugins_mode(&cache_key) { + return cached; + } + refreshes + .entry(cache_key.clone()) + .or_insert_with(|| Arc::new(OnceCell::new())) + .clone() + }; + + let mode = refresh + .get_or_init(|| async { + match crate::remote::fetch_recommended_plugins( + &remote_plugin_service_config(config), + auth, + ) + .await + { + Ok(mode) => { + let mut cache = match self.recommended_plugins_cache.write() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + cache.insert(cache_key.clone(), mode.clone()); + mode + } + Err(err) => { + warn!(error = %err, "failed to load recommended plugins"); + RecommendedPluginsMode::Legacy + } + } + }) + .await + .clone(); + + let mut refreshes = match self.recommended_plugins_refreshes.write() { + Ok(refreshes) => refreshes, + Err(err) => err.into_inner(), + }; + if refreshes + .get(&cache_key) + .is_some_and(|current| Arc::ptr_eq(current, &refresh)) + { + refreshes.remove(&cache_key); + } + + mode + } + + fn cached_recommended_plugins_mode( + &self, + cache_key: &RecommendedPluginsCacheKey, + ) -> Option { + let cache = match self.recommended_plugins_cache.read() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + cache.get(cache_key).cloned() + } + pub async fn install_plugin( &self, request: PluginInstallRequest, @@ -1420,7 +1540,7 @@ impl PluginsManager { let on_effective_plugins_changed = on_effective_plugins_changed.clone(); tokio::spawn(async move { let auth = auth_manager_for_remote_sync.auth().await; - manager.maybe_start_remote_installed_plugins_cache_refresh( + manager.maybe_start_remote_plugin_caches_refresh( &config_for_remote_sync, auth.clone(), on_effective_plugins_changed.clone(), @@ -1453,12 +1573,12 @@ impl PluginsManager { } }); - let config = config.clone(); + let config_for_featured_plugins = config.clone(); let manager = Arc::clone(self); tokio::spawn(async move { let auth = auth_manager.auth().await; if let Err(err) = manager - .featured_plugin_ids_for_config(&config, auth.as_ref()) + .featured_plugin_ids_for_config(&config_for_featured_plugins, auth.as_ref()) .await { warn!( diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 8ca3e0ac89..f219f5e760 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -11,6 +11,7 @@ use crate::marketplace::MarketplacePluginInstallPolicy; use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use crate::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME; use crate::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME; +use crate::remote::RecommendedPlugin; use crate::remote::RemoteInstalledPlugin; use crate::startup_sync::curated_plugins_repo_path; use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION; @@ -40,6 +41,7 @@ use codex_utils_absolute_path::test_support::PathBufExt; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; +use std::time::Duration; use tempfile::TempDir; use toml::Value; use wiremock::Mock; @@ -3703,6 +3705,236 @@ plugins = true assert_eq!(featured_plugin_ids, vec!["codex-plugin".to_string()]); } +#[tokio::test] +async fn remote_plugin_caches_refresh_warms_recommended_plugins_cache() { + let tmp = tempfile::tempdir().unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = true +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/ps/plugins/suggested")) + .and(query_param("scope", "GLOBAL")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "enabled": true, + "plugins": [] + }))) + .expect(1) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = server.uri(); + let manager = std::sync::Arc::new(PluginsManager::new(tmp.path().to_path_buf())); + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let cache_key = recommended_plugins_cache_key(&config); + + manager.maybe_start_remote_plugin_caches_refresh( + &config, + Some(auth.clone()), + /*on_effective_plugins_changed*/ None, + ); + + let mode = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Some(mode) = manager.cached_recommended_plugins_mode(&cache_key) { + break mode; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("recommended plugins cache should be warmed"); + assert_eq!( + mode, + RecommendedPluginsMode::Endpoint { + plugins: Vec::new() + } + ); + assert_eq!( + manager + .recommended_plugins_mode_for_config(&config, Some(&auth)) + .await, + mode + ); + manager.clear_recommended_plugins_cache(); + assert_eq!(manager.cached_recommended_plugins_mode(&cache_key), None); +} + +#[tokio::test] +async fn recommended_plugins_mode_deduplicates_concurrent_cache_misses() { + let tmp = tempfile::tempdir().unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = true +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/ps/plugins/suggested")) + .and(query_param("scope", "GLOBAL")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .and(header("OAI-Product-Sku", "codex")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ + "enabled": true, + "plugins": [ + { + "id": "plugin_slack", + "name": "slack", + "release": { + "display_name": "Slack", + "app_ids": ["connector_slack"] + } + }, + { + "id": "plugin_github", + "name": "github", + "release": {"display_name": "GitHub"} + } + ] + })) + .set_delay(Duration::from_millis(100)), + ) + .expect(1) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = server.uri(); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let expected = RecommendedPluginsMode::Endpoint { + plugins: vec![ + RecommendedPlugin { + config_id: "github@openai-curated-remote".to_string(), + remote_plugin_id: "plugin_github".to_string(), + display_name: "GitHub".to_string(), + app_connector_ids: Vec::new(), + }, + RecommendedPlugin { + config_id: "slack@openai-curated-remote".to_string(), + remote_plugin_id: "plugin_slack".to_string(), + display_name: "Slack".to_string(), + app_connector_ids: vec!["connector_slack".to_string()], + }, + ], + }; + + let (left, right) = tokio::join!( + manager.recommended_plugins_mode_for_config(&config, Some(&auth)), + manager.recommended_plugins_mode_for_config(&config, Some(&auth)), + ); + assert_eq!((left, right), (expected.clone(), expected.clone())); + assert_eq!( + manager + .recommended_plugins_mode_for_config(&config, Some(&auth)) + .await, + expected + ); +} + +#[tokio::test] +async fn recommended_plugins_mode_caches_explicit_false() { + let tmp = tempfile::tempdir().unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = true +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/ps/plugins/suggested")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "enabled": false, + "plugins": [] + }))) + .expect(1) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = server.uri(); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + assert_eq!( + manager + .recommended_plugins_mode_for_config(&config, Some(&auth)) + .await, + RecommendedPluginsMode::Legacy + ); + assert_eq!( + manager + .recommended_plugins_mode_for_config(&config, Some(&auth)) + .await, + RecommendedPluginsMode::Legacy + ); +} + +#[tokio::test] +async fn recommended_plugins_mode_retries_after_fetch_failure() { + let tmp = tempfile::tempdir().unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = true +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/ps/plugins/suggested")) + .respond_with(ResponseTemplate::new(500).set_body_string("unavailable")) + .expect(1) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = server.uri(); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + assert_eq!( + manager + .recommended_plugins_mode_for_config(&config, Some(&auth)) + .await, + RecommendedPluginsMode::Legacy + ); + + server.reset().await; + Mock::given(method("GET")) + .and(path("/ps/plugins/suggested")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "enabled": true, + "plugins": [] + }))) + .expect(1) + .mount(&server) + .await; + + assert_eq!( + manager + .recommended_plugins_mode_for_config(&config, Some(&auth)) + .await, + RecommendedPluginsMode::Endpoint { + plugins: Vec::new() + } + ); +} + #[test] fn refresh_curated_plugin_cache_replaces_existing_local_version_with_short_sha_version() { let tmp = tempfile::tempdir().unwrap(); diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 1a712c08a9..1f8d3f7201 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -79,7 +79,11 @@ const OPENAI_CURATED_REMOTE_COLLECTION_KEY: &str = "vertical"; const OAI_PRODUCT_SKU_HEADER: &str = "OAI-Product-Sku"; const CODEX_PRODUCT_SKU: &str = "codex"; const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30); +const RECOMMENDED_PLUGINS_TIMEOUT: Duration = Duration::from_secs(5); const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200; +const MAX_RECOMMENDED_PLUGINS: usize = 50; +const MAX_RECOMMENDED_PLUGIN_NAME_LEN: usize = 64; +const MAX_RECOMMENDED_PLUGIN_DISPLAY_NAME_LEN: usize = 64; const MAX_REMOTE_DEFAULT_PROMPT_COUNT: usize = 3; const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128; const INVALID_REQUEST_ERROR_CODE: i64 = -32600; @@ -237,6 +241,20 @@ pub struct RemoteDiscoverablePlugin { pub availability: PluginAvailability, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecommendedPlugin { + pub config_id: String, + pub remote_plugin_id: String, + pub display_name: String, + pub app_connector_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecommendedPluginsMode { + Legacy, + Endpoint { plugins: Vec }, +} + pub fn is_valid_remote_plugin_id(plugin_id: &str) -> bool { !plugin_id.is_empty() && plugin_id @@ -568,6 +586,32 @@ struct RemotePluginListResponse { pagination: RemotePluginPagination, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RecommendedPluginsResponse { + #[serde(default)] + enabled: Option, + #[serde(default)] + plugins: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RecommendedPluginItem { + id: String, + name: String, + #[serde(default)] + status: Option, + #[serde(default)] + installation_policy: Option, + release: RecommendedPluginRelease, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RecommendedPluginRelease { + display_name: String, + #[serde(default)] + app_ids: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginInstalledResponse { plugins: Vec, @@ -761,6 +805,86 @@ pub async fn fetch_and_cache_global_remote_plugin_catalog( Ok(()) } +pub async fn fetch_recommended_plugins( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, +) -> Result { + let auth = ensure_chatgpt_auth(auth)?; + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/ps/plugins/suggested"); + let client = build_reqwest_client(); + let request = authenticated_request(client.get(&url), auth)? + .timeout(RECOMMENDED_PLUGINS_TIMEOUT) + .query(&[("scope", "GLOBAL")]); + let response: RecommendedPluginsResponse = send_and_decode(request, &url).await?; + Ok(recommended_plugins_mode(response)) +} + +fn recommended_plugins_mode(response: RecommendedPluginsResponse) -> RecommendedPluginsMode { + if response.enabled != Some(true) { + return RecommendedPluginsMode::Legacy; + } + + let mut plugins = BTreeMap::new(); + for plugin in response.plugins { + if !is_valid_remote_plugin_id(&plugin.id) + || plugin.name.chars().count() > MAX_RECOMMENDED_PLUGIN_NAME_LEN + || plugin + .status + .is_some_and(|status| status != PluginAvailability::Available) + || plugin + .installation_policy + .is_some_and(|policy| policy != PluginInstallPolicy::Available) + { + continue; + } + let plugin_id = match PluginId::new( + plugin.name.clone(), + REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), + ) { + Ok(plugin_id) => plugin_id, + Err(err) => { + tracing::warn!( + plugin_name = plugin.name, + error = %err, + "ignoring invalid recommended plugin" + ); + continue; + } + }; + let RecommendedPluginRelease { + display_name, + app_ids, + } = plugin.release; + let display_name = non_empty_string(Some(&display_name)) + .unwrap_or_else(|| plugin.name.clone()) + .chars() + .take(MAX_RECOMMENDED_PLUGIN_DISPLAY_NAME_LEN) + .collect(); + let mut seen_app_ids = HashSet::new(); + let app_connector_ids = app_ids + .into_iter() + .filter(|app_id| !app_id.is_empty() && seen_app_ids.insert(app_id.clone())) + .collect(); + let config_id = plugin_id.as_key(); + plugins + .entry(config_id.clone()) + .or_insert(RecommendedPlugin { + config_id, + remote_plugin_id: plugin.id, + display_name, + app_connector_ids, + }); + } + + RecommendedPluginsMode::Endpoint { + plugins: plugins + .into_values() + .take(MAX_RECOMMENDED_PLUGINS) + .collect(), + } +} + pub fn has_cached_global_remote_plugin_catalog( codex_home: &Path, config: &RemotePluginServiceConfig, diff --git a/codex-rs/core-plugins/src/remote_tests.rs b/codex-rs/core-plugins/src/remote_tests.rs index cff134b80d..793a8a55bd 100644 --- a/codex-rs/core-plugins/src/remote_tests.rs +++ b/codex-rs/core-plugins/src/remote_tests.rs @@ -76,3 +76,206 @@ fn directory_plugin(id: &str, name: &str) -> RemotePluginDirectoryItem { }, } } +fn item(name: &str, display_name: &str) -> RecommendedPluginItem { + RecommendedPluginItem { + id: format!("plugin_{name}"), + name: name.to_string(), + status: None, + installation_policy: None, + release: RecommendedPluginRelease { + display_name: display_name.to_string(), + app_ids: Vec::new(), + }, + } +} + +#[test] +fn recommended_plugins_enabled_flag_selects_endpoint_or_legacy_mode() { + let disabled: RecommendedPluginsResponse = serde_json::from_value(serde_json::json!({ + "enabled": false, + "plugins": [{"id": "plugin_github", "name": "github", "release": {"display_name": "GitHub"}}] + })) + .expect("response should deserialize"); + assert_eq!( + recommended_plugins_mode(disabled), + RecommendedPluginsMode::Legacy + ); + + for response in [ + serde_json::json!({"plugins": []}), + serde_json::json!({"enabled": null, "plugins": []}), + ] { + let response: RecommendedPluginsResponse = + serde_json::from_value(response).expect("response should deserialize"); + assert_eq!( + recommended_plugins_mode(response), + RecommendedPluginsMode::Legacy + ); + } + + let enabled: RecommendedPluginsResponse = serde_json::from_value(serde_json::json!({ + "enabled": true, + "plugins": [] + })) + .expect("response should deserialize"); + assert_eq!( + recommended_plugins_mode(enabled), + RecommendedPluginsMode::Endpoint { + plugins: Vec::new() + } + ); +} + +#[test] +fn recommended_plugins_require_remote_install_identity() { + let response = serde_json::from_value::(serde_json::json!({ + "enabled": true, + "plugins": [{ + "name": "github", + "release": {"display_name": "GitHub"} + }] + })); + + assert!(response.is_err()); +} + +#[test] +fn recommended_plugins_are_validated_deduplicated_sorted_and_capped() { + let mut plugins = (0..=52) + .rev() + .map(|index| item(&format!("plugin-{index:02}"), &format!("Plugin {index:02}"))) + .collect::>(); + plugins.push(item("plugin-00", "Duplicate")); + plugins.push(item("not/a/plugin", "Invalid")); + plugins.push(RecommendedPluginItem { + id: "plugin_disabled".to_string(), + name: "disabled".to_string(), + status: Some(PluginAvailability::DisabledByAdmin), + installation_policy: Some(PluginInstallPolicy::Available), + release: RecommendedPluginRelease { + display_name: "Disabled".to_string(), + app_ids: Vec::new(), + }, + }); + plugins.push(RecommendedPluginItem { + id: "plugin_not_available".to_string(), + name: "not-available".to_string(), + status: Some(PluginAvailability::Available), + installation_policy: Some(PluginInstallPolicy::NotAvailable), + release: RecommendedPluginRelease { + display_name: "Not Available".to_string(), + app_ids: Vec::new(), + }, + }); + + let mode = recommended_plugins_mode(RecommendedPluginsResponse { + enabled: Some(true), + plugins, + }); + let RecommendedPluginsMode::Endpoint { plugins } = mode else { + panic!("expected endpoint mode"); + }; + + assert_eq!(plugins.len(), MAX_RECOMMENDED_PLUGINS); + assert_eq!( + plugins.first(), + Some(&RecommendedPlugin { + config_id: "plugin-00@openai-curated-remote".to_string(), + remote_plugin_id: "plugin_plugin-00".to_string(), + display_name: "Plugin 00".to_string(), + app_connector_ids: Vec::new(), + }) + ); + assert_eq!( + plugins.last(), + Some(&RecommendedPlugin { + config_id: "plugin-49@openai-curated-remote".to_string(), + remote_plugin_id: "plugin_plugin-49".to_string(), + display_name: "Plugin 49".to_string(), + app_connector_ids: Vec::new(), + }) + ); +} + +#[test] +fn recommended_plugins_bound_model_visible_fields() { + let overlong_name = "n".repeat(MAX_RECOMMENDED_PLUGIN_NAME_LEN + 1); + let overlong_display_name = "D".repeat(MAX_RECOMMENDED_PLUGIN_DISPLAY_NAME_LEN + 1); + let mode = recommended_plugins_mode(RecommendedPluginsResponse { + enabled: Some(true), + plugins: vec![ + item(&overlong_name, "Ignored"), + item("bounded", &overlong_display_name), + ], + }); + + assert_eq!( + mode, + RecommendedPluginsMode::Endpoint { + plugins: vec![RecommendedPlugin { + config_id: "bounded@openai-curated-remote".to_string(), + remote_plugin_id: "plugin_bounded".to_string(), + display_name: "D".repeat(MAX_RECOMMENDED_PLUGIN_DISPLAY_NAME_LEN), + app_connector_ids: Vec::new(), + }], + } + ); +} + +#[test] +fn recommended_plugins_preserve_install_identity_and_normalize_app_ids() { + let mode = recommended_plugins_mode(RecommendedPluginsResponse { + enabled: Some(true), + plugins: vec![RecommendedPluginItem { + id: "plugin_connector_sample".to_string(), + name: "sample".to_string(), + status: Some(PluginAvailability::Available), + installation_policy: Some(PluginInstallPolicy::Available), + release: RecommendedPluginRelease { + display_name: "Sample".to_string(), + app_ids: vec![ + "connector_one".to_string(), + String::new(), + "connector_two".to_string(), + "connector_one".to_string(), + ], + }, + }], + }); + + assert_eq!( + mode, + RecommendedPluginsMode::Endpoint { + plugins: vec![RecommendedPlugin { + config_id: "sample@openai-curated-remote".to_string(), + remote_plugin_id: "plugin_connector_sample".to_string(), + display_name: "Sample".to_string(), + app_connector_ids: vec!["connector_one".to_string(), "connector_two".to_string(),], + }], + } + ); +} + +#[test] +fn recommended_plugins_ignore_invalid_remote_plugin_ids() { + let mode = recommended_plugins_mode(RecommendedPluginsResponse { + enabled: Some(true), + plugins: vec![RecommendedPluginItem { + id: "not/a/plugin".to_string(), + name: "sample".to_string(), + status: None, + installation_policy: None, + release: RecommendedPluginRelease { + display_name: "Sample".to_string(), + app_ids: Vec::new(), + }, + }], + }); + + assert_eq!( + mode, + RecommendedPluginsMode::Endpoint { + plugins: Vec::new(), + } + ); +} From 587487df9ec1e8807f2cea7279cd0174a29d3195 Mon Sep 17 00:00:00 2001 From: Alex Daley Date: Tue, 16 Jun 2026 18:44:10 -0400 Subject: [PATCH 09/70] [codex] [2/4] Generalize plugin suggestion presentation (#28400) Summary - Add list-backed and developer-context presentations for plugin suggestion candidates. - Let tool planning, install validation, and request-tool copy follow the selected presentation. - Keep every production caller on the existing list-backed presentation, preserving the current list tool, request schema, connector behavior, and model-visible copy. - Leave developer-context presentation latent until the final PR in the stack. Stack - 2/3, based on #28399. - Follow-up: #27704 activates endpoint recommendations. Validation - `just test -p codex-core request_plugin_install` - `just test -p codex-core spec_plan` - `just fix -p codex-core` - `just fmt` - `git diff --check` --- codex-rs/core/src/session/tests.rs | 4 +- codex-rs/core/src/session/turn.rs | 11 +- .../core/src/stream_events_utils_tests.rs | 2 +- .../tools/handlers/request_plugin_install.rs | 24 +++- .../handlers/request_plugin_install_spec.rs | 34 +++++- codex-rs/core/src/tools/router.rs | 15 ++- codex-rs/core/src/tools/router_tests.rs | 10 +- codex-rs/core/src/tools/spec_plan.rs | 23 ++-- codex-rs/core/src/tools/spec_plan_tests.rs | 103 ++++++++++-------- 9 files changed, 147 insertions(+), 79 deletions(-) diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b4b786c902..6f0e730ca5 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -571,9 +571,9 @@ fn test_tool_runtime(session: Arc, turn_context: Arc) -> T let router = Arc::new(ToolRouter::from_turn_context( &turn_context, crate::tools::router::ToolRouterParams { + tool_suggest_candidates: None, mcp_tools: None, deferred_mcp_tools: None, - discoverable_tools: None, extension_tool_executors: Vec::new(), dynamic_tools: turn_context.dynamic_tools.as_slice(), }, @@ -9600,9 +9600,9 @@ async fn fatal_tool_error_stops_turn_and_reports_error() { let router = ToolRouter::from_turn_context( &turn_context, crate::tools::router::ToolRouterParams { + tool_suggest_candidates: None, deferred_mcp_tools, mcp_tools: Some(tools), - discoverable_tools: None, extension_tool_executors: Vec::new(), dynamic_tools: turn_context.dynamic_tools.as_slice(), }, diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index a105aeffee..8eb7d5f9e2 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -58,6 +58,8 @@ use crate::tools::context::SharedTurnDiffTracker; use crate::tools::parallel::ToolCallRuntime; use crate::tools::registry::ToolArgumentDiffConsumer; use crate::tools::router::ToolRouterParams; +use crate::tools::router::ToolSuggestCandidates; +use crate::tools::router::ToolSuggestPresentation; use crate::tools::router::extension_tool_executors; use crate::tools::spec_plan::search_tool_enabled; use crate::tools::spec_plan::tool_suggest_enabled; @@ -1190,7 +1192,7 @@ pub(crate) async fn built_tools( .into_iter() .map(|connector_id| connector_id.0) .collect::>(); - let discoverable_tools = async { + let tool_suggest_candidates = async { if apps_enabled && tool_suggest_enabled(turn_context) { if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() { match connectors::list_tool_suggest_discoverable_tools_with_auth( @@ -1208,7 +1210,10 @@ pub(crate) async fn built_tools( ) }) { Ok(discoverable_tools) if discoverable_tools.is_empty() => None, - Ok(discoverable_tools) => Some(discoverable_tools), + Ok(discoverable_tools) => Some(ToolSuggestCandidates { + tools: discoverable_tools, + presentation: ToolSuggestPresentation::ListTool, + }), Err(err) => { warn!("failed to load discoverable tool suggestions: {err:#}"); None @@ -1237,7 +1242,7 @@ pub(crate) async fn built_tools( ToolRouterParams { mcp_tools, deferred_mcp_tools, - discoverable_tools, + tool_suggest_candidates, extension_tool_executors: extension_tool_executors(sess), dynamic_tools: turn_context.dynamic_tools.as_slice(), }, diff --git a/codex-rs/core/src/stream_events_utils_tests.rs b/codex-rs/core/src/stream_events_utils_tests.rs index fdb8cddf46..b89d4f5c6e 100644 --- a/codex-rs/core/src/stream_events_utils_tests.rs +++ b/codex-rs/core/src/stream_events_utils_tests.rs @@ -279,9 +279,9 @@ async fn handle_output_item_done_returns_contributed_last_agent_message() { let router = Arc::new(ToolRouter::from_turn_context( &turn_context, crate::tools::router::ToolRouterParams { + tool_suggest_candidates: None, mcp_tools: None, deferred_mcp_tools: None, - discoverable_tools: None, extension_tool_executors: Vec::new(), dynamic_tools: turn_context.dynamic_tools.as_slice(), }, diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index 55906185ab..c4622615d3 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -37,14 +37,22 @@ use crate::tools::handlers::parse_arguments; use crate::tools::handlers::request_plugin_install_spec::create_request_plugin_install_tool; use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; +use crate::tools::router::ToolSuggestPresentation; pub struct RequestPluginInstallHandler { discoverable_tools: Vec, + presentation: ToolSuggestPresentation, } impl RequestPluginInstallHandler { - pub(crate) fn new(discoverable_tools: Vec) -> Self { - Self { discoverable_tools } + pub(crate) fn new( + discoverable_tools: Vec, + presentation: ToolSuggestPresentation, + ) -> Self { + Self { + discoverable_tools, + presentation, + } } } @@ -54,7 +62,7 @@ impl ToolExecutor for RequestPluginInstallHandler { } fn spec(&self) -> ToolSpec { - create_request_plugin_install_tool() + create_request_plugin_install_tool(self.presentation) } fn supports_parallel_tool_calls(&self) -> bool { @@ -118,8 +126,16 @@ impl RequestPluginInstallHandler { .into_iter() .find(|tool| tool.tool_type() == args.tool_type && tool.id() == args.tool_id) .ok_or_else(|| { + let source = match self.presentation { + ToolSuggestPresentation::ListTool => format!( + "the discoverable tools returned by {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}" + ), + ToolSuggestPresentation::DeveloperContext => { + "the developer recommendations".to_string() + } + }; FunctionCallError::RespondToModel(format!( - "tool_id must match one of the discoverable tools returned by {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}" + "tool_id must match one of {source}" )) })?; diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs b/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs index ed143283e7..1c9bfec01a 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs @@ -5,7 +5,11 @@ use codex_tools::ResponsesApiTool; use codex_tools::ToolSpec; use std::collections::BTreeMap; -pub(crate) fn create_request_plugin_install_tool() -> ToolSpec { +use crate::tools::router::ToolSuggestPresentation; + +pub(crate) fn create_request_plugin_install_tool( + presentation: ToolSuggestPresentation, +) -> ToolSpec { let properties = BTreeMap::from([ ( "tool_type".to_string(), @@ -31,9 +35,13 @@ pub(crate) fn create_request_plugin_install_tool() -> ToolSpec { ), ]); - let description = format!( - "# Request plugin/connector install\n\nUse this tool only after `{LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}` returns a plugin or connector that exactly matches the user's explicit request.\n\nDo not use it for adjacent capabilities, broad recommendations, or tools that merely seem useful. Pass the returned `tool_type` through directly, and pass the returned `id` as `tool_id`.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools." - ); + let description = match presentation { + ToolSuggestPresentation::ListTool => format!( + "# Request plugin/connector install\n\nUse this tool only after `{LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}` returns a plugin or connector that exactly matches the user's explicit request.\n\nDo not use it for adjacent capabilities, broad recommendations, or tools that merely seem useful. Pass the returned `tool_type` through directly, and pass the returned `id` as `tool_id`.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools." + ), + ToolSuggestPresentation::DeveloperContext => + "# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the developer `` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string(), + }; ToolSpec::Function(ResponsesApiTool { name: REQUEST_PLUGIN_INSTALL_TOOL_NAME.to_string(), @@ -62,7 +70,7 @@ mod tests { use std::collections::BTreeMap; #[test] - fn create_request_plugin_install_tool_uses_expected_wire_shape() { + fn create_request_plugin_install_tool_uses_expected_legacy_wire_shape() { let expected_description = concat!( "# Request plugin/connector install\n\n", "Use this tool only after `list_available_plugins_to_install` returns a plugin or connector that exactly matches the user's explicit request.\n\n", @@ -71,7 +79,7 @@ mod tests { ); assert_eq!( - create_request_plugin_install_tool(), + create_request_plugin_install_tool(ToolSuggestPresentation::ListTool), ToolSpec::Function(ResponsesApiTool { name: "request_plugin_install".to_string(), description: expected_description.to_string(), @@ -116,4 +124,18 @@ mod tests { }) ); } + + #[test] + fn developer_recommendations_change_only_the_description() { + let mut expected = create_request_plugin_install_tool(ToolSuggestPresentation::ListTool); + let recommendations = + create_request_plugin_install_tool(ToolSuggestPresentation::DeveloperContext); + + let ToolSpec::Function(expected_function) = &mut expected else { + panic!("expected function tool specs"); + }; + expected_function.description = "# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the developer `` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string(); + + assert_eq!(recommendations, expected); + } } diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index d7f622c039..7cc72395ff 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -40,11 +40,24 @@ pub struct ToolRouter { pub(crate) struct ToolRouterParams<'a> { pub(crate) mcp_tools: Option>, pub(crate) deferred_mcp_tools: Option>, - pub(crate) discoverable_tools: Option>, + pub(crate) tool_suggest_candidates: Option, pub(crate) extension_tool_executors: Vec>>, pub(crate) dynamic_tools: &'a [DynamicToolSpec], } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ToolSuggestPresentation { + ListTool, + #[allow(dead_code)] + DeveloperContext, +} + +#[derive(Clone, Debug)] +pub(crate) struct ToolSuggestCandidates { + pub(crate) tools: Vec, + pub(crate) presentation: ToolSuggestPresentation, +} + impl ToolRouter { pub(crate) fn from_turn_context( turn_context: &TurnContext, diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index 0a7345a84e..a38ab8af26 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -114,9 +114,9 @@ async fn parallel_support_does_not_match_namespaced_local_tool_names() -> anyhow let router = ToolRouter::from_turn_context( &turn, ToolRouterParams { + tool_suggest_candidates: None, deferred_mcp_tools: None, mcp_tools: Some(mcp_tools), - discoverable_tools: None, extension_tool_executors: Vec::new(), dynamic_tools: turn.dynamic_tools.as_slice(), }, @@ -182,6 +182,7 @@ async fn mcp_parallel_support_uses_handler_data() -> anyhow::Result<()> { let router = ToolRouter::from_turn_context( &turn, ToolRouterParams { + tool_suggest_candidates: None, deferred_mcp_tools: None, mcp_tools: Some(vec![ mcp_tool_info( @@ -197,7 +198,6 @@ async fn mcp_parallel_support_uses_handler_data() -> anyhow::Result<()> { "query_with_delay", ), ]), - discoverable_tools: None, extension_tool_executors: Vec::new(), dynamic_tools: turn.dynamic_tools.as_slice(), }, @@ -231,9 +231,9 @@ async fn tools_without_handlers_do_not_support_parallel() -> anyhow::Result<()> let router = ToolRouter::from_turn_context( &turn, ToolRouterParams { + tool_suggest_candidates: None, deferred_mcp_tools: None, mcp_tools: None, - discoverable_tools: None, extension_tool_executors: Vec::new(), dynamic_tools: turn.dynamic_tools.as_slice(), }, @@ -286,9 +286,9 @@ async fn specs_filter_deferred_dynamic_tools() -> anyhow::Result<()> { let router = ToolRouter::from_turn_context( &turn, ToolRouterParams { + tool_suggest_candidates: None, deferred_mcp_tools: None, mcp_tools: None, - discoverable_tools: None, extension_tool_executors: Vec::new(), dynamic_tools: &dynamic_tools, }, @@ -349,9 +349,9 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow let router = ToolRouter::from_turn_context( &turn, ToolRouterParams { + tool_suggest_candidates: None, deferred_mcp_tools: None, mcp_tools: None, - discoverable_tools: None, extension_tool_executors: extension_tool_executors(&session), dynamic_tools: turn.dynamic_tools.as_slice(), }, diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 026d579a3c..9269aa379b 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -68,7 +68,6 @@ use codex_protocol::openai_models::ToolMode; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; -use codex_tools::DiscoverableTool; use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::TOOL_SEARCH_TOOL_NAME; @@ -145,7 +144,7 @@ struct CoreToolPlanContext<'a> { turn_context: &'a TurnContext, mcp_tools: Option<&'a [ToolInfo]>, deferred_mcp_tools: Option<&'a [ToolInfo]>, - discoverable_tools: Option<&'a [DiscoverableTool]>, + tool_suggest_candidates: Option<&'a crate::tools::router::ToolSuggestCandidates>, extension_tool_executors: &'a [Arc>], dynamic_tools: &'a [DynamicToolSpec], tool_search_handler_cache: &'a ToolSearchHandlerCache, @@ -173,7 +172,7 @@ fn build_tool_specs_and_registry( let ToolRouterParams { mcp_tools, deferred_mcp_tools, - discoverable_tools, + tool_suggest_candidates, extension_tool_executors, dynamic_tools, } = params; @@ -183,7 +182,7 @@ fn build_tool_specs_and_registry( turn_context, mcp_tools: mcp_tools.as_deref(), deferred_mcp_tools: deferred_mcp_tools.as_deref(), - discoverable_tools: discoverable_tools.as_deref(), + tool_suggest_candidates: tool_suggest_candidates.as_ref(), extension_tool_executors: &extension_tool_executors, dynamic_tools, tool_search_handler_cache, @@ -672,14 +671,18 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut } if tool_suggest_enabled(turn_context) - && let Some(discoverable_tools) = - context.discoverable_tools.filter(|tools| !tools.is_empty()) + && let Some(candidates) = context + .tool_suggest_candidates + .filter(|candidates| !candidates.tools.is_empty()) { - planned_tools.add(ListAvailablePluginsToInstallHandler::new( - collect_request_plugin_install_entries(discoverable_tools), - )); + if candidates.presentation == crate::tools::router::ToolSuggestPresentation::ListTool { + planned_tools.add(ListAvailablePluginsToInstallHandler::new( + collect_request_plugin_install_entries(&candidates.tools), + )); + } planned_tools.add(RequestPluginInstallHandler::new( - discoverable_tools.to_vec(), + candidates.tools.clone(), + candidates.presentation, )); } diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 5faee28682..ee7cd7e359 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -36,12 +36,14 @@ use crate::tools::handlers::ToolSearchHandlerCache; use crate::tools::handlers::multi_agents_spec::MULTI_AGENT_V1_NAMESPACE; use crate::tools::router::ToolRouter; use crate::tools::router::ToolRouterParams; +use crate::tools::router::ToolSuggestCandidates; +use crate::tools::router::ToolSuggestPresentation; #[derive(Default)] struct ToolPlanInputs { mcp_tools: Option>, deferred_mcp_tools: Option>, - discoverable_tools: Option>, + tool_suggest_candidates: Option, extension_tool_executors: Vec>>, dynamic_tools: Vec, } @@ -179,9 +181,9 @@ async fn probe_with( let router = ToolRouter::from_turn_context( &turn, ToolRouterParams { + tool_suggest_candidates: inputs.tool_suggest_candidates, mcp_tools: inputs.mcp_tools, deferred_mcp_tools: inputs.deferred_mcp_tools, - discoverable_tools: inputs.discoverable_tools, extension_tool_executors: inputs.extension_tool_executors, dynamic_tools: inputs.dynamic_tools.as_slice(), }, @@ -410,17 +412,19 @@ fn dynamic_tool(namespace: Option<&str>, name: &str, defer_loading: bool) -> Dyn } } -fn discoverable_plugin(id: &str, name: &str) -> DiscoverableTool { - DiscoverablePluginInfo { - id: id.to_string(), - remote_plugin_id: None, - name: name.to_string(), - description: Some(format!("{name} plugin")), - has_skills: false, - mcp_server_names: Vec::new(), - app_connector_ids: Vec::new(), +fn plugin_candidates(presentation: ToolSuggestPresentation) -> ToolSuggestCandidates { + ToolSuggestCandidates { + tools: vec![DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo { + id: "github@openai-curated-remote".to_string(), + remote_plugin_id: None, + name: "GitHub".to_string(), + description: Some("Work with GitHub repositories".to_string()), + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }))], + presentation, } - .into() } fn has_parameter(spec: &ToolSpec, parameter_name: &str) -> bool { @@ -793,7 +797,7 @@ async fn tool_search_cache_rebuilds_when_deferred_sources_change() { ToolRouterParams { mcp_tools: None, deferred_mcp_tools: Some(vec![mcp_tool("first", "mcp__first", "lookup")]), - discoverable_tools: None, + tool_suggest_candidates: None, extension_tool_executors: Vec::new(), dynamic_tools: &[], }, @@ -808,7 +812,7 @@ async fn tool_search_cache_rebuilds_when_deferred_sources_change() { ToolRouterParams { mcp_tools: None, deferred_mcp_tools: Some(vec![mcp_tool("second", "mcp__second", "lookup")]), - discoverable_tools: None, + tool_suggest_candidates: None, extension_tool_executors: Vec::new(), dynamic_tools: &[], }, @@ -853,8 +857,7 @@ async fn invalid_mcp_tools_are_not_registered() { } #[tokio::test] -async fn request_plugin_install_requires_all_discovery_features_and_discoverable_tools() { - let discoverable_tools = Some(vec![discoverable_plugin("github", "GitHub")]); +async fn request_plugin_install_requires_all_discovery_features() { for disabled_feature in [Feature::ToolSuggest, Feature::Apps, Feature::Plugins] { let plan = probe_with( |turn| { @@ -865,7 +868,7 @@ async fn request_plugin_install_requires_all_discovery_features_and_discoverable set_feature(turn, disabled_feature, /*enabled*/ false); }, ToolPlanInputs { - discoverable_tools: discoverable_tools.clone(), + tool_suggest_candidates: Some(plugin_candidates(ToolSuggestPresentation::ListTool)), ..ToolPlanInputs::default() }, ) @@ -876,17 +879,31 @@ async fn request_plugin_install_requires_all_discovery_features_and_discoverable ]); } - let no_candidates = probe(|turn| { - set_features( - turn, - &[Feature::ToolSuggest, Feature::Apps, Feature::Plugins], - ); - }) - .await; - no_candidates.assert_visible_lacks(&[ - "list_available_plugins_to_install", - "request_plugin_install", - ]); + for tool_suggest_candidates in [ + None, + Some(ToolSuggestCandidates { + tools: Vec::new(), + presentation: ToolSuggestPresentation::DeveloperContext, + }), + ] { + let plan = probe_with( + |turn| { + set_features( + turn, + &[Feature::ToolSuggest, Feature::Apps, Feature::Plugins], + ); + }, + ToolPlanInputs { + tool_suggest_candidates, + ..ToolPlanInputs::default() + }, + ) + .await; + plan.assert_visible_lacks(&[ + "list_available_plugins_to_install", + "request_plugin_install", + ]); + } let enabled = probe_with( |turn| { @@ -896,7 +913,7 @@ async fn request_plugin_install_requires_all_discovery_features_and_discoverable ); }, ToolPlanInputs { - discoverable_tools, + tool_suggest_candidates: Some(plugin_candidates(ToolSuggestPresentation::ListTool)), ..ToolPlanInputs::default() }, ) @@ -908,7 +925,7 @@ async fn request_plugin_install_requires_all_discovery_features_and_discoverable } #[tokio::test] -async fn install_suggestion_tools_stay_visible_without_tool_search() { +async fn request_plugin_install_stays_visible_without_tool_search() { let plan = probe_with( |turn| { turn.model_info.supports_search_tool = false; @@ -918,7 +935,7 @@ async fn install_suggestion_tools_stay_visible_without_tool_search() { ); }, ToolPlanInputs { - discoverable_tools: Some(vec![discoverable_plugin("github", "GitHub")]), + tool_suggest_candidates: Some(plugin_candidates(ToolSuggestPresentation::ListTool)), ..ToolPlanInputs::default() }, ) @@ -932,7 +949,7 @@ async fn install_suggestion_tools_stay_visible_without_tool_search() { } #[tokio::test] -async fn request_plugin_install_description_defers_inventory_to_list_tool() { +async fn request_plugin_install_description_refers_to_recommended_plugins_hint() { let plan = probe_with( |turn| { set_features( @@ -941,23 +958,14 @@ async fn request_plugin_install_description_defers_inventory_to_list_tool() { ); }, ToolPlanInputs { - discoverable_tools: Some(vec![discoverable_plugin("github", "GitHub")]), + tool_suggest_candidates: Some(plugin_candidates( + ToolSuggestPresentation::DeveloperContext, + )), ..ToolPlanInputs::default() }, ) .await; - let ToolSpec::Function(ResponsesApiTool { - description: list_description, - .. - }) = plan.visible_spec("list_available_plugins_to_install") - else { - panic!("expected list_available_plugins_to_install function spec"); - }; - assert!(list_description.contains( - "Returns known plugins and connectors that can be passed to `request_plugin_install`." - )); - let ToolSpec::Function(ResponsesApiTool { description: request_description, .. @@ -965,10 +973,11 @@ async fn request_plugin_install_description_defers_inventory_to_list_tool() { else { panic!("expected request_plugin_install function spec"); }; - assert!(request_description.contains( - "Use this tool only after `list_available_plugins_to_install` returns a plugin or connector that exactly matches the user's explicit request." - )); + assert!(request_description.contains("developer `` list")); + assert!(!request_description.contains("list_available_plugins_to_install")); assert!(!request_description.contains("github")); + plan.assert_visible_lacks(&["list_available_plugins_to_install"]); + plan.assert_registered_lacks(&["list_available_plugins_to_install"]); } #[tokio::test] From a34da3b2956bbd4b1e927139903bd3c91c880f84 Mon Sep 17 00:00:00 2001 From: Alex Daley Date: Tue, 16 Jun 2026 19:04:07 -0400 Subject: [PATCH 10/70] [codex] [3/4] Activate endpoint plugin recommendations (#27704) Summary\n- Await endpoint recommendation selection while constructing each authenticated turn, removing the first-turn cache race.\n- Snapshot and filter endpoint candidates once per turn, then use that same set for the bounded contextual user fragment, tool exposure, and exact install validation.\n- Keep recommendation selection ephemeral: do not persist recommendation state in or gate resumed threads on prior context.\n- Hide the legacy list tool in endpoint mode and preserve legacy discovery unchanged when the endpoint is disabled or unavailable.\n- Keep remote plugin and connector app identities out of model-visible context and attach them only to Codex-owned elicitation metadata.\n\nStack\n- 3/4, based on #28400.\n- Endpoint client and cache: #28399.\n- Generalized suggestion presentation: #28400.\n- Install-schema follow-up: #28403.\n\nValidation\n- \n- \n- \n- \n- Full : 2,649 passed and 88 environment-dependent tests failed because this sandbox cannot write , nest Seatbelt, or locate auxiliary test binaries. --- codex-rs/Cargo.lock | 1 + codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../tests/suite/v2/recommended_plugins.rs | 165 +++++++ codex-rs/core-plugins/Cargo.toml | 1 + codex-rs/core-plugins/src/lib.rs | 1 + codex-rs/core-plugins/src/manager.rs | 67 +++ codex-rs/core-plugins/src/manager_tests.rs | 73 +++ .../src/context/contextual_user_message.rs | 4 + .../context/contextual_user_message_tests.rs | 8 + codex-rs/core/src/context/mod.rs | 2 + .../recommended_plugins_instructions.rs | 49 ++ codex-rs/core/src/session/mod.rs | 25 + codex-rs/core/src/session/turn.rs | 108 +++-- .../tools/handlers/request_plugin_install.rs | 4 +- .../handlers/request_plugin_install_spec.rs | 10 +- codex-rs/core/src/tools/router.rs | 3 +- codex-rs/core/src/tools/spec_plan_tests.rs | 6 +- .../tests/suite/request_plugin_install.rs | 442 +++++++++++++++--- 18 files changed, 846 insertions(+), 124 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/recommended_plugins.rs create mode 100644 codex-rs/core/src/context/recommended_plugins_instructions.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 59cea06edc..a0713e1711 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2760,6 +2760,7 @@ dependencies = [ "codex-otel", "codex-plugin", "codex-protocol", + "codex-tools", "codex-utils-absolute-path", "codex-utils-path-uri", "codex-utils-plugins", diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 917bc1d245..9d07da0660 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -44,6 +44,7 @@ mod process_exec; mod rate_limit_reset_credits; mod rate_limits; mod realtime_conversation; +mod recommended_plugins; mod remote_control; #[cfg(debug_assertions)] mod remote_thread_store; diff --git a/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs b/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs new file mode 100644 index 0000000000..59748a1980 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs @@ -0,0 +1,165 @@ +use anyhow::Result; +use app_test_support::ChatGptIdTokenClaims; +use app_test_support::TestAppServer; +use app_test_support::encode_id_token; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::LoginAccountResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::UserInput; +use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::responses; +use serde_json::Value; +use serde_json::json; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::query_param; + +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(20); +const WORKSPACE_ID: &str = "123e4567-e89b-42d3-a456-426614174010"; + +#[tokio::test] +async fn first_turn_after_external_login_waits_for_recommended_plugins() -> Result<()> { + let server = responses::start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + Mock::given(method("GET")) + .and(path("/ps/plugins/suggested")) + .and(query_param("scope", "GLOBAL")) + .respond_with( + ResponseTemplate::new(200) + .set_delay(Duration::from_millis(250)) + .set_body_json(json!({ + "enabled": true, + "plugins": [{ + "id": "plugin_github", + "name": "github", + "status": "ENABLED", + "installation_policy": "AVAILABLE", + "release": {"display_name": "GitHub"} + }] + })), + ) + .expect(1) + .mount(&server) + .await; + let response = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "done"), + responses::ev_completed("resp-1"), + ]); + let responses_mock = responses::mount_sse_once(&server, response).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &apps_server.chatgpt_base_url, + )?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + config_path, + format!( + "{config}\n[features]\napps = true\nplugins = true\nremote_plugin = true\ntool_suggest = true\n" + ), + )?; + + let sqlite_home = codex_home.path().to_string_lossy(); + let mut app_server = TestAppServer::new_without_managed_config_with_env( + codex_home.path(), + &[("CODEX_SQLITE_HOME", Some(sqlite_home.as_ref()))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; + + let access_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("embedded@example.com") + .plan_type("pro") + .chatgpt_account_id(WORKSPACE_ID), + )?; + let login_id = app_server + .send_chatgpt_auth_tokens_login_request( + access_token, + WORKSPACE_ID.to_string(), + Some("pro".to_string()), + ) + .await?; + let login_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(login_id)), + ) + .await??; + assert_eq!( + to_response::(login_response)?, + LoginAccountResponse::ChatgptAuthTokens {} + ); + + let thread_id = app_server + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(thread_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_response)?; + + let turn_id = app_server + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![UserInput::Text { + text: "suggest a plugin".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = responses_mock.requests(); + let request = requests + .iter() + .find(|request| { + request + .message_input_texts("user") + .iter() + .any(|text| text.contains("suggest a plugin")) + }) + .expect("turn request"); + let contextual_user_message = request.message_input_texts("user").join("\n"); + assert!(contextual_user_message.contains("")); + assert!(contextual_user_message.contains("- GitHub (github@openai-curated-remote)")); + let body = request.body_json(); + let tool_names = body + .get("tools") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|tool| tool.get("name").and_then(Value::as_str)) + .collect::>(); + assert!(tool_names.contains(&"request_plugin_install")); + assert!(!tool_names.contains(&"list_available_plugins_to_install")); + Ok(()) +} diff --git a/codex-rs/core-plugins/Cargo.toml b/codex-rs/core-plugins/Cargo.toml index 1d361559aa..b90192bfe9 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -27,6 +27,7 @@ codex-model-provider = { workspace = true } codex-otel = { workspace = true } codex-plugin = { workspace = true } codex-protocol = { workspace = true } +codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path-uri = { workspace = true } codex-utils-plugins = { workspace = true } diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 1b0f492231..356c0f8bdc 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -49,6 +49,7 @@ pub use manager::PluginReadRequest; pub use manager::PluginUninstallError; pub use manager::PluginsConfigInput; pub use manager::PluginsManager; +pub use manager::RecommendedPluginCandidatesInput; pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError; pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome; pub use provider::ExecutorPluginProvider; diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index e92b8d935b..7fbbcc0023 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -57,6 +57,8 @@ use codex_config::ConfigLayerStack; use codex_config::clear_user_plugin; use codex_config::set_user_plugin_enabled; use codex_config::types::PluginConfig; +use codex_config::types::ToolSuggestDisabledTool; +use codex_config::types::ToolSuggestDiscoverableType; use codex_core_skills::SkillMetadata; use codex_core_skills::config_rules::SkillConfigRules; use codex_core_skills::config_rules::skill_config_rules_from_stack; @@ -71,6 +73,9 @@ use codex_plugin::app_connector_ids_from_declarations; use codex_plugin::prompt_safe_plugin_description; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::Product; +use codex_tools::DiscoverablePluginInfo; +use codex_tools::DiscoverableTool; +use codex_tools::filter_request_plugin_install_discoverable_tools_for_client; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_plugins::PluginSkillRoot; use std::collections::HashMap; @@ -114,6 +119,15 @@ impl PluginsConfigInput { } } +/// Inputs used to select endpoint-backed plugin install candidates. +pub struct RecommendedPluginCandidatesInput<'a> { + pub plugins_config: &'a PluginsConfigInput, + pub loaded_plugins: &'a PluginLoadOutcome, + pub auth: Option<&'a CodexAuth>, + pub disabled_tools: &'a [ToolSuggestDisabledTool], + pub app_server_client_name: Option<&'a str>, +} + #[derive(Clone, PartialEq, Eq)] struct FeaturedPluginIdsCacheKey { chatgpt_base_url: String, @@ -997,6 +1011,59 @@ impl PluginsManager { mode } + /// Returns endpoint recommendations eligible for installation in the current client. + /// `None` selects the legacy discovery workflow. + pub async fn recommended_plugin_candidates_for_config( + &self, + input: RecommendedPluginCandidatesInput<'_>, + ) -> Option> { + let RecommendedPluginsMode::Endpoint { plugins } = self + .recommended_plugins_mode_for_config(input.plugins_config, input.auth) + .await + else { + return None; + }; + if plugins.is_empty() { + return Some(Vec::new()); + } + + let installed_plugin_ids = input + .loaded_plugins + .plugins() + .iter() + .map(|plugin| plugin.config_name.as_str()) + .collect::>(); + let disabled_plugin_ids = input + .disabled_tools + .iter() + .filter(|tool| tool.kind == ToolSuggestDiscoverableType::Plugin) + .map(|tool| tool.id.as_str()) + .collect::>(); + + let candidates = plugins + .into_iter() + .filter(|plugin| { + !installed_plugin_ids.contains(plugin.config_id.as_str()) + && !disabled_plugin_ids.contains(plugin.config_id.as_str()) + }) + .map(|plugin| { + DiscoverableTool::from(DiscoverablePluginInfo { + id: plugin.config_id, + remote_plugin_id: Some(plugin.remote_plugin_id), + name: plugin.display_name, + description: None, + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: plugin.app_connector_ids, + }) + }) + .collect(); + Some(filter_request_plugin_install_discoverable_tools_for_client( + candidates, + input.app_server_client_name, + )) + } + fn cached_recommended_plugins_mode( &self, cache_key: &RecommendedPluginsCacheKey, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index f219f5e760..7c88d114a2 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -3844,6 +3844,79 @@ remote_plugin = true ); } +#[tokio::test] +async fn recommended_plugin_candidates_filter_installed_and_disabled_plugins() { + let tmp = tempfile::tempdir().unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = true +"#, + ); + write_cached_plugin(tmp.path(), REMOTE_GLOBAL_MARKETPLACE_NAME, "linear"); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/ps/plugins/suggested")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "enabled": true, + "plugins": [ + { + "id": "plugin_linear", + "name": "linear", + "release": {"display_name": "Linear"} + }, + { + "id": "plugin_github", + "name": "github", + "release": {"display_name": "GitHub"} + }, + { + "id": "plugin_slack", + "name": "slack", + "release": {"display_name": "Slack"} + } + ] + }))) + .expect(1) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = server.uri(); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("linear")]); + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let disabled_tools = [ToolSuggestDisabledTool::plugin( + "github@openai-curated-remote", + )]; + let loaded_plugins = manager.plugins_for_config(&config).await; + + let candidates = manager + .recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput { + plugins_config: &config, + loaded_plugins: &loaded_plugins, + auth: Some(&auth), + disabled_tools: &disabled_tools, + app_server_client_name: None, + }) + .await; + + assert_eq!( + candidates, + Some(vec![DiscoverableTool::from(DiscoverablePluginInfo { + id: "slack@openai-curated-remote".to_string(), + remote_plugin_id: Some("plugin_slack".to_string()), + name: "Slack".to_string(), + description: None, + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + })]) + ); +} + #[tokio::test] async fn recommended_plugins_mode_caches_explicit_false() { let tmp = tempfile::tempdir().unwrap(); diff --git a/codex-rs/core/src/context/contextual_user_message.rs b/codex-rs/core/src/context/contextual_user_message.rs index 79768dd3a3..de735b6f1e 100644 --- a/codex-rs/core/src/context/contextual_user_message.rs +++ b/codex-rs/core/src/context/contextual_user_message.rs @@ -10,6 +10,7 @@ use super::InternalModelContextFragment; use super::LegacyApplyPatchExecCommandWarning; use super::LegacyModelMismatchWarning; use super::LegacyUnifiedExecProcessLimitWarning; +use super::RecommendedPluginsInstructions; use super::SkillInstructions; use super::SubagentNotification; use super::TurnAborted; @@ -33,6 +34,8 @@ static SUBAGENT_NOTIFICATION_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); +static RECOMMENDED_PLUGINS_REGISTRATION: FragmentRegistrationProxy = + FragmentRegistrationProxy::new(); static LEGACY_UNIFIED_EXEC_PROCESS_LIMIT_WARNING_REGISTRATION: FragmentRegistrationProxy< LegacyUnifiedExecProcessLimitWarning, > = FragmentRegistrationProxy::new(); @@ -52,6 +55,7 @@ static CONTEXTUAL_USER_FRAGMENTS: &[&dyn FragmentRegistration] = &[ &TURN_ABORTED_REGISTRATION, &SUBAGENT_NOTIFICATION_REGISTRATION, &INTERNAL_MODEL_CONTEXT_REGISTRATION, + &RECOMMENDED_PLUGINS_REGISTRATION, &LEGACY_UNIFIED_EXEC_PROCESS_LIMIT_WARNING_REGISTRATION, &LEGACY_APPLY_PATCH_EXEC_COMMAND_WARNING_REGISTRATION, &LEGACY_MODEL_MISMATCH_WARNING_REGISTRATION, diff --git a/codex-rs/core/src/context/contextual_user_message_tests.rs b/codex-rs/core/src/context/contextual_user_message_tests.rs index 133050af21..a7d6e408a5 100644 --- a/codex-rs/core/src/context/contextual_user_message_tests.rs +++ b/codex-rs/core/src/context/contextual_user_message_tests.rs @@ -75,6 +75,14 @@ fn detects_internal_model_context_fragment() { })); } +#[test] +fn detects_recommended_plugins_fragment() { + assert!(is_contextual_user_fragment(&ContentItem::InputText { + text: "\n- Google Drive (google-drive@openai-curated-remote)\n" + .to_string(), + })); +} + #[test] fn detects_legacy_goal_context_fragment() { assert!(is_contextual_user_fragment(&ContentItem::InputText { diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index bdc90ad250..a1f204e475 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -23,6 +23,7 @@ mod plugin_instructions; mod realtime_end_instructions; mod realtime_start_instructions; mod realtime_start_with_instructions; +mod recommended_plugins_instructions; mod subagent_notification; mod token_budget_context; mod turn_aborted; @@ -62,6 +63,7 @@ pub(crate) use plugin_instructions::PluginInstructions; pub(crate) use realtime_end_instructions::RealtimeEndInstructions; pub(crate) use realtime_start_instructions::RealtimeStartInstructions; pub(crate) use realtime_start_with_instructions::RealtimeStartWithInstructions; +pub(crate) use recommended_plugins_instructions::RecommendedPluginsInstructions; pub(crate) use subagent_notification::SubagentNotification; pub(crate) use token_budget_context::TokenBudgetContext; pub(crate) use token_budget_context::TokenBudgetRemainingContext; diff --git a/codex-rs/core/src/context/recommended_plugins_instructions.rs b/codex-rs/core/src/context/recommended_plugins_instructions.rs new file mode 100644 index 0000000000..a7b3ea78fe --- /dev/null +++ b/codex-rs/core/src/context/recommended_plugins_instructions.rs @@ -0,0 +1,49 @@ +use super::ContextualUserFragment; +use codex_tools::DiscoverableTool; + +const RECOMMENDED_PLUGINS_INTRO: &str = "Here is a list of plugins that are available but not installed. If the user's query would benefit from one of these plugins, use the `request_plugin_install` tool to suggest that they install it. All entries have `tool_type: plugin`; pass `plugin` as `tool_type` and the parenthesized ID as `tool_id`. For example, suggest the Google Drive plugin if the query could possibly be better answered with access to Google Drive."; +const MAX_RECOMMENDED_PLUGINS: usize = 50; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct RecommendedPluginsInstructions { + plugins: Vec, +} + +impl RecommendedPluginsInstructions { + pub(crate) fn from_plugins(plugins: &[DiscoverableTool]) -> Option { + if plugins.is_empty() { + return None; + } + Some(Self { + plugins: plugins + .iter() + .take(MAX_RECOMMENDED_PLUGINS) + .cloned() + .collect(), + }) + } +} + +impl ContextualUserFragment for RecommendedPluginsInstructions { + fn role(&self) -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + + fn body(&self) -> String { + let plugins = self + .plugins + .iter() + .map(|plugin| format!("- {} ({})", plugin.name(), plugin.id())) + .collect::>() + .join("\n"); + format!("\n{RECOMMENDED_PLUGINS_INTRO}\n\n{plugins}\n") + } +} diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 242e35f375..d632609c02 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -30,6 +30,7 @@ use crate::context::ContextualUserFragment; use crate::context::NetworkRuleSaved; use crate::context::PermissionsInstructions; use crate::context::PersonalitySpecInstructions; +use crate::context::RecommendedPluginsInstructions; use crate::default_skill_metadata_budget; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::exec_policy::ExecPolicyManager; @@ -325,6 +326,7 @@ use crate::turn_timing::record_turn_ttfm_metric; use crate::unified_exec::UnifiedExecProcessManager; use crate::windows_sandbox::WindowsSandboxLevelExt; use codex_core_plugins::PluginsManager; +use codex_core_plugins::RecommendedPluginCandidatesInput; use codex_git_utils::get_git_repo_root; use codex_mcp::McpConfig; use codex_mcp::compute_auth_statuses; @@ -2975,6 +2977,29 @@ impl Session { .plugins_manager .plugins_for_config(&turn_context.config.plugins_config_input()) .await; + let recommended_plugin_candidates = + if crate::tools::spec_plan::tool_suggest_enabled(turn_context) { + let auth = self.services.auth_manager.auth().await; + let plugins_config = turn_context.config.plugins_config_input(); + self.services + .plugins_manager + .recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput { + plugins_config: &plugins_config, + loaded_plugins: &loaded_plugins, + auth: auth.as_ref(), + disabled_tools: &turn_context.config.tool_suggest.disabled_tools, + app_server_client_name: turn_context.app_server_client_name.as_deref(), + }) + .await + } else { + None + }; + if let Some(recommended_plugins) = recommended_plugin_candidates + .as_deref() + .and_then(RecommendedPluginsInstructions::from_plugins) + { + contextual_user_sections.push(recommended_plugins.render()); + } if let Some(plugin_instructions) = AvailablePluginsInstructions::from_plugins(loaded_plugins.capability_summaries()) { diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 8eb7d5f9e2..4b052cb659 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -73,6 +73,7 @@ use codex_analytics::InvocationType; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_async_utils::OrCancelExt; +use codex_core_plugins::RecommendedPluginCandidatesInput; use codex_core_skills::injection::InjectedHostSkillPrompts; use codex_extension_api::TurnInputContext; use codex_extension_api::TurnInputEnvironment; @@ -1186,49 +1187,78 @@ pub(crate) async fn built_tools( } else { None }; - let auth = sess.services.auth_manager.auth().await; - let loaded_plugin_app_connector_ids = loaded_plugins - .effective_apps() - .into_iter() - .map(|connector_id| connector_id.0) - .collect::>(); - let tool_suggest_candidates = async { - if apps_enabled && tool_suggest_enabled(turn_context) { - if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() { - match connectors::list_tool_suggest_discoverable_tools_with_auth( - &turn_context.config, - sess.services.plugins_manager.as_ref(), - auth.as_ref(), - accessible_connectors.as_slice(), - &loaded_plugin_app_connector_ids, - ) - .await - .map(|discoverable_tools| { - filter_request_plugin_install_discoverable_tools_for_client( - discoverable_tools, - turn_context.app_server_client_name.as_deref(), - ) - }) { - Ok(discoverable_tools) if discoverable_tools.is_empty() => None, - Ok(discoverable_tools) => Some(ToolSuggestCandidates { - tools: discoverable_tools, - presentation: ToolSuggestPresentation::ListTool, - }), - Err(err) => { - warn!("failed to load discoverable tool suggestions: {err:#}"); + let tool_suggest_is_enabled = tool_suggest_enabled(turn_context); + let auth = if tool_suggest_is_enabled { + sess.services.auth_manager.auth().await + } else { + None + }; + let endpoint_recommended_plugin_candidates = if tool_suggest_is_enabled { + let plugins_config = turn_context.config.plugins_config_input(); + sess.services + .plugins_manager + .recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput { + plugins_config: &plugins_config, + loaded_plugins: &loaded_plugins, + auth: auth.as_ref(), + disabled_tools: &turn_context.config.tool_suggest.disabled_tools, + app_server_client_name: turn_context.app_server_client_name.as_deref(), + }) + .await + } else { + None + }; + let tool_suggest_candidates = + if let Some(recommended_plugin_candidates) = endpoint_recommended_plugin_candidates { + Some(ToolSuggestCandidates { + tools: recommended_plugin_candidates, + presentation: ToolSuggestPresentation::RecommendationContext, + }) + } else { + let loaded_plugin_app_connector_ids = loaded_plugins + .effective_apps() + .into_iter() + .map(|connector_id| connector_id.0) + .collect::>(); + async { + if apps_enabled && tool_suggest_is_enabled { + if let Some(accessible_connectors) = + accessible_connectors_with_enabled_state.as_ref() + { + match connectors::list_tool_suggest_discoverable_tools_with_auth( + &turn_context.config, + sess.services.plugins_manager.as_ref(), + auth.as_ref(), + accessible_connectors.as_slice(), + &loaded_plugin_app_connector_ids, + ) + .await + .map(|discoverable_tools| { + filter_request_plugin_install_discoverable_tools_for_client( + discoverable_tools, + turn_context.app_server_client_name.as_deref(), + ) + }) { + Ok(discoverable_tools) if discoverable_tools.is_empty() => None, + Ok(discoverable_tools) => Some(ToolSuggestCandidates { + tools: discoverable_tools, + presentation: ToolSuggestPresentation::ListTool, + }), + Err(err) => { + warn!("failed to load discoverable tool suggestions: {err:#}"); + None + } + } + } else { None } + } else { + None } - } else { - None } - } else { - None - } - } - .instrument(trace_span!("built_tools.load_discoverable_tools")) - .await; - + .instrument(trace_span!("built_tools.load_discoverable_tools")) + .await + }; let mcp_tool_exposure = build_mcp_tool_exposure( &all_mcp_tools, connectors.as_deref(), diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index c4622615d3..0a7f19546c 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -130,8 +130,8 @@ impl RequestPluginInstallHandler { ToolSuggestPresentation::ListTool => format!( "the discoverable tools returned by {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}" ), - ToolSuggestPresentation::DeveloperContext => { - "the developer recommendations".to_string() + ToolSuggestPresentation::RecommendationContext => { + "the list".to_string() } }; FunctionCallError::RespondToModel(format!( diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs b/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs index 1c9bfec01a..fa3bbdab07 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs @@ -39,8 +39,8 @@ pub(crate) fn create_request_plugin_install_tool( ToolSuggestPresentation::ListTool => format!( "# Request plugin/connector install\n\nUse this tool only after `{LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}` returns a plugin or connector that exactly matches the user's explicit request.\n\nDo not use it for adjacent capabilities, broad recommendations, or tools that merely seem useful. Pass the returned `tool_type` through directly, and pass the returned `id` as `tool_id`.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools." ), - ToolSuggestPresentation::DeveloperContext => - "# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the developer `` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string(), + ToolSuggestPresentation::RecommendationContext => + "# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the `` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string(), }; ToolSpec::Function(ResponsesApiTool { @@ -126,15 +126,15 @@ mod tests { } #[test] - fn developer_recommendations_change_only_the_description() { + fn recommendation_context_changes_only_the_description() { let mut expected = create_request_plugin_install_tool(ToolSuggestPresentation::ListTool); let recommendations = - create_request_plugin_install_tool(ToolSuggestPresentation::DeveloperContext); + create_request_plugin_install_tool(ToolSuggestPresentation::RecommendationContext); let ToolSpec::Function(expected_function) = &mut expected else { panic!("expected function tool specs"); }; - expected_function.description = "# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the developer `` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string(); + expected_function.description = "# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the `` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string(); assert_eq!(recommendations, expected); } diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 7cc72395ff..0acd6e37c2 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -48,8 +48,7 @@ pub(crate) struct ToolRouterParams<'a> { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum ToolSuggestPresentation { ListTool, - #[allow(dead_code)] - DeveloperContext, + RecommendationContext, } #[derive(Clone, Debug)] diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index ee7cd7e359..fc17ff5d3d 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -883,7 +883,7 @@ async fn request_plugin_install_requires_all_discovery_features() { None, Some(ToolSuggestCandidates { tools: Vec::new(), - presentation: ToolSuggestPresentation::DeveloperContext, + presentation: ToolSuggestPresentation::RecommendationContext, }), ] { let plan = probe_with( @@ -959,7 +959,7 @@ async fn request_plugin_install_description_refers_to_recommended_plugins_hint() }, ToolPlanInputs { tool_suggest_candidates: Some(plugin_candidates( - ToolSuggestPresentation::DeveloperContext, + ToolSuggestPresentation::RecommendationContext, )), ..ToolPlanInputs::default() }, @@ -973,7 +973,7 @@ async fn request_plugin_install_description_refers_to_recommended_plugins_hint() else { panic!("expected request_plugin_install function spec"); }; - assert!(request_description.contains("developer `` list")); + assert!(request_description.contains("the `` list")); assert!(!request_description.contains("list_available_plugins_to_install")); assert!(!request_description.contains("github")); plan.assert_visible_lacks(&["list_available_plugins_to_install"]); diff --git a/codex-rs/core/tests/suite/request_plugin_install.rs b/codex-rs/core/tests/suite/request_plugin_install.rs index b35ecbc221..9812d39410 100644 --- a/codex-rs/core/tests/suite/request_plugin_install.rs +++ b/codex-rs/core/tests/suite/request_plugin_install.rs @@ -2,24 +2,46 @@ #![allow(clippy::unwrap_used)] use anyhow::Result; +use codex_config::types::ToolSuggestDisabledTool; use codex_config::types::ToolSuggestDiscoverable; use codex_config::types::ToolSuggestDiscoverableType; use codex_core::config::Config; use codex_features::Feature; use codex_login::CodexAuth; use codex_models_manager::bundled_models_response; +use codex_protocol::approvals::ElicitationAction; +use codex_protocol::approvals::ElicitationRequest; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; +use codex_protocol::user_input::UserInput; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; +use core_test_support::test_codex::turn_permission_fields; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; use serde_json::Value; +use serde_json::json; +use wiremock::Mock; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::query_param; const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; const LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME: &str = "list_available_plugins_to_install"; @@ -43,36 +65,20 @@ fn tool_names(body: &Value) -> Vec { .unwrap_or_default() } -fn function_tool_description(body: &Value, name: &str) -> Option { - body.get("tools") - .and_then(Value::as_array) - .and_then(|tools| { - tools.iter().find_map(|tool| { - if tool.get("name").and_then(Value::as_str) == Some(name) { - tool.get("description") - .and_then(Value::as_str) - .map(str::to_string) - } else { - None - } - }) - }) -} - fn configure_apps_without_search_tool(config: &mut Config, apps_base_url: &str) { - config - .features - .enable(Feature::Apps) - .expect("test config should allow feature update"); - config - .features - .enable(Feature::Plugins) - .expect("test config should allow feature update"); - config - .features - .enable(Feature::ToolSuggest) - .expect("test config should allow feature update"); - let mut model_catalog = bundled_models_response().expect("bundled models.json should parse"); + for feature in [ + Feature::Apps, + Feature::Plugins, + Feature::RemotePlugin, + Feature::ToolSuggest, + ] { + config + .features + .enable(feature) + .expect("test config should allow feature update"); + } + let mut model_catalog = bundled_models_response() + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); let model = model_catalog .models .iter_mut() @@ -88,13 +94,326 @@ fn configure_apps_without_search_tool(config: &mut Config, apps_base_url: &str) config.model_catalog = Some(model_catalog); } +async fn mount_recommendations(server: &wiremock::MockServer, response: ResponseTemplate) { + Mock::given(method("GET")) + .and(path("/ps/plugins/suggested")) + .and(query_param("scope", "GLOBAL")) + .respond_with(response) + .mount(server) + .await; +} + +fn assert_legacy_tools(body: &Value) { + let tools = tool_names(body); + assert!(!tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME)); + assert!( + tools + .iter() + .any(|name| name == LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME), + "legacy mode should expose {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}: {tools:?}" + ); + assert!( + tools + .iter() + .any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME), + "legacy mode should expose {REQUEST_PLUGIN_INSTALL_TOOL_NAME}: {tools:?}" + ); +} + +async fn build_test( + server: &wiremock::MockServer, + apps_server: &AppsTestServer, +) -> Result { + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config({ + let apps_base_url = apps_server.chatgpt_base_url.clone(); + move |config| configure_apps_without_search_tool(config, apps_base_url.as_str()) + }); + builder.build(server).await +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn request_plugin_install_is_available_without_search_tool_after_discovery_attempts() --> Result<()> { +async fn explicit_false_preserves_legacy_workflow() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let apps_server = AppsTestServer::mount(&server).await?; + mount_recommendations( + &server, + ResponseTemplate::new(200).set_body_json(json!({"enabled": false, "plugins": []})), + ) + .await; + let call_id = "list-installable-tools"; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME, "{}"), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + let test = build_test(&server, &apps_server).await?; + test.submit_turn_with_approval_and_permission_profile( + "list tools", + AskForApproval::Never, + PermissionProfile::Disabled, + ) + .await?; + + let requests = mock.requests(); + assert_eq!(requests.len(), 2); + let request = &requests[0]; + assert!( + !request + .message_input_texts("user") + .join("\n") + .contains("") + ); + assert_legacy_tools(&request.body_json()); + let output = requests[1] + .function_call_output_text(call_id) + .expect("list tool output"); + let output: Value = serde_json::from_str(&output)?; + assert!(output["tools"].as_array().is_some_and(|tools| { + tools + .iter() + .any(|tool| tool["id"] == DISCOVERABLE_GMAIL_ID && tool["tool_type"] == "connector") + })); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn endpoint_mode_injects_candidates_hides_list_and_rejects_invented_ids() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + mount_recommendations( + &server, + ResponseTemplate::new(200).set_body_json(json!({ + "enabled": true, + "plugins": [ + { + "id": "plugin_google_calendar", + "name": "google-calendar", + "status": "ENABLED", + "installation_policy": "AVAILABLE", + "release": {"display_name": "Google Calendar"} + }, + { + "id": "plugin_github", + "name": "github", + "status": "ENABLED", + "installation_policy": "AVAILABLE", + "release": {"display_name": "GitHub"} + } + ] + })), + ) + .await; + let call_id = "invented-plugin"; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + REQUEST_PLUGIN_INSTALL_TOOL_NAME, + &serde_json::to_string(&json!({ + "tool_type": "plugin", + "action_type": "install", + "tool_id": "invented@openai-curated-remote", + "suggest_reason": "Try this" + }))?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + let test = build_test(&server, &apps_server).await?; + + test.submit_turn("suggest a plugin").await?; + + let requests = mock.requests(); + assert_eq!(requests.len(), 2); + let contextual_user_message = requests[0].message_input_texts("user").join("\n"); + assert!(contextual_user_message.contains("")); + assert!(contextual_user_message.contains("github@openai-curated-remote")); + assert!(contextual_user_message.contains("google-calendar@openai-curated-remote")); + let body = requests[0].body_json(); + let tools = tool_names(&body); + assert!( + !tools + .iter() + .any(|name| name == LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME) + ); + assert!( + tools + .iter() + .any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME) + ); + let output = requests[1] + .function_call_output_text(call_id) + .expect("request tool output"); + assert!(output.contains(" list")); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn endpoint_recommendation_adds_install_identity_only_to_elicitation_metadata() -> Result<()> +{ + skip_if_no_network!(Ok(())); + + const REMOTE_PLUGIN_ID: &str = "plugin_connector_github"; + const APP_CONNECTOR_ID: &str = "connector_github"; + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + mount_recommendations( + &server, + ResponseTemplate::new(200).set_body_json(json!({ + "enabled": true, + "plugins": [{ + "id": REMOTE_PLUGIN_ID, + "name": "github", + "status": "ENABLED", + "installation_policy": "AVAILABLE", + "release": { + "display_name": "GitHub", + "app_ids": [APP_CONNECTOR_ID] + } + }] + })), + ) + .await; + let call_id = "install-github"; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + REQUEST_PLUGIN_INSTALL_TOOL_NAME, + &serde_json::to_string(&json!({ + "tool_type": "plugin", + "action_type": "install", + "tool_id": "github@openai-curated-remote", + "suggest_reason": "Use GitHub for this request" + }))?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + let test = build_test(&server, &apps_server).await?; + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "use GitHub".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: ThreadSettingsOverrides { + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: test.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, + }) + .await?; + + let elicitation = wait_for_event_match(&test.codex, |event| match event { + EventMsg::ElicitationRequest(request) => Some(request.clone()), + _ => None, + }) + .await; + let ElicitationRequest::Form { + meta: Some(meta), .. + } = &elicitation.request + else { + panic!("expected form elicitation metadata"); + }; + assert_eq!(meta["remote_plugin_id"], REMOTE_PLUGIN_ID); + assert_eq!(meta["app_connector_ids"], json!([APP_CONNECTOR_ID])); + + test.codex + .submit(Op::ResolveElicitation { + server_name: elicitation.server_name, + request_id: elicitation.id, + decision: ElicitationAction::Decline, + content: None, + meta: None, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = mock.requests(); + assert_eq!(requests.len(), 2); + for request in requests { + let body = request.body_json().to_string(); + assert!(!body.contains(REMOTE_PLUGIN_ID)); + assert!(!body.contains(APP_CONNECTOR_ID)); + } + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn endpoint_mode_with_no_eligible_candidates_exposes_no_suggestion_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + mount_recommendations( + &server, + ResponseTemplate::new(200).set_body_json(json!({ + "enabled": true, + "plugins": [{ + "id": "plugin_google_calendar", + "name": "google-calendar", + "release": {"display_name": "Google Calendar"} + }] + })), + ) + .await; let mock = mount_sse_once( &server, sse(vec![ @@ -104,61 +423,38 @@ async fn request_plugin_install_is_available_without_search_tool_after_discovery ]), ) .await; - let mut builder = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) - .with_config(move |config| { - configure_apps_without_search_tool(config, apps_server.chatgpt_base_url.as_str()) + .with_config({ + let apps_base_url = apps_server.chatgpt_base_url.clone(); + move |config| { + configure_apps_without_search_tool(config, apps_base_url.as_str()); + config.tool_suggest.disabled_tools = vec![ToolSuggestDisabledTool::plugin( + "google-calendar@openai-curated-remote", + )]; + } }); let test = builder.build(&server).await?; - test.submit_turn_with_approval_and_permission_profile( - "list tools", - AskForApproval::Never, - PermissionProfile::Disabled, - ) - .await?; + test.submit_turn("list tools").await?; - let body = mock.single_request().body_json(); - let tools = tool_names(&body); + let request = mock.single_request(); assert!( - !tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME), - "tools list should not include {TOOL_SEARCH_TOOL_NAME}: {tools:?}" + !request + .message_input_texts("user") + .join("\n") + .contains("") ); + let tools = tool_names(&request.body_json()); assert!( - tools + !tools .iter() - .any(|name| name == LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME), - "tools list should include {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}: {tools:?}" + .any(|name| name == LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME) ); assert!( - tools + !tools .iter() - .any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME), - "tools list should include {REQUEST_PLUGIN_INSTALL_TOOL_NAME}: {tools:?}" + .any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME) ); - - let list_description = - function_tool_description(&body, LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME) - .expect("description"); - assert!(list_description.contains( - "The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list." - )); - assert!(list_description.contains( - "`tool_search` is not available, or it has already been called and did not find or make the requested tool callable." - )); - assert!(list_description.contains( - "When both a plugin and a connector match, prefer the plugin; use the connector only when its corresponding plugin is already installed." - )); - - let description = - function_tool_description(&body, REQUEST_PLUGIN_INSTALL_TOOL_NAME).expect("description"); - assert!(description.contains( - "Use this tool only after `list_available_plugins_to_install` returns a plugin or connector that exactly matches the user's explicit request." - )); - assert!(description.contains("IMPORTANT: DO NOT call this tool in parallel with other tools.")); - assert!(!description.contains(DISCOVERABLE_GMAIL_ID)); - assert!(!description.contains("tool_search fails to find a good match")); - Ok(()) } From 4c79527e31a0e2f77f727ae67e8e86a1233f2876 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 16 Jun 2026 16:17:47 -0700 Subject: [PATCH 11/70] core: render remote environment cwd natively (#28152) ## Why Model-visible `` should match the environment of the executor, not of the app server. Stacked on #28146. ## What - Keep selected environment cwd values as `PathUri` while building environment context. - Render cwd text using the path convention represented by the URI, with the canonical URI as a fallback. - Preserve compatibility with legacy `TurnContextItem.cwd` values when reconstructing and diffing context. - Extend the Wine-backed remote Windows test to assert that the model sees `powershell` and `C:\windows`. --- codex-rs/core/src/codex_thread.rs | 2 +- .../core/src/context/environment_context.rs | 72 +++++++--------- .../src/context/environment_context_tests.rs | 57 +++++++++---- .../core/src/context_manager/history_tests.rs | 9 +- codex-rs/core/src/context_manager/updates.rs | 42 +++++----- codex-rs/core/src/prompt_debug.rs | 2 +- codex-rs/core/src/session/mod.rs | 23 ++--- .../session/rollout_reconstruction_tests.rs | 17 ++-- codex-rs/core/src/session/tests.rs | 62 ++++++++++---- codex-rs/core/src/session/turn.rs | 12 ++- codex-rs/core/src/session/turn_context.rs | 11 ++- .../remote_env_windows_test.rs | 46 ++++++++++ codex-rs/core/tests/suite/resume_warning.rs | 3 +- codex-rs/exec/src/lib.rs | 6 +- codex-rs/protocol/src/protocol.rs | 71 ++++++++++++---- codex-rs/rollout/src/metadata.rs | 2 +- codex-rs/rollout/src/metadata_tests.rs | 3 +- codex-rs/rollout/src/recorder.rs | 5 +- codex-rs/rollout/src/recorder_tests.rs | 3 +- codex-rs/state/src/extract.rs | 84 ++++++++++++++----- codex-rs/state/src/runtime/threads.rs | 2 +- codex-rs/thread-store/src/live_thread.rs | 4 +- .../thread-store/src/thread_metadata_sync.rs | 69 ++++++++++----- codex-rs/utils/path-uri/src/lib.rs | 13 +++ codex-rs/utils/path-uri/src/tests.rs | 22 +++++ 25 files changed, 457 insertions(+), 185 deletions(-) diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 451b497827..e19bad0eb8 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -458,7 +458,7 @@ impl CodexThread { self.codex .session .record_context_updates_and_set_reference_context_item(turn_context.as_ref()) - .await; + .await?; } self.codex .session diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index f0cc9f80bc..fe5f9b1d0d 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -10,6 +10,7 @@ use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use std::collections::HashSet; use std::path::PathBuf; @@ -28,12 +29,12 @@ pub(crate) struct EnvironmentContext { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct EnvironmentContextEnvironment { pub(crate) id: String, - pub(crate) cwd: AbsolutePathBuf, + pub(crate) cwd: PathUri, pub(crate) shell: String, } impl EnvironmentContextEnvironment { - fn legacy(cwd: AbsolutePathBuf, shell: String) -> Self { + fn legacy(cwd: PathUri, shell: String) -> Self { Self { id: String::new(), cwd, @@ -44,18 +45,14 @@ impl EnvironmentContextEnvironment { fn from_turn_environments(environments: &[TurnEnvironment], shell: &Shell) -> Vec { environments .iter() - .filter_map(|environment| { - // TODO(anp): Migrate EnvironmentContextEnvironment to PathUri so foreign - // environments remain visible in model context. - Some(Self { - id: environment.environment_id.clone(), - cwd: environment.cwd().to_abs_path().ok()?, - shell: environment - .shell - .as_ref() - .map(|shell| shell.name().to_string()) - .unwrap_or_else(|| shell.name().to_string()), - }) + .map(|environment| Self { + id: environment.environment_id.clone(), + cwd: environment.cwd().clone(), + shell: environment + .shell + .as_ref() + .map(|shell| shell.name().to_string()) + .unwrap_or_else(|| shell.name().to_string()), }) .collect() } @@ -383,12 +380,12 @@ impl EnvironmentContext { pub(crate) fn diff_from_turn_context_item( before: &TurnContextItem, after: &EnvironmentContext, - ) -> Self { + ) -> std::io::Result { let before_network = Self::network_from_turn_context_item(before); - let before_filesystem = Self::filesystem_from_turn_context_item(before); + let before_filesystem = Self::filesystem_from_turn_context_item(before)?; let environments = match &after.environments { EnvironmentContextEnvironments::Single(environment) => { - if before.cwd.as_path() != environment.cwd.as_path() { + if before.cwd != environment.cwd { EnvironmentContextEnvironments::Single(EnvironmentContextEnvironment::legacy( environment.cwd.clone(), environment.shell.clone(), @@ -412,14 +409,14 @@ impl EnvironmentContext { } else { before_filesystem }; - EnvironmentContext::new_with_environments( + Ok(EnvironmentContext::new_with_environments( environments, after.current_date.clone(), after.timezone.clone(), network, filesystem, /*subagents*/ None, - ) + )) } pub(crate) fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { @@ -443,21 +440,18 @@ impl EnvironmentContext { pub(crate) fn from_turn_context_item( turn_context_item: &TurnContextItem, shell: String, - ) -> Self { - let cwd = match AbsolutePathBuf::try_from(turn_context_item.cwd.clone()) { - Ok(cwd) => cwd, - Err(_) => AbsolutePathBuf::resolve_path_against_base(&turn_context_item.cwd, "/"), - }; - Self::new_with_environments( + ) -> std::io::Result { + Ok(Self::new_with_environments( EnvironmentContextEnvironments::from_vec(vec![EnvironmentContextEnvironment::legacy( - cwd, shell, + turn_context_item.cwd.clone(), + shell, )]), turn_context_item.current_date.clone(), turn_context_item.timezone.clone(), Self::network_from_turn_context_item(turn_context_item), - Self::filesystem_from_turn_context_item(turn_context_item), + Self::filesystem_from_turn_context_item(turn_context_item)?, /*subagents*/ None, - ) + )) } pub(crate) fn with_subagents(mut self, subagents: String) -> Self { @@ -504,11 +498,11 @@ impl EnvironmentContext { fn filesystem_from_turn_context_item( turn_context_item: &TurnContextItem, - ) -> Option { - Some(FileSystemContext::from_permission_profile( - &turn_context_item.permission_profile(), + ) -> std::io::Result> { + Ok(Some(FileSystemContext::from_permission_profile( + &turn_context_item.permission_profile()?, &workspace_roots_from_turn_context_item(turn_context_item), - )) + ))) } } @@ -521,7 +515,7 @@ fn workspace_roots_from_turn_context_item( // Older rollout items did not persist workspace roots. Fall back to the // legacy cwd binding only when reconstructing that historical context. - match AbsolutePathBuf::try_from(turn_context_item.cwd.clone()) { + match turn_context_item.cwd.to_abs_path() { Ok(cwd) => vec![cwd], Err(_) => Vec::new(), } @@ -547,20 +541,16 @@ impl ContextualUserFragment for EnvironmentContext { let mut lines = Vec::new(); match &self.environments { EnvironmentContextEnvironments::Single(environment) => { - lines.push(format!( - " {}", - environment.cwd.to_string_lossy() - )); + let cwd = environment.cwd.inferred_native_path_string(); + lines.push(format!(" {cwd}")); lines.push(format!(" {}", environment.shell)); } EnvironmentContextEnvironments::Multiple(environments) => { lines.push(" ".to_string()); for environment in environments { lines.push(format!(" ", environment.id)); - lines.push(format!( - " {}", - environment.cwd.to_string_lossy() - )); + let cwd = environment.cwd.inferred_native_path_string(); + lines.push(format!(" {cwd}")); lines.push(format!(" {}", environment.shell)); lines.push(" ".to_string()); } diff --git a/codex-rs/core/src/context/environment_context_tests.rs b/codex-rs/core/src/context/environment_context_tests.rs index 5f391ca387..383a38b55c 100644 --- a/codex-rs/core/src/context/environment_context_tests.rs +++ b/codex-rs/core/src/context/environment_context_tests.rs @@ -36,7 +36,7 @@ fn serialize_workspace_write_environment_context() { let context = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: cwd.abs(), + cwd: PathUri::from_abs_path(&cwd.abs()), shell: fake_shell_name(), }], Some("2026-02-26".to_string()), @@ -58,6 +58,29 @@ fn serialize_workspace_write_environment_context() { assert_eq!(context.render(), expected); } +#[test] +fn serialize_environment_context_with_foreign_windows_cwd() { + let context = EnvironmentContext::new( + vec![EnvironmentContextEnvironment { + id: "remote".to_string(), + cwd: PathUri::parse("file:///C:/windows").expect("Windows cwd URI"), + shell: "powershell".to_string(), + }], + /*current_date*/ None, + /*timezone*/ None, + /*network*/ None, + /*subagents*/ None, + ); + + assert_eq!( + context.render(), + r#" + C:\windows + powershell +"# + ); +} + #[test] fn serialize_environment_context_with_network() { let network = NetworkContext::new( @@ -67,7 +90,7 @@ fn serialize_environment_context_with_network() { let context = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_path_buf("/repo").abs(), + cwd: PathUri::from_abs_path(&test_abs_path("/repo")), shell: fake_shell_name(), }], Some("2026-02-26".to_string()), @@ -129,7 +152,7 @@ fn serialize_environment_context_with_full_filesystem_profile() { let mut context = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_path_buf("/repo").abs(), + cwd: PathUri::from_abs_path(&test_abs_path("/repo")), shell: fake_shell_name(), }], /*current_date*/ None, @@ -167,7 +190,7 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() { let repo_private = repo.join("private"); let item = TurnContextItem { turn_id: None, - cwd: test_path_buf("/not-the-workspace"), + cwd: PathUri::from_abs_path(&test_abs_path("/not-the-workspace")), workspace_roots: Some(vec![repo.clone(), other_repo.clone()]), current_date: None, timezone: None, @@ -186,7 +209,9 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() { summary: codex_protocol::config_types::ReasoningSummary::Auto, }; - let context = EnvironmentContext::from_turn_context_item(&item, fake_shell_name()).render(); + let context = EnvironmentContext::from_turn_context_item(&item, fake_shell_name()) + .expect("turn context should hydrate") + .render(); assert!( context.contains(&format!( @@ -234,7 +259,7 @@ fn equals_except_shell_compares_cwd() { let context1 = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_abs_path("/repo"), + cwd: PathUri::from_abs_path(&test_abs_path("/repo")), shell: fake_shell_name(), }], /*current_date*/ None, @@ -245,7 +270,7 @@ fn equals_except_shell_compares_cwd() { let context2 = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_abs_path("/repo"), + cwd: PathUri::from_abs_path(&test_abs_path("/repo")), shell: fake_shell_name(), }], /*current_date*/ None, @@ -261,7 +286,7 @@ fn equals_except_shell_compares_cwd_differences() { let context1 = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_abs_path("/repo1"), + cwd: PathUri::from_abs_path(&test_abs_path("/repo1")), shell: fake_shell_name(), }], /*current_date*/ None, @@ -272,7 +297,7 @@ fn equals_except_shell_compares_cwd_differences() { let context2 = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_abs_path("/repo2"), + cwd: PathUri::from_abs_path(&test_abs_path("/repo2")), shell: fake_shell_name(), }], /*current_date*/ None, @@ -289,7 +314,7 @@ fn equals_except_shell_ignores_shell() { let context1 = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_abs_path("/repo"), + cwd: PathUri::from_abs_path(&test_abs_path("/repo")), shell: "bash".to_string(), }], /*current_date*/ None, @@ -300,7 +325,7 @@ fn equals_except_shell_ignores_shell() { let context2 = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "other".to_string(), - cwd: test_abs_path("/repo"), + cwd: PathUri::from_abs_path(&test_abs_path("/repo")), shell: "zsh".to_string(), }], /*current_date*/ None, @@ -317,7 +342,7 @@ fn serialize_environment_context_with_subagents() { let context = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_path_buf("/repo").abs(), + cwd: PathUri::from_abs_path(&test_abs_path("/repo")), shell: fake_shell_name(), }], Some("2026-02-26".to_string()), @@ -351,12 +376,12 @@ fn serialize_environment_context_with_multiple_selected_environments() { vec![ EnvironmentContextEnvironment { id: "local".to_string(), - cwd: local_cwd.abs(), + cwd: PathUri::from_abs_path(&local_cwd.abs()), shell: "bash".to_string(), }, EnvironmentContextEnvironment { id: "remote".to_string(), - cwd: remote_cwd.abs(), + cwd: PathUri::from_abs_path(&remote_cwd.abs()), shell: "bash".to_string(), }, ], @@ -396,12 +421,12 @@ fn serialize_environment_context_prefers_environment_shell_when_present() { vec![ EnvironmentContextEnvironment { id: "local".to_string(), - cwd: local_cwd.abs(), + cwd: PathUri::from_abs_path(&local_cwd.abs()), shell: "powershell".to_string(), }, EnvironmentContextEnvironment { id: "remote".to_string(), - cwd: remote_cwd.abs(), + cwd: PathUri::from_abs_path(&remote_cwd.abs()), shell: "cmd".to_string(), }, ], diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index a98061b5b0..0b538be110 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -23,13 +23,13 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnContextItem; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::truncate_text; +use codex_utils_path_uri::PathUri; use image::ImageBuffer; use image::ImageFormat; use image::Luma; use image::Rgba; use pretty_assertions::assert_eq; use regex_lite::Regex; -use std::path::PathBuf; const EXEC_FORMAT_MAX_BYTES: usize = 10_000; const EXEC_FORMAT_MAX_TOKENS: usize = 2_500; @@ -127,7 +127,12 @@ fn developer_msg_with_fragments(texts: &[&str]) -> ResponseItem { fn reference_context_item() -> TurnContextItem { TurnContextItem { turn_id: Some("reference-turn".to_string()), - cwd: PathBuf::from("/tmp/reference-cwd"), + cwd: PathUri::from_path( + std::env::current_dir() + .expect("current directory") + .join("reference-cwd"), + ) + .expect("absolute reference cwd"), workspace_roots: None, current_date: Some("2026-03-23".to_string()), timezone: Some("America/Los_Angeles".to_string()), diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 0ce02240b0..d9c0b2f03e 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -22,40 +22,44 @@ fn build_environment_update_item( previous: Option<&TurnContextItem>, next: &TurnContext, shell: &Shell, -) -> Option { +) -> std::io::Result> { if !next.config.include_environment_context { - return None; + return Ok(None); } - let prev = previous?; - let prev_context = EnvironmentContext::from_turn_context_item(prev, shell.name().to_string()); + let Some(prev) = previous else { + return Ok(None); + }; + let prev_context = EnvironmentContext::from_turn_context_item(prev, shell.name().to_string())?; let next_context = EnvironmentContext::from_turn_context(next, shell); if prev_context.equals_except_shell(&next_context) { - return None; + return Ok(None); } - Some(ContextualUserFragment::into( - EnvironmentContext::diff_from_turn_context_item(prev, &next_context), - )) + Ok(Some(ContextualUserFragment::into( + EnvironmentContext::diff_from_turn_context_item(prev, &next_context)?, + ))) } fn build_permissions_update_item( previous: Option<&TurnContextItem>, next: &TurnContext, exec_policy: &Policy, -) -> Option { +) -> std::io::Result> { if !next.config.include_permissions_instructions { - return None; + return Ok(None); } - let prev = previous?; - if prev.permission_profile() == next.permission_profile() + let Some(prev) = previous else { + return Ok(None); + }; + if prev.permission_profile()? == next.permission_profile() && prev.approval_policy == next.approval_policy.value() { - return None; + return Ok(None); } - Some( + Ok(Some( PermissionsInstructions::from_permission_profile( &next.permission_profile, next.approval_policy.value(), @@ -67,7 +71,7 @@ fn build_permissions_update_item( next.features.enabled(Feature::RequestPermissionsTool), ) .render(), - ) + )) } fn build_collaboration_mode_update_item( @@ -214,17 +218,17 @@ pub(crate) fn build_settings_update_items( shell: &Shell, exec_policy: &Policy, personality_feature_enabled: bool, -) -> Vec { +) -> std::io::Result> { // TODO(ccunningham): build_settings_update_items still does not cover every // model-visible item emitted by build_initial_context. Persist the remaining // inputs or add explicit replay events so fork/resume can diff everything // deterministically. - let contextual_user_message = build_environment_update_item(previous, next, shell); + let contextual_user_message = build_environment_update_item(previous, next, shell)?; let developer_update_sections = [ // Keep model-switch instructions first so model-specific guidance is read before // any other context diffs on this turn. build_model_instructions_update_item(previous_turn_settings, next), - build_permissions_update_item(previous, next, exec_policy), + build_permissions_update_item(previous, next, exec_policy)?, build_collaboration_mode_update_item(previous, next), build_realtime_update_item(previous, previous_turn_settings, next), build_personality_update_item(previous, next, personality_feature_enabled), @@ -240,5 +244,5 @@ pub(crate) fn build_settings_update_items( if let Some(contextual_user_message) = contextual_user_message { items.push(contextual_user_message); } - items + Ok(items) } diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 0da42d8a4e..f3da9b93fe 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -77,7 +77,7 @@ pub(crate) async fn build_prompt_input_from_session( ) -> CodexResult> { let turn_context = sess.new_default_turn().await; sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref()) - .await; + .await?; if !input.is_empty() { let response_item = sess.response_item_from_user_input(turn_context.as_ref(), input); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index d632609c02..5a2e33bbfe 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1621,7 +1621,7 @@ impl Session { &self, reference_context_item: Option<&TurnContextItem>, current_context: &TurnContext, - ) -> Vec { + ) -> CodexResult> { // TODO: Make context updates a pure diff of persisted previous/current TurnContextItem // state so replay/backtracking is deterministic. Runtime inputs that affect model-visible // context (shell, exec policy, feature gates, previous-turn bridge) should be persisted @@ -1632,13 +1632,15 @@ impl Session { }; let shell = self.user_shell(); let exec_policy = self.services.exec_policy.current(); - crate::context_manager::updates::build_settings_update_items( - reference_context_item, - previous_turn_settings.as_ref(), - current_context, - shell.as_ref(), - exec_policy.as_ref(), - self.features.enabled(Feature::Personality), + Ok( + crate::context_manager::updates::build_settings_update_items( + reference_context_item, + previous_turn_settings.as_ref(), + current_context, + shell.as_ref(), + exec_policy.as_ref(), + self.features.enabled(Feature::Personality), + )?, ) } @@ -3186,7 +3188,7 @@ impl Session { pub(crate) async fn record_context_updates_and_set_reference_context_item( &self, turn_context: &TurnContext, - ) { + ) -> CodexResult<()> { let reference_context_item = { let state = self.state.lock().await; state.reference_context_item() @@ -3197,7 +3199,7 @@ impl Session { } else { // Steady-state path: append only context diffs to minimize token overhead. self.build_settings_update_items(reference_context_item.as_ref(), turn_context) - .await + .await? }; let turn_context_item = turn_context.to_turn_context_item(); if !context_items.is_empty() { @@ -3213,6 +3215,7 @@ impl Session { // context items. This keeps later runtime diffing aligned with the current turn state. let mut state = self.state.lock().await; state.set_reference_context_item(Some(turn_context_item)); + Ok(()) } pub(crate) async fn update_token_usage_info( diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index d53e3866c9..67632aa536 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -9,6 +9,7 @@ use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::ResumedHistory; +use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -88,7 +89,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: turn_context.cwd.to_path_buf(), + cwd: PathUri::from_abs_path(&turn_context.cwd), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -128,7 +129,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif let mut previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: turn_context.cwd.to_path_buf(), + cwd: PathUri::from_abs_path(&turn_context.cwd), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -989,7 +990,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: turn_context.cwd.to_path_buf(), + cwd: PathUri::from_abs_path(&turn_context.cwd), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1071,7 +1072,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis serde_json::to_value(Some(TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: turn_context.cwd.to_path_buf(), + cwd: PathUri::from_abs_path(&turn_context.cwd), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1101,7 +1102,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: turn_context.cwd.to_path_buf(), + cwd: PathUri::from_abs_path(&turn_context.cwd), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1223,7 +1224,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo let current_context_item = TurnContextItem { turn_id: Some(current_turn_id.clone()), #[allow(deprecated)] - cwd: turn_context.cwd.to_path_buf(), + cwd: PathUri::from_abs_path(&turn_context.cwd), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1343,7 +1344,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: turn_context.cwd.to_path_buf(), + cwd: PathUri::from_abs_path(&turn_context.cwd), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1506,7 +1507,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: turn_context.cwd.to_path_buf(), + cwd: PathUri::from_abs_path(&turn_context.cwd), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 6f0e730ca5..96044ed3ad 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1874,7 +1874,8 @@ async fn resumed_history_injects_initial_context_on_first_context_update_only() session .record_context_updates_and_set_reference_context_item(&turn_context) - .await; + .await + .expect("context updates should hydrate"); let initial_context = session.build_initial_context(&turn_context).await; expected.extend(initial_context); let history_after_seed = session.clone_history().await; @@ -1882,7 +1883,8 @@ async fn resumed_history_injects_initial_context_on_first_context_update_only() session .record_context_updates_and_set_reference_context_item(&turn_context) - .await; + .await + .expect("context updates should hydrate"); let history_after_second_seed = session.clone_history().await; assert_eq!( history_after_seed.raw_items(), @@ -2673,7 +2675,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: turn_context.cwd.to_path_buf(), + cwd: PathUri::from_abs_path(&turn_context.cwd), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -7290,7 +7292,8 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes( let reference_context_item = previous_context.to_turn_context_item(); let update_items = session .build_settings_update_items(Some(&reference_context_item), ¤t_context) - .await; + .await + .expect("settings updates should hydrate"); let environment_update = user_input_texts(&update_items) .into_iter() @@ -7360,7 +7363,8 @@ async fn build_settings_update_items_emits_environment_item_for_time_changes() { let reference_context_item = previous_context.to_turn_context_item(); let update_items = session .build_settings_update_items(Some(&reference_context_item), ¤t_context) - .await; + .await + .expect("settings updates should hydrate"); let environment_update = user_input_texts(&update_items) .into_iter() @@ -7388,7 +7392,8 @@ async fn build_settings_update_items_omits_environment_item_when_disabled() { let reference_context_item = previous_context.to_turn_context_item(); let update_items = session .build_settings_update_items(Some(&reference_context_item), ¤t_context) - .await; + .await + .expect("settings updates should hydrate"); let user_texts = user_input_texts(&update_items); assert!( @@ -7416,7 +7421,8 @@ async fn build_settings_update_items_emits_realtime_start_when_session_becomes_l Some(&previous_context.to_turn_context_item()), ¤t_context, ) - .await; + .await + .expect("settings updates should hydrate"); let developer_texts = developer_input_texts(&update_items); assert!( @@ -7444,7 +7450,8 @@ async fn build_settings_update_items_emits_realtime_end_when_session_stops_being Some(&previous_context.to_turn_context_item()), ¤t_context, ) - .await; + .await + .expect("settings updates should hydrate"); let developer_texts = developer_input_texts(&update_items); assert!( @@ -7478,7 +7485,8 @@ async fn build_settings_update_items_uses_previous_turn_settings_for_realtime_en .await; let update_items = session .build_settings_update_items(Some(&previous_context_item), ¤t_context) - .await; + .await + .expect("settings updates should hydrate"); let developer_texts = developer_input_texts(&update_items); assert!( @@ -8130,6 +8138,21 @@ async fn turn_context_item_uses_turn_context_comp_hash_snapshot() { ); } +#[tokio::test] +async fn turn_context_item_stores_primary_environment_cwd_uri() { + let (_session, mut turn_context) = make_session_and_context().await; + let environment = turn_context.environments.turn_environments[0].clone(); + let cwd = PathUri::parse("file:///C:/windows").expect("Windows cwd URI"); + turn_context.environments.turn_environments[0] = TurnEnvironment::new( + "remote".to_string(), + environment.environment, + cwd.clone(), + environment.shell, + ); + + assert_eq!(turn_context.to_turn_context_item().cwd, cwd); +} + #[tokio::test] async fn turn_context_item_omits_legacy_equivalent_file_system_sandbox_policy() { let (_session, turn_context) = make_session_and_context().await; @@ -8171,7 +8194,8 @@ async fn record_context_updates_and_set_reference_context_item_injects_full_cont let (session, turn_context) = make_session_and_context().await; session .record_context_updates_and_set_reference_context_item(&turn_context) - .await; + .await + .expect("context updates should hydrate"); let history = session.clone_history().await; let initial_context = session.build_initial_context(&turn_context).await; assert_eq!(history.raw_items().to_vec(), initial_context); @@ -8202,7 +8226,8 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co .await; session .record_context_updates_and_set_reference_context_item(&turn_context) - .await; + .await + .expect("context updates should hydrate"); { let mut state = session.state.lock().await; state.set_reference_context_item(/*item*/ None); @@ -8216,7 +8241,8 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co session .record_context_updates_and_set_reference_context_item(&turn_context) - .await; + .await + .expect("context updates should hydrate"); let history = session.clone_history().await; let mut expected_history = vec![compacted_summary]; @@ -8246,12 +8272,14 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline let update_items = session .build_settings_update_items(Some(&previous_context_item), &turn_context) - .await; + .await + .expect("settings updates should hydrate"); assert_eq!(update_items, Vec::new()); session .record_context_updates_and_set_reference_context_item(&turn_context) - .await; + .await + .expect("context updates should hydrate"); assert_eq!( session.clone_history().await.raw_items().to_vec(), @@ -8298,7 +8326,8 @@ async fn record_context_updates_and_set_reference_context_item_persists_split_fi session .record_context_updates_and_set_reference_context_item(&turn_context) - .await; + .await + .expect("context updates should hydrate"); session.ensure_rollout_materialized().await; session.flush_rollout().await.expect("rollout should flush"); @@ -8382,7 +8411,8 @@ async fn record_context_updates_and_set_reference_context_item_persists_full_rei .await; session .record_context_updates_and_set_reference_context_item(&turn_context) - .await; + .await + .expect("context updates should hydrate"); session.ensure_rollout_materialized().await; session.flush_rollout().await.expect("rollout should flush"); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 4b052cb659..d70a9cc8de 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -159,8 +159,16 @@ pub(crate) async fn run_turn( return None; } - sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref()) - .await; + if let Err(err) = sess + .record_context_updates_and_set_reference_context_item(turn_context.as_ref()) + .await + { + let error = err.to_codex_protocol_error(); + sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) + .await; + error!(%err, "failed to hydrate persisted turn context"); + return None; + } let (injection_items, explicitly_enabled_connectors) = build_skills_and_plugins(&sess, turn_context.as_ref(), &input, &cancellation_token).await?; diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index aed5fae681..25ad962fd4 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -396,10 +396,17 @@ impl TurnContext { pub(crate) fn to_turn_context_item(&self) -> TurnContextItem { let workspace_roots = self.config.effective_workspace_roots(); + let cwd = self + .environments + .primary() + .map(|environment| environment.cwd().clone()) + .unwrap_or_else(|| { + #[allow(deprecated)] + PathUri::from_abs_path(&self.cwd) + }); TurnContextItem { turn_id: Some(self.sub_id.clone()), - #[allow(deprecated)] - cwd: self.cwd.to_path_buf(), + cwd, workspace_roots: (!workspace_roots.is_empty()).then_some(workspace_roots), current_date: self.current_date.clone(), timezone: self.timezone.clone(), diff --git a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs index 208dfa554d..c092c7dcc4 100644 --- a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs +++ b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs @@ -38,6 +38,7 @@ use core_test_support::wait_for_event; use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; +use serde_json::Value; use serde_json::json; use std::collections::BTreeMap; use tempfile::TempDir; @@ -246,6 +247,51 @@ async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Resul ) .await??; + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + let first_request = requests + .iter() + .find(|request| request.url.path().ends_with("/responses")) + .context("turn should send a Responses request")?; + let body = first_request.body_json::()?; + let environment_context = body["input"] + .as_array() + .into_iter() + .flatten() + .filter(|item| item.get("role").and_then(Value::as_str) == Some("user")) + .filter_map(|item| item.get("content").and_then(Value::as_array)) + .flatten() + .filter_map(|content| content.get("text").and_then(Value::as_str)) + .find(|text| text.starts_with("")) + .context("environment context should be model visible")?; + // The model should see the remote environment's shell, not the Linux app-server's + // host shell. + assert_eq!( + environment_context + .lines() + .find(|line| line.trim_start().starts_with("")) + .map(str::trim), + Some("powershell"), + ); + // The model should see cwd using the remote environment's native path convention, not + // the Linux app-server's host path convention. + assert_eq!( + environment_context + .lines() + .find(|line| line.trim_start().starts_with("")) + .map(str::trim), + Some(r"C:\windows"), + ); + let host_workspace_roots = format!( + "{}", + codex_home.path().display() + ); + // TODO(anp): Derive model-visible workspace roots from the selected remote environment + // and render them using its native path convention. + assert!(environment_context.contains(&host_workspace_roots)); + Ok(()) }) .await diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0ba3922984..001c112995 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -14,6 +14,7 @@ use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use codex_protocol::protocol::WarningEvent; +use codex_utils_path_uri::PathUri; use core::time::Duration; use core_test_support::load_default_config_for_test; use core_test_support::wait_for_event; @@ -27,7 +28,7 @@ fn resume_history( let turn_id = "resume-warning-seed-turn".to_string(); let turn_ctx = TurnContextItem { turn_id: Some(turn_id.clone()), - cwd: config.cwd.to_path_buf(), + cwd: PathUri::from_abs_path(&config.cwd), workspace_roots: None, current_date: None, timezone: None, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index f8ac851bf9..c7ab8cfa0a 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1423,7 +1423,11 @@ async fn parse_latest_turn_context_cwd(path: &Path) -> Option { continue; }; if let RolloutItem::TurnContext(item) = rollout_line.item { - return Some(item.cwd); + return item + .cwd + .to_abs_path() + .ok() + .map(AbsolutePathBuf::into_path_buf); } } None diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 88c08bdb68..b3ebbff171 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2988,7 +2988,7 @@ pub struct TurnContextNetworkItem { pub struct TurnContextItem { #[serde(default, skip_serializing_if = "Option::is_none")] pub turn_id: Option, - pub cwd: PathBuf, + pub cwd: PathUri, /// Effective workspace roots used to materialize symbolic /// `:workspace_roots` filesystem permissions in `permission_profile`. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -3026,21 +3026,35 @@ pub struct TurnContextItem { } impl TurnContextItem { - pub fn permission_profile(&self) -> PermissionProfile { - self.permission_profile.clone().unwrap_or_else(|| { - let file_system_sandbox_policy = - self.file_system_sandbox_policy.clone().unwrap_or_else(|| { - FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( - &self.sandbox_policy, - &self.cwd, + pub fn permission_profile(&self) -> std::io::Result { + if let Some(permission_profile) = self.permission_profile.clone() { + return Ok(permission_profile); + } + let file_system_sandbox_policy = match self.file_system_sandbox_policy.clone() { + Some(file_system_sandbox_policy) => file_system_sandbox_policy, + None => { + let cwd = self.cwd.to_abs_path().map_err(|err| { + std::io::Error::new( + err.kind(), + format!( + "cannot hydrate legacy permission profile for cwd {}: {err}", + self.cwd + ), ) - }); + })?; + FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( + &self.sandbox_policy, + cwd.as_path(), + ) + } + }; + Ok( PermissionProfile::from_runtime_permissions_with_enforcement( SandboxEnforcement::from_legacy_sandbox_policy(&self.sandbox_policy), &file_system_sandbox_policy, NetworkSandboxPolicy::from(&self.sandbox_policy), - ) - }) + ), + ) } } @@ -5301,21 +5315,50 @@ mod tests { } #[test] - fn turn_context_item_deserializes_without_network() -> Result<()> { + fn turn_context_item_accepts_legacy_cwd_and_serializes_path_uri() -> Result<()> { + let legacy_cwd = test_path_buf("/tmp"); let item: TurnContextItem = serde_json::from_value(json!({ - "cwd": test_path_buf("/tmp"), + "cwd": legacy_cwd, "approval_policy": "never", "sandbox_policy": { "type": "danger-full-access" }, "model": "gpt-5", "summary": "auto", }))?; + let expected_cwd = PathUri::from_path(legacy_cwd)?; + assert_eq!(item.cwd, expected_cwd); + assert_eq!( + serde_json::to_value(&item)?["cwd"], + json!(expected_cwd.to_string()) + ); assert_eq!(item.network, None); assert_eq!(item.file_system_sandbox_policy, None); assert_eq!(item.comp_hash, None); Ok(()) } + #[test] + fn turn_context_item_rejects_legacy_policy_hydration_for_foreign_cwd() -> Result<()> { + let foreign_cwd = if cfg!(windows) { + "file:///tmp" + } else { + "file://server/share" + }; + let item: TurnContextItem = serde_json::from_value(json!({ + "cwd": foreign_cwd, + "approval_policy": "never", + "sandbox_policy": { "type": "workspace-write" }, + "model": "gpt-5", + "summary": "auto", + }))?; + + let err = item + .permission_profile() + .expect_err("foreign cwd should not hydrate a legacy permission profile"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + Ok(()) + } + #[test] fn multi_agent_version_uses_newest_present_session_meta_value() -> Result<()> { let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; @@ -5353,7 +5396,7 @@ mod tests { fn turn_context_item_serializes_network_when_present() -> Result<()> { let item = TurnContextItem { turn_id: None, - cwd: test_path_buf("/tmp"), + cwd: PathUri::from_abs_path(&test_path_buf("/tmp").abs()), workspace_roots: None, current_date: None, timezone: None, diff --git a/codex-rs/rollout/src/metadata.rs b/codex-rs/rollout/src/metadata.rs index 19e9a9320c..6eabd21ae6 100644 --- a/codex-rs/rollout/src/metadata.rs +++ b/codex-rs/rollout/src/metadata.rs @@ -111,7 +111,7 @@ pub async fn extract_metadata_from_rollout( })?; let mut metadata = builder.build(default_provider); for item in &items { - apply_rollout_item(&mut metadata, item, default_provider); + apply_rollout_item(&mut metadata, item, default_provider)?; } if let Some(updated_at) = file_modified_time_utc(rollout_path).await { metadata.updated_at = updated_at; diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index 03d251e1b3..43aa62e646 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -69,7 +69,8 @@ async fn extract_metadata_from_rollout_uses_session_meta() { let builder = builder_from_session_meta(&session_meta_line, path.as_path()).expect("builder"); let mut expected = builder.build("openai"); - apply_rollout_item(&mut expected, &rollout_line.item, "openai"); + apply_rollout_item(&mut expected, &rollout_line.item, "openai") + .expect("rollout item should apply"); expected.updated_at = file_modified_time_utc(&path).await.expect("mtime"); assert_eq!(outcome.metadata, expected); diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 5dc207049f..a3e5c0514b 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -1765,15 +1765,16 @@ async fn resume_candidate_matches_cwd( if let Ok((items, _, _)) = RolloutRecorder::load_rollout_items(rollout_path).await && let Some(latest_turn_context_cwd) = items.iter().rev().find_map(|item| match item { - RolloutItem::TurnContext(turn_context) => Some(turn_context.cwd.as_path()), + RolloutItem::TurnContext(turn_context) => Some(&turn_context.cwd), RolloutItem::SessionMeta(_) | RolloutItem::ResponseItem(_) | RolloutItem::InterAgentCommunication(_) | RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) => None, }) + && let Ok(latest_turn_context_cwd) = latest_turn_context_cwd.to_abs_path() { - return cwd_matches(latest_turn_context_cwd, cwd); + return cwd_matches(latest_turn_context_cwd.as_path(), cwd); } metadata::extract_metadata_from_rollout(rollout_path, default_provider) diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 68896ad202..c49a477944 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -1134,7 +1134,8 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re timestamp: "2025-01-03T13:00:01Z".to_string(), item: RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), - cwd: latest_cwd.clone(), + cwd: serde_json::from_value(serde_json::json!(&latest_cwd)) + .expect("absolute latest cwd"), workspace_roots: None, current_date: None, timezone: None, diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index ce2da4156d..2254b4da89 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -16,10 +16,10 @@ pub fn apply_rollout_item( metadata: &mut ThreadMetadata, item: &RolloutItem, default_provider: &str, -) { +) -> std::io::Result<()> { match item { RolloutItem::SessionMeta(meta_line) => apply_session_meta_from_item(metadata, meta_line), - RolloutItem::TurnContext(turn_ctx) => apply_turn_context(metadata, turn_ctx), + RolloutItem::TurnContext(turn_ctx) => apply_turn_context(metadata, turn_ctx)?, RolloutItem::EventMsg(event) => apply_event_msg(metadata, event), RolloutItem::ResponseItem(item) => apply_response_item(metadata, item), RolloutItem::InterAgentCommunication(_) => {} @@ -28,6 +28,7 @@ pub fn apply_rollout_item( if metadata.model_provider.is_empty() { metadata.model_provider = default_provider.to_string(); } + Ok(()) } /// Return whether this rollout item can mutate thread metadata stored in SQLite. @@ -72,15 +73,21 @@ fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &Sessi } } -fn apply_turn_context(metadata: &mut ThreadMetadata, turn_ctx: &TurnContextItem) { - if metadata.cwd.as_os_str().is_empty() { - metadata.cwd = turn_ctx.cwd.clone(); +fn apply_turn_context( + metadata: &mut ThreadMetadata, + turn_ctx: &TurnContextItem, +) -> std::io::Result<()> { + if metadata.cwd.as_os_str().is_empty() + && let Ok(cwd) = turn_ctx.cwd.to_abs_path() + { + metadata.cwd = cwd.into_path_buf(); } metadata.model = Some(turn_ctx.model.clone()); metadata.reasoning_effort = turn_ctx.effort.clone(); metadata.sandbox_policy = - serde_json::to_string(&turn_ctx.permission_profile()).unwrap_or_default(); + serde_json::to_string(&turn_ctx.permission_profile()?).unwrap_or_default(); metadata.approval_mode = enum_to_string(&turn_ctx.approval_policy); + Ok(()) } fn apply_event_msg(metadata: &mut ThreadMetadata, event: &EventMsg) { @@ -194,7 +201,8 @@ mod tests { metadata: None, }); - apply_rollout_item(&mut metadata, &item, "test-provider"); + apply_rollout_item(&mut metadata, &item, "test-provider") + .expect("rollout item should apply"); assert_eq!(metadata.first_user_message, None); assert_eq!(metadata.preview, None); @@ -213,7 +221,8 @@ mod tests { ..Default::default() })); - apply_rollout_item(&mut metadata, &item, "test-provider"); + apply_rollout_item(&mut metadata, &item, "test-provider") + .expect("rollout item should apply"); assert_eq!( metadata.first_user_message.as_deref(), @@ -235,7 +244,8 @@ mod tests { ..Default::default() })); - apply_rollout_item(&mut metadata, &item, "test-provider"); + apply_rollout_item(&mut metadata, &item, "test-provider") + .expect("rollout item should apply"); assert_eq!( metadata.first_user_message.as_deref(), @@ -260,7 +270,8 @@ mod tests { ..Default::default() })); - apply_rollout_item(&mut metadata, &item, "test-provider"); + apply_rollout_item(&mut metadata, &item, "test-provider") + .expect("rollout item should apply"); assert_eq!(metadata.first_user_message, None); assert_eq!(metadata.preview, None); @@ -286,7 +297,8 @@ mod tests { }, })); - apply_rollout_item(&mut metadata, &goal_item, "test-provider"); + apply_rollout_item(&mut metadata, &goal_item, "test-provider") + .expect("rollout item should apply"); assert_eq!(metadata.preview.as_deref(), Some("optimize the benchmark")); assert_eq!(metadata.first_user_message, None); @@ -301,7 +313,8 @@ mod tests { ..Default::default() })); - apply_rollout_item(&mut metadata, &user_item, "test-provider"); + apply_rollout_item(&mut metadata, &user_item, "test-provider") + .expect("rollout item should apply"); assert_eq!(metadata.preview.as_deref(), Some("optimize the benchmark")); assert_eq!( @@ -344,12 +357,18 @@ mod tests { git: None, }), "test-provider", - ); + ) + .expect("rollout item should apply"); apply_rollout_item( &mut metadata, &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), - cwd: PathBuf::from("/parent/workspace"), + cwd: serde_json::from_value(serde_json::json!( + std::env::current_dir() + .expect("current directory") + .join("parent/workspace") + )) + .expect("absolute parent cwd"), workspace_roots: None, current_date: None, timezone: None, @@ -368,7 +387,8 @@ mod tests { summary: codex_protocol::config_types::ReasoningSummary::Auto, }), "test-provider", - ); + ) + .expect("rollout item should apply"); assert_eq!(metadata.cwd, PathBuf::from("/child/worktree")); let permission_profile: PermissionProfile = PermissionProfile::Disabled; @@ -388,7 +408,12 @@ mod tests { &mut metadata, &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), - cwd: PathBuf::from("/workspace"), + cwd: serde_json::from_value(serde_json::json!( + std::env::current_dir() + .expect("current directory") + .join("workspace") + )) + .expect("absolute workspace cwd"), workspace_roots: None, current_date: None, timezone: None, @@ -407,7 +432,8 @@ mod tests { summary: codex_protocol::config_types::ReasoningSummary::Auto, }), "test-provider", - ); + ) + .expect("rollout item should apply"); assert_eq!( metadata.sandbox_policy, @@ -419,12 +445,16 @@ mod tests { fn turn_context_sets_cwd_when_session_cwd_missing() { let mut metadata = metadata_for_test(); metadata.cwd = PathBuf::new(); + let fallback_cwd = std::env::current_dir() + .expect("current directory") + .join("fallback/workspace"); apply_rollout_item( &mut metadata, &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), - cwd: PathBuf::from("/fallback/workspace"), + cwd: serde_json::from_value(serde_json::json!(&fallback_cwd)) + .expect("absolute fallback cwd"), workspace_roots: None, current_date: None, timezone: None, @@ -443,9 +473,10 @@ mod tests { summary: codex_protocol::config_types::ReasoningSummary::Auto, }), "test-provider", - ); + ) + .expect("rollout item should apply"); - assert_eq!(metadata.cwd, PathBuf::from("/fallback/workspace")); + assert_eq!(metadata.cwd, fallback_cwd); } #[test] @@ -456,7 +487,12 @@ mod tests { &mut metadata, &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), - cwd: PathBuf::from("/fallback/workspace"), + cwd: serde_json::from_value(serde_json::json!( + std::env::current_dir() + .expect("current directory") + .join("fallback/workspace") + )) + .expect("absolute fallback cwd"), workspace_roots: None, current_date: None, timezone: None, @@ -475,7 +511,8 @@ mod tests { summary: codex_protocol::config_types::ReasoningSummary::Auto, }), "test-provider", - ); + ) + .expect("rollout item should apply"); assert_eq!(metadata.model.as_deref(), Some("gpt-5")); assert_eq!(metadata.reasoning_effort, Some(ReasoningEffort::High)); @@ -511,7 +548,8 @@ mod tests { git: None, }), "test-provider", - ); + ) + .expect("rollout item should apply"); assert_eq!(metadata.model, None); assert_eq!(metadata.reasoning_effort, None); diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 6cebf27e87..81a490a0cb 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -829,7 +829,7 @@ ON CONFLICT(id) DO UPDATE SET .unwrap_or_else(|| builder.build(&self.default_provider)); metadata.rollout_path = builder.rollout_path.clone(); for item in items { - apply_rollout_item(&mut metadata, item, &self.default_provider); + apply_rollout_item(&mut metadata, item, &self.default_provider)?; } if let Some(existing_metadata) = existing_metadata.as_ref() { metadata.prefer_existing_git_info(existing_metadata); diff --git a/codex-rs/thread-store/src/live_thread.rs b/codex-rs/thread-store/src/live_thread.rs index 88d347f57b..0e81b165ad 100644 --- a/codex-rs/thread-store/src/live_thread.rs +++ b/codex-rs/thread-store/src/live_thread.rs @@ -125,7 +125,7 @@ impl LiveThread { } } } - let metadata_sync = ThreadMetadataSync::for_resume(¶ms); + let metadata_sync = ThreadMetadataSync::for_resume(¶ms)?; Ok(Self { thread_id, thread_store, @@ -151,7 +151,7 @@ impl LiveThread { .metadata_sync .lock() .await - .observe_appended_items(canonical_items.as_slice()); + .observe_appended_items(canonical_items.as_slice())?; if let Some(update) = update { self.thread_store .update_thread_metadata(UpdateThreadMetadataParams { diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index 8eb23e3632..d4bd8edb76 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -18,6 +18,8 @@ use crate::CreateThreadParams; use crate::GitInfoPatch; use crate::ResumeThreadParams; use crate::ThreadMetadataPatch; +use crate::ThreadStoreError; +use crate::ThreadStoreResult; const IMAGE_ONLY_USER_MESSAGE_PLACEHOLDER: &str = "[Image]"; #[cfg(not(test))] @@ -90,7 +92,7 @@ impl ThreadMetadataSync { } } - pub(crate) fn for_resume(params: &ResumeThreadParams) -> Self { + pub(crate) fn for_resume(params: &ResumeThreadParams) -> ThreadStoreResult { let mut sync = Self { thread_id: params.thread_id, cwd_seen: params @@ -108,11 +110,11 @@ impl ThreadMetadataSync { defer_resume_update_until_append: false, }; if let Some(history) = params.history.as_deref() { - let update = sync.observe_resume_history(history); + let update = sync.observe_resume_history(history)?; sync.merge_pending_update(update); sync.defer_resume_update_until_append = sync.pending_update.is_some(); } - sync + Ok(sync) } pub(crate) fn take_pending_update(&self) -> Option { @@ -148,7 +150,7 @@ impl ThreadMetadataSync { pub(crate) fn observe_appended_items( &mut self, items: &[RolloutItem], - ) -> Option { + ) -> ThreadStoreResult> { self.defer_create_update_until_history_exists = false; self.defer_resume_update_until_append = false; let affects_metadata = items @@ -157,7 +159,10 @@ impl ThreadMetadataSync { let update = if affects_metadata { self.observe_items(items)? } else { - thread_updated_at_touch() + Some(thread_updated_at_touch()) + }; + let Some(update) = update else { + return Ok(None); }; self.merge_pending_update(Some(update)); if !affects_metadata @@ -169,12 +174,15 @@ impl ThreadMetadataSync { Instant::now().duration_since(last_touch) < THREAD_UPDATED_AT_TOUCH_INTERVAL }) { - return None; + return Ok(None); } - self.take_pending_update() + Ok(self.take_pending_update()) } - fn observe_items(&mut self, items: &[RolloutItem]) -> Option { + fn observe_items( + &mut self, + items: &[RolloutItem], + ) -> ThreadStoreResult> { self.observe_items_with_update( items, ThreadMetadataPatch { @@ -184,7 +192,10 @@ impl ThreadMetadataSync { ) } - fn observe_resume_history(&mut self, items: &[RolloutItem]) -> Option { + fn observe_resume_history( + &mut self, + items: &[RolloutItem], + ) -> ThreadStoreResult> { self.observe_items_with_update(items, ThreadMetadataPatch::default()) } @@ -192,9 +203,9 @@ impl ThreadMetadataSync { &mut self, items: &[RolloutItem], mut update: ThreadMetadataPatch, - ) -> Option { + ) -> ThreadStoreResult> { if items.is_empty() { - return None; + return Ok(None); } for item in items { match item { @@ -227,14 +238,23 @@ impl ThreadMetadataSync { } } RolloutItem::TurnContext(turn_ctx) => { - if !self.cwd_seen && !turn_ctx.cwd.as_os_str().is_empty() { + if !self.cwd_seen + && let Ok(cwd) = turn_ctx.cwd.to_abs_path() + { self.cwd_seen = true; - update.cwd = Some(turn_ctx.cwd.clone()); + update.cwd = Some(cwd.into_path_buf()); } update.model = Some(turn_ctx.model.clone()); update.reasoning_effort = turn_ctx.effort.clone(); update.approval_mode = Some(turn_ctx.approval_policy); - update.permission_profile = Some(turn_ctx.permission_profile()); + update.permission_profile = + Some(turn_ctx.permission_profile().map_err(|err| { + ThreadStoreError::Internal { + message: format!( + "failed to hydrate turn permission profile: {err}" + ), + } + })?); } RolloutItem::EventMsg(EventMsg::UserMessage(user)) => { if let Some(preview) = user_message_preview(user) { @@ -276,7 +296,7 @@ impl ThreadMetadataSync { | RolloutItem::Compacted(_) => {} } } - Some(update) + Ok(Some(update)) } fn merge_pending_update(&mut self, update: Option) { @@ -394,7 +414,8 @@ mod tests { RolloutItem::SessionMeta(session_meta(thread_id)), RolloutItem::EventMsg(EventMsg::UserMessage(user_message("hello metadata"))), ], - )); + )) + .expect("resume metadata should hydrate"); let update = sync.take_pending_update().expect("pending metadata update"); assert_eq!( @@ -433,7 +454,8 @@ mod tests { ))), RolloutItem::EventMsg(EventMsg::UserMessage(user_message("first user text"))), ], - )); + )) + .expect("resume metadata should hydrate"); let update = sync.take_pending_update().expect("pending metadata update"); assert_eq!(update.patch.preview.as_deref(), Some("ship the refactor")); @@ -452,7 +474,8 @@ mod tests { vec![RolloutItem::EventMsg(EventMsg::UserMessage(user_message( "first user text", )))], - )); + )) + .expect("resume metadata should hydrate"); let pending = sync.take_pending_update().expect("pending resume metadata"); sync.mark_pending_update_applied(&pending); @@ -460,6 +483,7 @@ mod tests { .observe_appended_items(&[RolloutItem::EventMsg(EventMsg::UserMessage(user_message( "later user text", )))]) + .expect("appended metadata should hydrate") .expect("updated_at touch"); assert_eq!(update.patch.preview, None); @@ -471,7 +495,8 @@ mod tests { #[test] fn metadata_irrelevant_items_coalesce_updated_at_touches() { let thread_id = ThreadId::new(); - let mut sync = ThreadMetadataSync::for_resume(&resume_params(thread_id, Vec::new())); + let mut sync = ThreadMetadataSync::for_resume(&resume_params(thread_id, Vec::new())) + .expect("resume metadata should hydrate"); let item = RolloutItem::Compacted(CompactedItem { message: "compacted".to_string(), replacement_history: None, @@ -480,12 +505,14 @@ mod tests { let first = sync .observe_appended_items(std::slice::from_ref(&item)) + .expect("appended metadata should hydrate") .expect("first touch should apply immediately"); assert!(first.patch.updated_at.is_some()); sync.mark_pending_update_applied(&first); assert!( sync.observe_appended_items(std::slice::from_ref(&item)) + .expect("appended metadata should hydrate") .is_none(), "second touch inside the coalescing window should wait for a barrier" ); @@ -504,7 +531,8 @@ mod tests { RolloutItem::SessionMeta(session_meta(thread_id)), RolloutItem::EventMsg(EventMsg::UserMessage(user_message("hello metadata"))), ], - )); + )) + .expect("resume metadata should hydrate"); assert!( sync.take_pending_update_for_existing_history().is_none(), @@ -514,6 +542,7 @@ mod tests { sync.observe_appended_items(&[RolloutItem::EventMsg(EventMsg::UserMessage( user_message("new append"), ))]) + .expect("appended metadata should hydrate") .is_some(), "the first append should flush resume metadata together with append metadata" ); diff --git a/codex-rs/utils/path-uri/src/lib.rs b/codex-rs/utils/path-uri/src/lib.rs index b835530d5b..18142c7b19 100644 --- a/codex-rs/utils/path-uri/src/lib.rs +++ b/codex-rs/utils/path-uri/src/lib.rs @@ -164,6 +164,19 @@ impl PathUri { } } + /// Renders this URI using the native path syntax inferred from its shape. + /// + /// This is independent of the current host: a Windows URI renders with + /// Windows separators on every host. If the convention cannot be inferred + /// or the URI cannot be represented using that convention, the canonical + /// URI string is returned instead. + pub fn inferred_native_path_string(&self) -> String { + self.infer_path_convention() + .and_then(|convention| LegacyAppPathString::from_path_uri(self, convention).ok()) + .map(LegacyAppPathString::into_string) + .unwrap_or_else(|| self.to_string()) + } + /// Returns the decoded final URI path segment, or `None` for the URI root /// or an opaque fallback URI created by [`Self::from_abs_path`]. /// diff --git a/codex-rs/utils/path-uri/src/tests.rs b/codex-rs/utils/path-uri/src/tests.rs index fe436476dd..1992cc9c75 100644 --- a/codex-rs/utils/path-uri/src/tests.rs +++ b/codex-rs/utils/path-uri/src/tests.rs @@ -100,6 +100,28 @@ fn drive_shaped_posix_uri_is_intentionally_inferred_as_windows() { assert_eq!(path.infer_path_convention(), Some(PathConvention::Windows)); } +#[test] +fn inferred_native_path_string_uses_the_inferred_convention() { + for (uri, expected) in [ + ("file:///home/alice/a%20file.rs", "/home/alice/a file.rs"), + ( + "file:///C:/Users/Alice%20Smith/main.rs", + r"C:\Users\Alice Smith\main.rs", + ), + ("file://server/share/main.rs", r"\\server\share\main.rs"), + ("file://server/", "file://server/"), + ("file:///%00/bad/path/YQ", "file:///%00/bad/path/YQ"), + ] { + let path = PathUri::parse(uri).expect("valid path URI"); + + assert_eq!( + path.inferred_native_path_string(), + expected, + "rendering {uri}" + ); + } +} + #[cfg(windows)] #[test] fn file_uri_falls_back_for_windows_prefixes_without_a_uri_representation() { From a397b59887d6b5df7498dd7c52d1000db78b6b4e Mon Sep 17 00:00:00 2001 From: Alex Daley Date: Tue, 16 Jun 2026 19:44:42 -0400 Subject: [PATCH 12/70] [codex] [4/4] Simplify recommended plugin install schema (#28403) ## Summary - Simplify recommendation-context `request_plugin_install` arguments to `plugin_id` and `suggest_reason`. - Derive plugin type and install action from the matched candidate while preserving Codex-owned elicitation metadata. - Keep the legacy list-backed schema unchanged and accept resumed calls that still use `tool_id`. ## Stack - #28399 - #28400 - #27704 - This PR ## Validation - `just test -p codex-tools -p codex-core request_plugin_install` (25 passed) - `just fix -p codex-tools -p codex-core` - `just fmt` - `git diff --check` --- .../recommended_plugins_instructions.rs | 2 +- .../tools/handlers/request_plugin_install.rs | 76 +++++++--- .../handlers/request_plugin_install_spec.rs | 136 +++++++++++------- .../handlers/request_plugin_install_tests.rs | 18 +++ codex-rs/core/src/tools/spec_plan_tests.rs | 8 +- .../tests/suite/request_plugin_install.rs | 8 +- codex-rs/tools/src/request_plugin_install.rs | 12 +- .../tools/src/request_plugin_install_tests.rs | 22 +-- 8 files changed, 174 insertions(+), 108 deletions(-) diff --git a/codex-rs/core/src/context/recommended_plugins_instructions.rs b/codex-rs/core/src/context/recommended_plugins_instructions.rs index a7b3ea78fe..9ad3c9a0d7 100644 --- a/codex-rs/core/src/context/recommended_plugins_instructions.rs +++ b/codex-rs/core/src/context/recommended_plugins_instructions.rs @@ -1,7 +1,7 @@ use super::ContextualUserFragment; use codex_tools::DiscoverableTool; -const RECOMMENDED_PLUGINS_INTRO: &str = "Here is a list of plugins that are available but not installed. If the user's query would benefit from one of these plugins, use the `request_plugin_install` tool to suggest that they install it. All entries have `tool_type: plugin`; pass `plugin` as `tool_type` and the parenthesized ID as `tool_id`. For example, suggest the Google Drive plugin if the query could possibly be better answered with access to Google Drive."; +const RECOMMENDED_PLUGINS_INTRO: &str = "Here is a list of plugins that are available but not installed. If the user's query would benefit from one of these plugins, use the `request_plugin_install` tool to suggest that they install it. Pass the parenthesized ID as `plugin_id`. For example, suggest the Google Drive plugin if the query could possibly be better answered with access to Google Drive."; const MAX_RECOMMENDED_PLUGINS: usize = 50; #[derive(Debug, Clone, PartialEq)] diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index 0a7f19546c..8047213140 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -22,6 +22,7 @@ use codex_tools::build_request_plugin_install_elicitation_request; use codex_tools::filter_request_plugin_install_discoverable_tools_for_client; use codex_tools::verified_connector_install_completed; use rmcp::model::RequestId; +use serde::Deserialize; use serde_json::Value; use tracing::warn; @@ -39,6 +40,13 @@ use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; use crate::tools::router::ToolSuggestPresentation; +#[derive(Debug, Deserialize, PartialEq, Eq)] +struct RecommendedPluginInstallArgs { + #[serde(alias = "tool_id")] + plugin_id: String, + suggest_reason: String, +} + pub struct RequestPluginInstallHandler { discoverable_tools: Vec, presentation: ToolSuggestPresentation, @@ -96,20 +104,30 @@ impl RequestPluginInstallHandler { } }; - let args: RequestPluginInstallArgs = parse_arguments(&arguments)?; - let suggest_reason = args.suggest_reason.trim(); + let (requested_tool_id, requested_tool_type, suggest_reason) = match self.presentation { + ToolSuggestPresentation::ListTool => { + let args: RequestPluginInstallArgs = parse_arguments(&arguments)?; + if args.action_type != DiscoverableToolAction::Install { + return Err(FunctionCallError::RespondToModel( + "plugin install requests currently support only action_type=\"install\"" + .to_string(), + )); + } + (args.tool_id, Some(args.tool_type), args.suggest_reason) + } + ToolSuggestPresentation::RecommendationContext => { + let args: RecommendedPluginInstallArgs = parse_arguments(&arguments)?; + (args.plugin_id, None, args.suggest_reason) + } + }; + let suggest_reason = suggest_reason.trim(); if suggest_reason.is_empty() { return Err(FunctionCallError::RespondToModel( "suggest_reason must not be empty".to_string(), )); } - if args.action_type != DiscoverableToolAction::Install { - return Err(FunctionCallError::RespondToModel( - "plugin install requests currently support only action_type=\"install\"" - .to_string(), - )); - } - if args.tool_type == DiscoverableToolType::Plugin + if (requested_tool_type == Some(DiscoverableToolType::Plugin) + || self.presentation == ToolSuggestPresentation::RecommendationContext) && turn.app_server_client_name.as_deref() == Some("codex-tui") { return Err(FunctionCallError::RespondToModel( @@ -124,27 +142,41 @@ impl RequestPluginInstallHandler { let tool = discoverable_tools .into_iter() - .find(|tool| tool.tool_type() == args.tool_type && tool.id() == args.tool_id) - .ok_or_else(|| { - let source = match self.presentation { - ToolSuggestPresentation::ListTool => format!( - "the discoverable tools returned by {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}" - ), - ToolSuggestPresentation::RecommendationContext => { - "the list".to_string() + .find(|tool| { + tool.id() == requested_tool_id + && match self.presentation { + ToolSuggestPresentation::ListTool => { + Some(tool.tool_type()) == requested_tool_type + } + ToolSuggestPresentation::RecommendationContext => { + matches!(tool, DiscoverableTool::Plugin(_)) + } } + }) + .ok_or_else(|| { + let (argument_name, source) = match self.presentation { + ToolSuggestPresentation::ListTool => ( + "tool_id", + format!( + "the discoverable tools returned by {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}" + ), + ), + ToolSuggestPresentation::RecommendationContext => ( + "plugin_id", + "the entries in the list".to_string(), + ), }; FunctionCallError::RespondToModel(format!( - "tool_id must match one of {source}" + "{argument_name} must match one of {source}" )) })?; + let tool_type = tool.tool_type(); let request_id = RequestId::String(format!("request_plugin_install_{call_id}").into()); let params = build_request_plugin_install_elicitation_request( CODEX_APPS_MCP_SERVER_NAME, session.thread_id.to_string(), turn.sub_id.clone(), - &args, suggest_reason, &tool, ); @@ -173,7 +205,7 @@ impl RequestPluginInstallHandler { } if elicitation.sent { - let tool_type = match args.tool_type { + let tool_type = match tool_type { DiscoverableToolType::Connector => "connector", DiscoverableToolType::Plugin => "plugin", }; @@ -196,8 +228,8 @@ impl RequestPluginInstallHandler { let content = serde_json::to_string(&RequestPluginInstallResult { completed, user_confirmed, - tool_type: args.tool_type, - action_type: args.action_type, + tool_type, + action_type: DiscoverableToolAction::Install, tool_id: tool.id().to_string(), tool_name: tool.name().to_string(), suggest_reason: suggest_reason.to_string(), diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs b/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs index fa3bbdab07..e37e223832 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs @@ -10,37 +10,63 @@ use crate::tools::router::ToolSuggestPresentation; pub(crate) fn create_request_plugin_install_tool( presentation: ToolSuggestPresentation, ) -> ToolSpec { - let properties = BTreeMap::from([ - ( - "tool_type".to_string(), - JsonSchema::string(Some( - "Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"." - .to_string(), - )), + let (properties, required, description) = match presentation { + ToolSuggestPresentation::ListTool => ( + BTreeMap::from([ + ( + "tool_type".to_string(), + JsonSchema::string(Some( + "Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"." + .to_string(), + )), + ), + ( + "action_type".to_string(), + JsonSchema::string(Some( + "Suggested action for the tool. Use \"install\".".to_string(), + )), + ), + ( + "tool_id".to_string(), + JsonSchema::string(Some("Connector or plugin id to suggest.".to_string())), + ), + ( + "suggest_reason".to_string(), + JsonSchema::string(Some( + "Concise one-line user-facing reason why this plugin or connector can help with the current request." + .to_string(), + )), + ), + ]), + vec![ + "tool_type".to_string(), + "action_type".to_string(), + "tool_id".to_string(), + "suggest_reason".to_string(), + ], + format!( + "# Request plugin/connector install\n\nUse this tool only after `{LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}` returns a plugin or connector that exactly matches the user's explicit request.\n\nDo not use it for adjacent capabilities, broad recommendations, or tools that merely seem useful. Pass the returned `tool_type` through directly, and pass the returned `id` as `tool_id`.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools." + ), ), - ( - "action_type".to_string(), - JsonSchema::string(Some("Suggested action for the tool. Use \"install\".".to_string())), - ), - ( - "tool_id".to_string(), - JsonSchema::string(Some("Connector or plugin id to suggest.".to_string())), - ), - ( - "suggest_reason".to_string(), - JsonSchema::string(Some( - "Concise one-line user-facing reason why this plugin or connector can help with the current request." - .to_string(), - )), - ), - ]); - - let description = match presentation { - ToolSuggestPresentation::ListTool => format!( - "# Request plugin/connector install\n\nUse this tool only after `{LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}` returns a plugin or connector that exactly matches the user's explicit request.\n\nDo not use it for adjacent capabilities, broad recommendations, or tools that merely seem useful. Pass the returned `tool_type` through directly, and pass the returned `id` as `tool_id`.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools." - ), - ToolSuggestPresentation::RecommendationContext => + ToolSuggestPresentation::RecommendationContext => ( + BTreeMap::from([ + ( + "plugin_id".to_string(), + JsonSchema::string(Some( + "Plugin id from the `` list.".to_string(), + )), + ), + ( + "suggest_reason".to_string(), + JsonSchema::string(Some( + "Concise one-line user-facing reason why this plugin can help with the current request." + .to_string(), + )), + ), + ]), + vec!["plugin_id".to_string(), "suggest_reason".to_string()], "# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the `` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string(), + ), }; ToolSpec::Function(ResponsesApiTool { @@ -48,16 +74,7 @@ pub(crate) fn create_request_plugin_install_tool( description, strict: false, defer_loading: None, - parameters: JsonSchema::object( - properties, - Some(vec![ - "tool_type".to_string(), - "action_type".to_string(), - "tool_id".to_string(), - "suggest_reason".to_string(), - ]), - Some(false.into()), - ), + parameters: JsonSchema::object(properties, Some(required), Some(false.into())), output_schema: None, }) } @@ -126,16 +143,35 @@ mod tests { } #[test] - fn recommendation_context_changes_only_the_description() { - let mut expected = create_request_plugin_install_tool(ToolSuggestPresentation::ListTool); - let recommendations = - create_request_plugin_install_tool(ToolSuggestPresentation::RecommendationContext); - - let ToolSpec::Function(expected_function) = &mut expected else { - panic!("expected function tool specs"); - }; - expected_function.description = "# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the `` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string(); - - assert_eq!(recommendations, expected); + fn recommendation_context_uses_simplified_plugin_wire_shape() { + assert_eq!( + create_request_plugin_install_tool(ToolSuggestPresentation::RecommendationContext), + ToolSpec::Function(ResponsesApiTool { + name: "request_plugin_install".to_string(), + description: "# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the `` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + BTreeMap::from([ + ( + "plugin_id".to_string(), + JsonSchema::string(Some( + "Plugin id from the `` list.".to_string(), + )), + ), + ( + "suggest_reason".to_string(), + JsonSchema::string(Some( + "Concise one-line user-facing reason why this plugin can help with the current request." + .to_string(), + )), + ), + ]), + Some(vec!["plugin_id".to_string(), "suggest_reason".to_string()]), + Some(false.into()), + ), + output_schema: None, + }) + ); } } diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs b/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs index cacc25ff95..62c8afaa65 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs @@ -68,6 +68,24 @@ fn remote_plugin_install_suggestions_skip_core_installed_verification() { assert!(!is_remote_plugin_install_suggestion("Plugin_123")); } +#[test] +fn recommended_plugin_install_args_accept_legacy_tool_id() { + let current: RecommendedPluginInstallArgs = serde_json::from_value(json!({ + "plugin_id": "google-drive@openai-curated-remote", + "suggest_reason": "Use Google Drive for this request" + })) + .expect("current arguments should deserialize"); + let legacy: RecommendedPluginInstallArgs = serde_json::from_value(json!({ + "tool_type": "plugin", + "action_type": "install", + "tool_id": "google-drive@openai-curated-remote", + "suggest_reason": "Use Google Drive for this request" + })) + .expect("legacy arguments should deserialize"); + + assert_eq!(current, legacy); +} + #[test] fn request_plugin_install_response_persists_only_decline_always_mode() { assert!(request_plugin_install_response_requests_persistent_disable( diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index fc17ff5d3d..8a324dcc8a 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -966,16 +966,22 @@ async fn request_plugin_install_description_refers_to_recommended_plugins_hint() ) .await; + let request_spec = plan.visible_spec("request_plugin_install"); let ToolSpec::Function(ResponsesApiTool { description: request_description, .. - }) = plan.visible_spec("request_plugin_install") + }) = request_spec else { panic!("expected request_plugin_install function spec"); }; assert!(request_description.contains("the `` list")); assert!(!request_description.contains("list_available_plugins_to_install")); assert!(!request_description.contains("github")); + assert!(has_parameter(request_spec, "plugin_id")); + assert!(has_parameter(request_spec, "suggest_reason")); + assert!(!has_parameter(request_spec, "tool_id")); + assert!(!has_parameter(request_spec, "tool_type")); + assert!(!has_parameter(request_spec, "action_type")); plan.assert_visible_lacks(&["list_available_plugins_to_install"]); plan.assert_registered_lacks(&["list_available_plugins_to_install"]); } diff --git a/codex-rs/core/tests/suite/request_plugin_install.rs b/codex-rs/core/tests/suite/request_plugin_install.rs index 9812d39410..f3da1bea44 100644 --- a/codex-rs/core/tests/suite/request_plugin_install.rs +++ b/codex-rs/core/tests/suite/request_plugin_install.rs @@ -230,9 +230,7 @@ async fn endpoint_mode_injects_candidates_hides_list_and_rejects_invented_ids() call_id, REQUEST_PLUGIN_INSTALL_TOOL_NAME, &serde_json::to_string(&json!({ - "tool_type": "plugin", - "action_type": "install", - "tool_id": "invented@openai-curated-remote", + "plugin_id": "invented@openai-curated-remote", "suggest_reason": "Try this" }))?, ), @@ -312,9 +310,7 @@ async fn endpoint_recommendation_adds_install_identity_only_to_elicitation_metad call_id, REQUEST_PLUGIN_INSTALL_TOOL_NAME, &serde_json::to_string(&json!({ - "tool_type": "plugin", - "action_type": "install", - "tool_id": "github@openai-curated-remote", + "plugin_id": "github@openai-curated-remote", "suggest_reason": "Use GitHub for this request" }))?, ), diff --git a/codex-rs/tools/src/request_plugin_install.rs b/codex-rs/tools/src/request_plugin_install.rs index 5d6352b7f8..7bc0f6d4c6 100644 --- a/codex-rs/tools/src/request_plugin_install.rs +++ b/codex-rs/tools/src/request_plugin_install.rs @@ -57,7 +57,6 @@ pub fn build_request_plugin_install_elicitation_request( server_name: &str, thread_id: String, turn_id: String, - args: &RequestPluginInstallArgs, suggest_reason: &str, tool: &DiscoverableTool, ) -> McpServerElicitationRequestParams { @@ -69,8 +68,6 @@ pub fn build_request_plugin_install_elicitation_request( server_name: server_name.to_string(), request: McpServerElicitationRequest::Form { meta: Some(json!(build_request_plugin_install_meta( - args.tool_type, - args.action_type, suggest_reason, tool, ))), @@ -105,14 +102,13 @@ pub fn verified_connector_install_completed( } fn build_request_plugin_install_meta<'a>( - tool_type: DiscoverableToolType, - action_type: DiscoverableToolAction, suggest_reason: &'a str, tool: &'a DiscoverableTool, ) -> RequestPluginInstallMeta<'a> { - let (remote_plugin_id, app_connector_ids) = match tool { - DiscoverableTool::Connector(_) => (None, None), + let (tool_type, remote_plugin_id, app_connector_ids) = match tool { + DiscoverableTool::Connector(_) => (DiscoverableToolType::Connector, None, None), DiscoverableTool::Plugin(plugin) => ( + DiscoverableToolType::Plugin, plugin.remote_plugin_id.as_deref(), Some(plugin.app_connector_ids.as_slice()), ), @@ -121,7 +117,7 @@ fn build_request_plugin_install_meta<'a>( codex_approval_kind: REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE, persist: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE, tool_type, - suggest_type: action_type, + suggest_type: DiscoverableToolAction::Install, suggest_reason, tool_id: tool.id(), tool_name: tool.name(), diff --git a/codex-rs/tools/src/request_plugin_install_tests.rs b/codex-rs/tools/src/request_plugin_install_tests.rs index ab8af9be67..400126dc6d 100644 --- a/codex-rs/tools/src/request_plugin_install_tests.rs +++ b/codex-rs/tools/src/request_plugin_install_tests.rs @@ -5,12 +5,6 @@ use serde_json::json; #[test] fn build_request_plugin_install_elicitation_request_uses_expected_shape() { - let args = RequestPluginInstallArgs { - tool_type: DiscoverableToolType::Connector, - action_type: DiscoverableToolAction::Install, - tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(), - suggest_reason: "Plan and reference events from your calendar".to_string(), - }; let connector = DiscoverableTool::Connector(Box::new(AppInfo { id: "connector_2128aebfecb84f64a069897515042a44".to_string(), name: "Google Calendar".to_string(), @@ -34,7 +28,6 @@ fn build_request_plugin_install_elicitation_request_uses_expected_shape() { "codex-apps", "thread-1".to_string(), "turn-1".to_string(), - &args, "Plan and reference events from your calendar", &connector, ); @@ -74,12 +67,6 @@ fn build_request_plugin_install_elicitation_request_uses_expected_shape() { #[test] fn build_request_plugin_install_elicitation_request_injects_plugin_metadata() { - let args = RequestPluginInstallArgs { - tool_type: DiscoverableToolType::Plugin, - action_type: DiscoverableToolAction::Install, - tool_id: "sample@openai-curated-remote".to_string(), - suggest_reason: "Use the sample plugin's skills and MCP server".to_string(), - }; let plugin = DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo { id: "sample@openai-curated-remote".to_string(), remote_plugin_id: Some("plugins~Plugin_sample".to_string()), @@ -94,7 +81,6 @@ fn build_request_plugin_install_elicitation_request_injects_plugin_metadata() { "codex-apps", "thread-1".to_string(), "turn-1".to_string(), - &args, "Use the sample plugin's skills and MCP server", &plugin, ); @@ -149,12 +135,8 @@ fn build_request_plugin_install_meta_uses_expected_shape() { is_enabled: true, plugin_display_names: Vec::new(), })); - let meta = build_request_plugin_install_meta( - DiscoverableToolType::Connector, - DiscoverableToolAction::Install, - "Find and reference emails from your inbox", - &connector, - ); + let meta = + build_request_plugin_install_meta("Find and reference emails from your inbox", &connector); assert_eq!( meta, From f0cb96bcb12f99e4143d6f577dd441a46415a4e9 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 16 Jun 2026 16:54:37 -0700 Subject: [PATCH 13/70] PAC 1 - Add system proxy feature config surface (#26706) ## Summary Introduces the default-off `respect_system_proxy` feature flag used to gate first-class system PAC/proxy support for Codex-owned native clients. With the feature disabled or absent, behavior remains unchanged. This PR establishes the configuration and managed-requirement surface; proxy discovery and request routing are implemented by follow-up PRs. ## Configuration User configuration uses the standard boolean feature form: ```toml [features] respect_system_proxy = true ``` Managed feature requirements use the corresponding boolean key. The effective runtime configuration is exposed as a boolean and defaults to `false`. ## Implementation - Registers `respect_system_proxy` as an under-development, default-off feature. - Resolves user configuration and managed feature requirements into `Config.respect_system_proxy`. - Provides bootstrap resolution for startup paths that must evaluate the feature before full configuration loading completes. - Uses the standard feature CLI and config-editing behavior. - Excludes `features.respect_system_proxy` from project-local configuration. - Updates the generated configuration schema. ## End-user behavior - No networking behavior changes when the feature is absent or disabled. - Enabling the feature makes the boolean available to the native proxy-routing implementation in follow-up PRs. - Repository-local configuration cannot enable the feature. ## Test coverage Covers scalar configuration and CLI override resolution, managed requirement constraints, bootstrap resolution, and project-local filtering. --- codex-rs/config/src/loader/mod.rs | 5 ++ codex-rs/core/config.schema.json | 8 +- .../core/src/config/config_loader_tests.rs | 4 + codex-rs/core/src/config/config_tests.rs | 86 +++++++++++++++++++ codex-rs/core/src/config/mod.rs | 26 ++++++ codex-rs/features/src/lib.rs | 8 ++ codex-rs/features/src/tests.rs | 1 + codex-rs/thread-manager-sample/src/main.rs | 1 + 8 files changed, 138 insertions(+), 1 deletion(-) diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 6d496bc9c5..e3c7ee1128 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -945,6 +945,11 @@ fn sanitize_project_config(config: &mut TomlValue) -> Vec { ignored_keys.push((*key).to_string()); } } + if let Some(features) = table.get_mut("features").and_then(TomlValue::as_table_mut) + && features.remove("respect_system_proxy").is_some() + { + ignored_keys.push("features.respect_system_proxy".to_string()); + } ignored_keys } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 3d7cffdcdd..66325bf0a5 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -587,6 +587,9 @@ "resize_all_images": { "type": "boolean" }, + "respect_system_proxy": { + "type": "boolean" + }, "responses_websockets": { "type": "boolean" }, @@ -4745,6 +4748,9 @@ "resize_all_images": { "type": "boolean" }, + "respect_system_proxy": { + "type": "boolean" + }, "responses_websockets": { "type": "boolean" }, @@ -5280,4 +5286,4 @@ }, "title": "ConfigToml", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 87a46fd14a..c01c555519 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -2931,6 +2931,9 @@ notify = ["sh", "-c", "echo attacker"] profile = "attacker" experimental_realtime_ws_base_url = "wss://attacker.example/realtime" +[features] +respect_system_proxy = true + [otel] environment = "attacker" @@ -2984,6 +2987,7 @@ wire_api = "responses" "profiles", "experimental_realtime_ws_base_url", "otel", + "features.respect_system_proxy", ]; let expected_startup_warnings = vec![format!( concat!( diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 38f04d23b6..47aa8910da 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1361,6 +1361,92 @@ sandbox = "elevated" Ok(()) } +#[tokio::test] +async fn respect_system_proxy_feature_resolves_enabled() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + features: Some( + toml::from_str( + r#" +respect_system_proxy = true +"#, + ) + .expect("valid features"), + ), + ..Default::default() + }, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert!(config.respect_system_proxy); + Ok(()) +} + +#[test] +fn bootstrap_respect_system_proxy_honors_feature_requirements() -> std::io::Result<()> { + let configured = ConfigToml { + features: Some( + toml::from_str( + r#" +respect_system_proxy = true +"#, + ) + .expect("valid features"), + ), + ..Default::default() + }; + let disabled = Sourced::new( + FeatureRequirementsToml { + entries: BTreeMap::from([("respect_system_proxy".to_string(), false)]), + }, + RequirementSource::Unknown, + ); + assert!(!resolve_bootstrap_respect_system_proxy( + &configured, + Some(&disabled) + )?); + + let configured = ConfigToml::default(); + let enabled = Sourced::new( + FeatureRequirementsToml { + entries: BTreeMap::from([("respect_system_proxy".to_string(), true)]), + }, + RequirementSource::Unknown, + ); + assert!(resolve_bootstrap_respect_system_proxy( + &configured, + Some(&enabled) + )?); + Ok(()) +} + +#[tokio::test] +async fn respect_system_proxy_cli_override_enables_feature() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +[features] +respect_system_proxy = false +"#, + )?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .cli_overrides(vec![( + "features.respect_system_proxy".to_string(), + toml::Value::Boolean(true), + )]) + .build() + .await?; + + assert!(config.respect_system_proxy); + Ok(()) +} + #[tokio::test] async fn experimental_network_requirements_enable_proxy_without_feature() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 04d418117e..3a6120b9f0 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -947,6 +947,9 @@ pub struct Config { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: String, + /// Whether Codex-owned clients should respect host system proxy settings. + pub respect_system_proxy: bool, + /// Optional product SKU forwarded to the host-owned apps MCP server. pub apps_mcp_product_sku: Option, @@ -2507,6 +2510,27 @@ fn network_proxy_toml_config(features: Option<&FeaturesToml>) -> Option<&Network } } +/// Bootstrap-only resolver for the cloud-config fetch. +/// +/// Call before a cloud-config bundle is available. Final [`Config`] loading +/// resolves the effective feature value after all layers are available. +pub fn resolve_bootstrap_respect_system_proxy( + cfg: &ConfigToml, + feature_requirements: Option<&Sourced>, +) -> std::io::Result { + let configured_features = Features::from_sources( + FeatureConfigSource { + features: cfg.features.as_ref(), + experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, + }, + FeatureConfigSource::default(), + FeatureOverrides::default(), + ); + let features = + ManagedFeatures::from_configured(configured_features, feature_requirements.cloned())?; + Ok(features.get().enabled(Feature::RespectSystemProxy)) +} + pub(crate) fn resolve_web_search_mode_for_turn( web_search_mode: &Constrained, permission_profile: &PermissionProfile, @@ -2770,6 +2794,7 @@ impl Config { feature_requirements, &mut startup_warnings, )?; + let respect_system_proxy = features.enabled(Feature::RespectSystemProxy); let enable_network_proxy = features.enabled(Feature::NetworkProxy); let configured_windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg); // Keep the configured mode separate so a requirement-constrained mode @@ -3617,6 +3642,7 @@ impl Config { chatgpt_base_url: cfg .chatgpt_base_url .unwrap_or("https://chatgpt.com/backend-api/".to_string()), + respect_system_proxy, apps_mcp_product_sku: cfg.apps_mcp_product_sku.clone(), realtime_audio: cfg .audio diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 10c5f3afe9..b887237a18 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -135,6 +135,8 @@ pub enum Feature { EnableRequestCompression, /// Start the managed network proxy for sandboxed sessions. NetworkProxy, + /// Respect host system proxy settings for Codex-owned network clients. + RespectSystemProxy, /// Enable collab tools. Collab, /// Enable task-path-based multi-agent routing. @@ -967,6 +969,12 @@ pub const FEATURES: &[FeatureSpec] = &[ }, default_enabled: false, }, + FeatureSpec { + id: Feature::RespectSystemProxy, + key: "respect_system_proxy", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::Collab, key: "multi_agent", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 0995dfe019..2e9d3203c0 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -649,6 +649,7 @@ fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config( features.enable(Feature::CodeMode); features.enable(Feature::MultiAgentV2); features.enable(Feature::NetworkProxy); + features.enable(Feature::RespectSystemProxy); let mut features_toml = FeaturesToml { multi_agent_v2: Some(FeatureToml::Config(crate::MultiAgentV2ConfigToml { diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 608c9a90a1..e2d803ce76 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -255,6 +255,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R model_catalog: None, model_verbosity: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + respect_system_proxy: false, apps_mcp_product_sku: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_ws_base_url: None, From fac3158c2a783095768076489815f361fa9b0db4 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:06:22 -0700 Subject: [PATCH 14/70] Add thread recencyAt for sidebar ordering (#27910) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add a server-owned `recencyAt` timestamp and `recency_at` thread-list sort key for product recency ordering while preserving the existing meaning of `updatedAt` as the latest persisted thread mutation. This is the server-side alternative to #27697. Rather than narrowing `updatedAt`, clients can sort the sidebar by `recency_at` and continue treating `updatedAt` as mutation time. Paired Codex Apps PR: [openai/openai#1024599](https://github.com/openai/openai/pull/1024599) ## Contract - `recencyAt` initializes when a thread is created. - A turn start advances `recencyAt` monotonically. - Commentary, agent output, tool results, token/accounting updates, turn completion, archive, unarchive, resume, and generic metadata writes do not advance it. - `updatedAt` retains its existing behavior and continues to advance for persisted thread mutations. - Current servers populate `recencyAt`; the response field is optional in generated TypeScript so clients connected to older servers can fall back to `updatedAt`. - Filesystem-only fallback uses existing updated/mtime ordering when SQLite is unavailable. ## Persistence and compatibility Migration 0038 adds second- and millisecond-precision recency columns, backfills them from the existing updated timestamp, creates list indexes, and includes an insert trigger so older binaries writing to a migrated database seed recency without causing later mutations to advance it. Generic metadata upserts preserve existing recency values. Turn-start updates use a dedicated monotonic touch, and process-local allocation keeps millisecond cursor values unique. State DB list, search, read, filtered-list repair, rollout fallback propagation, and app-server conversions all carry the new field. ## API `Thread` responses include: ```ts recencyAt?: number ``` `thread/list` and `thread/search` accept: ```json { "sortKey": "recency_at" } ``` Generated TypeScript and JSON schemas are included. ## Validation - `just test -p codex-state` — 146 passed - `just test -p codex-rollout` — 69 passed - `just test -p codex-thread-store` — 81 passed - `just test -p codex-app-server-protocol` — 231 passed - Focused app-server list ordering, response mapping, archive/unarchive, and resume lifecycle tests passed - Scoped `just fix` for state, rollout, thread-store, app-server-protocol, and app-server - `just fmt` - `git diff --check` - Independent correctness, simplicity, elegance, security, and test-quality reviews; actionable ordering, lifecycle, query-projection, and timestamp-uniqueness findings were addressed --- .../analytics/src/analytics_client_tests.rs | 1 + codex-rs/analytics/src/client_tests.rs | 1 + .../schema/json/ClientRequest.json | 3 +- .../schema/json/ServerNotification.json | 8 + .../codex_app_server_protocol.schemas.json | 11 +- .../codex_app_server_protocol.v2.schemas.json | 11 +- .../schema/json/v2/ThreadForkResponse.json | 8 + .../schema/json/v2/ThreadListParams.json | 3 +- .../schema/json/v2/ThreadListResponse.json | 8 + .../json/v2/ThreadMetadataUpdateResponse.json | 8 + .../schema/json/v2/ThreadReadResponse.json | 8 + .../schema/json/v2/ThreadResumeResponse.json | 8 + .../json/v2/ThreadRollbackResponse.json | 8 + .../schema/json/v2/ThreadStartResponse.json | 8 + .../json/v2/ThreadStartedNotification.json | 8 + .../json/v2/ThreadUnarchiveResponse.json | 8 + .../schema/typescript/v2/Thread.ts | 4 + .../schema/typescript/v2/ThreadSortKey.ts | 2 +- .../src/protocol/common.rs | 2 + .../src/protocol/v2/tests.rs | 10 + .../src/protocol/v2/thread.rs | 1 + .../src/protocol/v2/thread_data.rs | 3 + codex-rs/app-server/README.md | 7 +- .../app-server/src/bespoke_event_handling.rs | 1 + .../request_processors/thread_processor.rs | 5 + .../thread_processor_tests.rs | 1 + .../thread_resume_redaction.rs | 1 + .../src/request_processors/thread_summary.rs | 1 + codex-rs/app-server/src/thread_status.rs | 1 + .../app-server/tests/suite/v2/thread_list.rs | 83 +++++ .../tests/suite/v2/thread_resume.rs | 26 +- codex-rs/core/src/realtime_context_tests.rs | 4 + codex-rs/exec/src/lib_tests.rs | 2 + codex-rs/rollout/src/list.rs | 71 ++-- codex-rs/rollout/src/metadata.rs | 1 + codex-rs/rollout/src/metadata_tests.rs | 1 + codex-rs/rollout/src/recorder.rs | 67 +++- codex-rs/rollout/src/recorder_tests.rs | 3 + codex-rs/rollout/src/state_db.rs | 7 +- codex-rs/rollout/src/state_db_tests.rs | 16 + codex-rs/rollout/src/tests.rs | 11 + .../migrations/0038_threads_recency_at.sql | 28 ++ codex-rs/state/src/extract.rs | 1 + codex-rs/state/src/migrations.rs | 4 + codex-rs/state/src/migrations_tests.rs | 129 +++++++ codex-rs/state/src/model/thread_metadata.rs | 26 +- codex-rs/state/src/runtime.rs | 44 ++- codex-rs/state/src/runtime/memories.rs | 2 + codex-rs/state/src/runtime/test_support.rs | 1 + codex-rs/state/src/runtime/threads.rs | 345 ++++++++++++++++-- codex-rs/thread-store/src/in_memory.rs | 3 + .../thread-store/src/local/archive_thread.rs | 1 + codex-rs/thread-store/src/local/helpers.rs | 2 + .../thread-store/src/local/list_threads.rs | 1 + codex-rs/thread-store/src/local/mod.rs | 91 +++++ .../thread-store/src/local/read_thread.rs | 9 + .../thread-store/src/local/search_threads.rs | 16 +- .../src/local/search_threads_tests.rs | 29 ++ .../src/local/unarchive_thread.rs | 1 + .../src/local/update_thread_metadata.rs | 18 + .../thread-store/src/thread_metadata_sync.rs | 31 +- codex-rs/thread-store/src/types.rs | 10 + codex-rs/tui/src/app/loaded_threads.rs | 1 + codex-rs/tui/src/app/tests.rs | 4 + codex-rs/tui/src/app/thread_session_state.rs | 1 + codex-rs/tui/src/app_server_session.rs | 1 + codex-rs/tui/src/resume_picker.rs | 12 +- 67 files changed, 1153 insertions(+), 99 deletions(-) create mode 100644 codex-rs/state/migrations/0038_threads_recency_at.sql create mode 100644 codex-rs/state/src/migrations_tests.rs create mode 100644 codex-rs/thread-store/src/local/search_threads_tests.rs diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 7c634ce370..b18b253b4a 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -178,6 +178,7 @@ fn sample_thread_with_metadata( model_provider: "openai".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: AppServerThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 1835932e8e..18bb8c2159 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -285,6 +285,7 @@ fn sample_thread(thread_id: &str) -> Thread { model_provider: "openai".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: AppServerThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 3358ea9d63..e7ead53958 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -4158,7 +4158,8 @@ "ThreadSortKey": { "enum": [ "created_at", - "updated_at" + "updated_at", + "recency_at" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index d64befa0a0..a0d49cc548 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3582,6 +3582,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 1e11c76ae4..bbf68eb315 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -16621,6 +16621,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -18880,7 +18888,8 @@ "ThreadSortKey": { "enum": [ "created_at", - "updated_at" + "updated_at", + "recency_at" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 326bfdfc9a..238be0011c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -14429,6 +14429,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -16688,7 +16696,8 @@ "ThreadSortKey": { "enum": [ "created_at", - "updated_at" + "updated_at", + "recency_at" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 4a666672b6..2b7caeca7e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1055,6 +1055,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json index 789d9b61fc..11f7da47d3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json @@ -24,7 +24,8 @@ "ThreadSortKey": { "enum": [ "created_at", - "updated_at" + "updated_at", + "recency_at" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index c1d1b6759a..0caa0daf5d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -870,6 +870,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 801d1cf131..a98d3586ec 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -870,6 +870,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 01b5ed907e..51e56f14b8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -870,6 +870,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 032d1dd74e..e8c309507e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1055,6 +1055,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index c1ad534756..52cba55edb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -870,6 +870,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 33373b9f72..245cc946d4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1055,6 +1055,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 577df57e05..bf497ecfa2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -870,6 +870,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index fcc42e6261..14f25cfcdc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -870,6 +870,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 5fa30b64e3..1c288ccbd4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -41,6 +41,10 @@ createdAt: number, * Unix timestamp (in seconds) when the thread was last updated. */ updatedAt: number, +/** + * Unix timestamp (in seconds) used for thread recency ordering. + */ +recencyAt: number | null, /** * Current runtime status for the thread. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts index dbf1b6c40f..d93f1c47bf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ThreadSortKey = "created_at" | "updated_at"; +export type ThreadSortKey = "created_at" | "updated_at" | "recency_at"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 7611c27ff1..3e9c0aade4 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2501,6 +2501,7 @@ mod tests { model_provider: "openai".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(3), status: v2::ThreadStatus::Idle, path: None, cwd: cwd.clone(), @@ -2544,6 +2545,7 @@ mod tests { "modelProvider": "openai", "createdAt": 1, "updatedAt": 2, + "recencyAt": 3, "status": { "type": "idle" }, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index de138d5c2e..39bc52717a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -174,6 +174,7 @@ fn thread_resume_response_round_trips_initial_turns_page() { model_provider: "openai".to_string(), created_at: 1, updated_at: 1, + recency_at: Some(1), status: ThreadStatus::Idle, path: None, cwd: absolute_path("tmp"), @@ -3600,6 +3601,7 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { assert_eq!(start.instruction_sources, Vec::::new()); assert_eq!(start.thread.parent_thread_id, None); + assert_eq!(start.thread.recency_at, None); assert_eq!(resume.instruction_sources, Vec::::new()); assert_eq!(fork.instruction_sources, Vec::::new()); assert_eq!(start.active_permission_profile, None); @@ -3608,6 +3610,14 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { assert_eq!(fork.active_permission_profile, None); } +#[test] +fn thread_recency_sort_key_serializes_as_snake_case() { + assert_eq!( + serde_json::to_value(ThreadSortKey::RecencyAt).expect("sort key should serialize"), + json!("recency_at") + ); +} + #[test] fn turn_start_params_preserve_explicit_null_service_tier() { let params: TurnStartParams = serde_json::from_value(json!({ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 0252c4b048..b5809f09b8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -1086,6 +1086,7 @@ pub enum ThreadSourceKind { pub enum ThreadSortKey { CreatedAt, UpdatedAt, + RecencyAt, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs index fc0762361f..52a8d7017a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -152,6 +152,9 @@ pub struct Thread { /// Unix timestamp (in seconds) when the thread was last updated. #[ts(type = "number")] pub updated_at: i64, + /// Unix timestamp (in seconds) used for thread recency ordering. + #[ts(type = "number | null")] + pub recency_at: Option, /// Current runtime status for the thread. pub status: ThreadStatus, /// [UNSTABLE] Path to the thread on disk. diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 336f769d71..8d470b8288 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -364,7 +364,8 @@ Like `thread/resume`, experimental clients can pass `excludeTurns: true` to `thr - `cursor` — opaque string from a prior response; omit for the first page. - `limit` — server defaults to a reasonable page size if unset. -- `sortKey` — `created_at` (default) or `updated_at`. +- `sortKey` — `created_at` (default), `updated_at`, or `recency_at`. +- `recencyAt` is initialized when the thread is created and advances when a turn starts. Unlike `updatedAt`, background output and other persisted mutations do not advance it. - `sortDirection` — `desc` (default) or `asc`. - `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers. - `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`). @@ -386,8 +387,8 @@ Example: } } { "id": 20, "result": { "data": [ - { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "status": { "type": "notLoaded" }, "agentNickname": "Atlas", "agentRole": "explorer" }, - { "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000, "status": { "type": "notLoaded" } } + { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "recencyAt": 1730831111, "status": { "type": "notLoaded" }, "agentNickname": "Atlas", "agentRole": "explorer" }, + { "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000, "recencyAt": 1730750000, "status": { "type": "notLoaded" } } ], "nextCursor": "opaque-token-or-null", "backwardsCursor": "opaque-token-or-null" diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 868cef81fc..f71401e95c 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2259,6 +2259,7 @@ mod tests { reasoning_effort: None, created_at, updated_at: created_at, + recency_at: created_at, archived_at: None, cwd: test_path_buf("/tmp").abs().into(), cli_version: "0.0.0".to_string(), diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index a5299f2c72..907b05a03b 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1897,6 +1897,7 @@ impl ThreadRequestProcessor { let store_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) { ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt, + ThreadSortKey::RecencyAt => StoreThreadSortKey::RecencyAt, }; let sort_direction = sort_direction.unwrap_or(SortDirection::Desc); let (stored_threads, next_cursor) = self @@ -1978,6 +1979,7 @@ impl ThreadRequestProcessor { let store_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) { ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt, + ThreadSortKey::RecencyAt => StoreThreadSortKey::RecencyAt, }; let store_sort_direction = sort_direction.unwrap_or(SortDirection::Desc); let (allowed_sources, source_kind_filter) = compute_source_filters(source_kinds); @@ -3695,6 +3697,7 @@ fn thread_backwards_cursor_for_sort_key( let timestamp = match sort_key { StoreThreadSortKey::CreatedAt => thread.created_at, StoreThreadSortKey::UpdatedAt => thread.updated_at, + StoreThreadSortKey::RecencyAt => thread.recency_at, }; // The state DB stores unique millisecond timestamps. Offset the reverse cursor by one // millisecond so the opposite-direction query includes the page anchor. @@ -4139,6 +4142,7 @@ pub(crate) fn thread_from_stored_thread( }, created_at: thread.created_at.timestamp(), updated_at: thread.updated_at.timestamp(), + recency_at: Some(thread.recency_at.timestamp()), status: ThreadStatus::NotLoaded, path, cwd, @@ -4344,6 +4348,7 @@ fn build_thread_from_snapshot( model_provider: config_snapshot.model_provider_id.clone(), created_at: now, updated_at: now, + recency_at: Some(now), status: ThreadStatus::NotLoaded, path, cwd: config_snapshot.cwd().clone(), diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 42711e8570..1f333f1eb1 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -478,6 +478,7 @@ mod thread_processor_behavior_tests { reasoning_effort: None, created_at: created_at.with_timezone(&Utc), updated_at: updated_at.with_timezone(&Utc), + recency_at: updated_at.with_timezone(&Utc), archived_at: None, cwd: PathBuf::from("/tmp"), cli_version: "0.0.0".to_string(), diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index e970a88a9f..110d6d5cbf 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -178,6 +178,7 @@ mod tests { model_provider: "mock_provider".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index 0fb1320055..67d2d9eabc 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -320,6 +320,7 @@ pub(crate) fn summary_to_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), + recency_at: updated_at.map(|dt| dt.timestamp()), status: ThreadStatus::NotLoaded, path: (!path.as_os_str().is_empty()).then_some(path), cwd, diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index 6d66a32d6a..a5997a58f5 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -897,6 +897,7 @@ mod tests { model_provider: "mock-provider".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: ThreadStatus::NotLoaded, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 3f6adc167e..9bdff3bb8b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -1689,6 +1689,89 @@ async fn thread_list_sort_updated_at_orders_by_mtime() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_list_sort_recency_at_uses_state_db_order_with_provider_filter() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let id_old = create_fake_rollout( + codex_home.path(), + "2025-01-01T10-00-00", + "2025-01-01T10:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + let id_new = create_fake_rollout( + codex_home.path(), + "2025-01-01T11-00-00", + "2025-01-01T11:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-01-01T10-00-00", &id_old).as_path(), + "2025-01-03T00:00:00Z", + )?; + + let state_db = + codex_state::StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()) + .await?; + state_db + .mark_backfill_complete(/*last_watermark*/ None) + .await?; + let rollout_config = codex_rollout::RolloutConfig { + codex_home: codex_home.path().to_path_buf(), + sqlite_home: codex_home.path().to_path_buf(), + cwd: codex_home.path().to_path_buf(), + model_provider_id: "mock_provider".to_string(), + generate_memories: false, + }; + codex_core::RolloutRecorder::list_threads( + Some(state_db.clone()), + &rollout_config, + /*page_size*/ 10, + /*cursor*/ None, + codex_core::ThreadSortKey::CreatedAt, + codex_core::SortDirection::Desc, + codex_core::INTERACTIVE_SESSION_SOURCES.as_slice(), + /*model_providers*/ None, + /*cwd_filters*/ None, + "mock_provider", + /*search_term*/ None, + ) + .await?; + state_db + .touch_thread_recency_at( + ThreadId::from_string(&id_new)?, + DateTime::::from_timestamp(1_800_000_000, 0).expect("timestamp"), + ) + .await?; + + let mut mcp = init_mcp(codex_home.path()).await?; + let ThreadListResponse { data, .. } = list_threads_with_sort( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["mock_provider".to_string()]), + /*source_kinds*/ None, + Some(ThreadSortKey::RecencyAt), + /*archived*/ None, + ) + .await?; + + assert_eq!( + data.iter() + .map(|thread| thread.id.as_str()) + .collect::>(), + vec![id_new.as_str(), id_old.as_str()] + ); + assert!(data.iter().all(|thread| thread.recency_at.is_some())); + + Ok(()) +} + #[tokio::test] async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> { let codex_home = TempDir::new()?; 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 e67184bf01..03e30e373e 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -2200,6 +2200,7 @@ async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; assert_eq!(thread.updated_at, before_resume.updated_at); + assert_eq!(thread.recency_at, before_resume.recency_at); assert_eq!(thread.status, ThreadStatus::Idle); let after_modified = std::fs::metadata(&rollout.rollout_file_path)?.modified()?; @@ -2234,7 +2235,7 @@ async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { - thread_id, + thread_id: thread_id.clone(), input: vec![UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -2247,6 +2248,29 @@ async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), ) .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread_id.clone(), + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { + thread: after_turn_start, + .. + } = to_response::(read_resp)?; + assert!(after_turn_start.recency_at > before_resume.recency_at); + timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index f04282a65f..352d6ae8e4 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -47,6 +47,10 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre .timestamp_opt(1_709_251_200, 0) .single() .expect("valid timestamp"), + recency_at: Utc + .timestamp_opt(1_709_251_200, 0) + .single() + .expect("valid timestamp"), archived_at: None, cwd: PathBuf::from(cwd), cli_version: "test".to_string(), diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 6b27d01116..526c5fc487 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -342,6 +342,7 @@ fn turn_items_for_thread_returns_matching_turn_items() { model_provider: "openai".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/project").abs(), @@ -760,6 +761,7 @@ fn sample_thread_start_response() -> ThreadStartResponse { model_provider: "openai".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: codex_app_server_protocol::ThreadStatus::Idle, path: Some(PathBuf::from("/tmp/rollout.jsonl")), cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/rollout/src/list.rs b/codex-rs/rollout/src/list.rs index 845145aaef..f5013e7aae 100644 --- a/codex-rs/rollout/src/list.rs +++ b/codex-rs/rollout/src/list.rs @@ -77,6 +77,8 @@ pub struct ThreadItem { pub created_at: Option, /// RFC3339 timestamp string for the most recent update (from file mtime). pub updated_at: Option, + /// RFC3339 timestamp string used for product recency ordering. + pub recency_at: Option, } #[allow(dead_code)] @@ -115,6 +117,7 @@ const USER_EVENT_SCAN_LIMIT: usize = 200; pub enum ThreadSortKey { CreatedAt, UpdatedAt, + RecencyAt, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -137,20 +140,29 @@ pub struct ThreadListConfig<'a> { pub layout: ThreadListLayout, } -/// Pagination cursor identifying the timestamp of the last item in a page. +/// Pagination cursor identifying the last item in a page. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Cursor { ts: OffsetDateTime, + id: Option, } impl Cursor { - fn new(ts: OffsetDateTime) -> Self { - Self { ts } + pub(crate) fn new(ts: OffsetDateTime) -> Self { + Self { ts, id: None } + } + + pub(crate) fn with_thread_id(ts: OffsetDateTime, id: ThreadId) -> Self { + Self { ts, id: Some(id) } } pub(crate) fn timestamp(&self) -> OffsetDateTime { self.ts } + + pub(crate) fn thread_id(&self) -> Option { + self.id + } } /// Keeps track of where a paginated listing left off. As the file scan goes newest -> oldest, @@ -287,7 +299,10 @@ impl serde::Serialize for Cursor { .ts .format(&Rfc3339) .map_err(|e| serde::ser::Error::custom(format!("format error: {e}")))?; - serializer.serialize_str(&ts_str) + match self.id { + Some(id) => serializer.serialize_str(&format!("{ts_str}|{id}")), + None => serializer.serialize_str(&ts_str), + } } } @@ -308,7 +323,7 @@ impl From for Cursor { .timestamp_nanos_opt() .and_then(|nanos| OffsetDateTime::from_unix_timestamp_nanos(nanos as i128).ok()) .unwrap_or(OffsetDateTime::UNIX_EPOCH); - Self::new(ts) + Self { ts, id: anchor.id } } } @@ -419,7 +434,7 @@ async fn traverse_directories_for_paths( ) .await } - ThreadSortKey::UpdatedAt => { + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => { traverse_directories_for_paths_updated( root, page_size, @@ -454,7 +469,7 @@ async fn traverse_flat_paths( ) .await } - ThreadSortKey::UpdatedAt => { + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => { traverse_flat_paths_updated( root, page_size, @@ -702,35 +717,48 @@ async fn traverse_flat_paths_updated( }) } -/// Pagination cursor token format: an RFC3339 timestamp. +/// Pagination cursor token format: an RFC3339 timestamp with an optional thread ID tie-breaker. pub fn parse_cursor(token: &str) -> Option { - if token.contains('|') { - return None; - } + let (timestamp, id) = match token.rsplit_once('|') { + Some((timestamp, id)) => (timestamp, Some(ThreadId::from_string(id).ok()?)), + None => (token, None), + }; - let ts = OffsetDateTime::parse(token, &Rfc3339).ok().or_else(|| { - let format: &[FormatItem] = - format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); - PrimitiveDateTime::parse(token, format) - .ok() - .map(PrimitiveDateTime::assume_utc) - })?; + let ts = OffsetDateTime::parse(timestamp, &Rfc3339) + .ok() + .or_else(|| { + let format: &[FormatItem] = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); + PrimitiveDateTime::parse(timestamp, format) + .ok() + .map(PrimitiveDateTime::assume_utc) + })?; - Some(Cursor::new(ts)) + Some(Cursor { ts, id }) } fn build_next_cursor(items: &[ThreadItem], sort_key: ThreadSortKey) -> Option { let last = items.last()?; let file_name = last.path.file_name()?.to_string_lossy(); - let (created_ts, _id) = parse_timestamp_uuid_from_filename(&file_name)?; + let (created_ts, id) = parse_timestamp_uuid_from_filename(&file_name)?; let ts = match sort_key { ThreadSortKey::CreatedAt => created_ts, ThreadSortKey::UpdatedAt => { let updated_at = last.updated_at.as_deref()?; OffsetDateTime::parse(updated_at, &Rfc3339).ok()? } + ThreadSortKey::RecencyAt => { + let recency_at = last.recency_at.as_deref().or(last.updated_at.as_deref())?; + OffsetDateTime::parse(recency_at, &Rfc3339).ok()? + } }; - Some(Cursor::new(ts)) + match sort_key { + ThreadSortKey::RecencyAt => Some(Cursor::with_thread_id( + ts, + ThreadId::from_string(&id.to_string()).ok()?, + )), + ThreadSortKey::CreatedAt | ThreadSortKey::UpdatedAt => Some(Cursor::new(ts)), + } } async fn build_thread_item( @@ -806,6 +834,7 @@ async fn build_thread_item( model_provider, cli_version, created_at, + recency_at: summary_updated_at.clone(), updated_at: summary_updated_at, }); } diff --git a/codex-rs/rollout/src/metadata.rs b/codex-rs/rollout/src/metadata.rs index 6eabd21ae6..61d89f09d7 100644 --- a/codex-rs/rollout/src/metadata.rs +++ b/codex-rs/rollout/src/metadata.rs @@ -115,6 +115,7 @@ pub async fn extract_metadata_from_rollout( } if let Some(updated_at) = file_modified_time_utc(rollout_path).await { metadata.updated_at = updated_at; + metadata.recency_at = updated_at; } Ok(ExtractionOutcome { metadata, diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index 43aa62e646..e28ae84df7 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -72,6 +72,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() { apply_rollout_item(&mut expected, &rollout_line.item, "openai") .expect("rollout item should apply"); expected.updated_at = file_modified_time_utc(&path).await.expect("mtime"); + expected.recency_at = expected.updated_at; assert_eq!(outcome.metadata, expected); assert_eq!(outcome.memory_mode, None); diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index a3e5c0514b..113ee372e1 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -543,6 +543,27 @@ impl RolloutRecorder { ) .await; } + if sort_key == ThreadSortKey::RecencyAt { + if let Some(repaired_db_page) = state_db::list_threads_db( + state_db_ctx.as_deref(), + codex_home, + page_size, + cursor, + sort_key, + sort_direction, + allowed_sources, + model_providers, + cwd_filters, + /*parent_thread_id*/ None, + archived, + search_term, + ) + .await + { + return Ok(repaired_db_page.into()); + } + return Ok(db_page.into()); + } codex_state::record_fallback( "list_threads", "metadata_filter", @@ -985,6 +1006,11 @@ fn truncate_fs_page( let cursor_token = match sort_key { ThreadSortKey::CreatedAt => created_at.format(&Rfc3339).ok()?, ThreadSortKey::UpdatedAt => item.updated_at.as_deref()?.to_string(), + ThreadSortKey::RecencyAt => item + .recency_at + .as_deref() + .or(item.updated_at.as_deref())? + .to_string(), }; parse_cursor(cursor_token.as_str()) }); @@ -1049,6 +1075,7 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt cli_version, created_at, updated_at, + recency_at, } = state_item; if item.first_user_message.is_none() { @@ -1093,6 +1120,9 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt if item.updated_at.is_none() { item.updated_at = updated_at; } + if recency_at.is_some() { + item.recency_at = recency_at; + } } #[allow(clippy::too_many_arguments)] @@ -1269,9 +1299,20 @@ async fn list_threads_from_files_asc( .collect::>(); if let Some(cursor) = cursor { - let anchor = cursor.timestamp(); - all_items - .retain(|item| thread_item_sort_key(item, sort_key).is_some_and(|key| key.0 > anchor)); + let anchor = ( + cursor.timestamp(), + cursor + .thread_id() + .and_then(|id| uuid::Uuid::parse_str(&id.to_string()).ok()), + ); + all_items.retain(|item| { + thread_item_sort_key(item, sort_key).is_some_and(|key| match anchor.1 { + Some(anchor_id) if sort_key == ThreadSortKey::RecencyAt => { + key > (anchor.0, anchor_id) + } + _ => key.0 > anchor.0, + }) + }); } let more_matches_available = all_items.len() > page_size || reached_scan_cap; @@ -1329,14 +1370,27 @@ fn thread_item_sort_key( let updated_at = item.updated_at.as_deref().or(item.created_at.as_deref())?; OffsetDateTime::parse(updated_at, &Rfc3339).ok()? } + ThreadSortKey::RecencyAt => { + let recency_at = item + .recency_at + .as_deref() + .or(item.updated_at.as_deref()) + .or(item.created_at.as_deref())?; + OffsetDateTime::parse(recency_at, &Rfc3339).ok()? + } }; Some((timestamp, id)) } fn cursor_from_thread_item(item: &ThreadItem, sort_key: ThreadSortKey) -> Option { - let (timestamp, _id) = thread_item_sort_key(item, sort_key)?; - let cursor_token = timestamp.format(&Rfc3339).ok()?; - parse_cursor(cursor_token.as_str()) + let (timestamp, id) = thread_item_sort_key(item, sort_key)?; + match sort_key { + ThreadSortKey::RecencyAt => Some(Cursor::with_thread_id( + timestamp, + ThreadId::from_string(&id.to_string()).ok()?, + )), + ThreadSortKey::CreatedAt | ThreadSortKey::UpdatedAt => Some(Cursor::new(timestamp)), + } } struct LogFileInfo { @@ -1725,6 +1779,7 @@ fn thread_item_from_state_metadata(item: codex_state::ThreadMetadata) -> ThreadI cli_version: Some(item.cli_version), created_at: Some(item.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)), updated_at: Some(item.updated_at.to_rfc3339_opts(SecondsFormat::Millis, true)), + recency_at: Some(item.recency_at.to_rfc3339_opts(SecondsFormat::Millis, true)), } } diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index c49a477944..2558750f15 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -984,6 +984,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi model_provider: None, cli_version: None, created_at: None, + recency_at: Some("2025-01-03T15:59:00.000Z".to_string()), updated_at: None, }; let state_item = ThreadItem { @@ -1002,6 +1003,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi model_provider: Some("state-provider".to_string()), cli_version: Some("state-version".to_string()), created_at: Some("2025-01-03T16:00:00Z".to_string()), + recency_at: Some("2025-01-03T16:00:30.001Z".to_string()), updated_at: Some("2025-01-03T16:01:02.003Z".to_string()), }; @@ -1027,6 +1029,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi assert_eq!(item.model_provider.as_deref(), Some("state-provider")); assert_eq!(item.cli_version.as_deref(), Some("state-version")); assert_eq!(item.created_at.as_deref(), Some("2025-01-03T16:00:00Z")); + assert_eq!(item.recency_at.as_deref(), Some("2025-01-03T16:00:30.001Z")); assert_eq!(item.updated_at.as_deref(), Some("2025-01-03T16:01:02.003Z")); } diff --git a/codex-rs/rollout/src/state_db.rs b/codex-rs/rollout/src/state_db.rs index ab93305fa6..96fbf7af1c 100644 --- a/codex-rs/rollout/src/state_db.rs +++ b/codex-rs/rollout/src/state_db.rs @@ -290,7 +290,10 @@ fn cursor_to_anchor(cursor: Option<&Cursor>) -> Option { let millis = cursor.timestamp().unix_timestamp_nanos() / 1_000_000; let millis = i64::try_from(millis).ok()?; let ts = chrono::DateTime::::from_timestamp_millis(millis)?; - Some(codex_state::Anchor { ts }) + Some(codex_state::Anchor { + ts, + id: cursor.thread_id(), + }) } pub fn normalize_cwd_for_state_db(cwd: &Path) -> PathBuf { @@ -336,6 +339,7 @@ pub async fn list_thread_ids_db( match sort_key { ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt, + ThreadSortKey::RecencyAt => codex_state::SortKey::RecencyAt, }, allowed_sources.as_slice(), model_providers.as_deref(), @@ -401,6 +405,7 @@ pub async fn list_threads_db( sort_key: match sort_key { ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt, + ThreadSortKey::RecencyAt => codex_state::SortKey::RecencyAt, }, sort_direction: match sort_direction { SortDirection::Asc => codex_state::SortDirection::Asc, diff --git a/codex-rs/rollout/src/state_db_tests.rs b/codex-rs/rollout/src/state_db_tests.rs index a84ede199b..ba92ca27f1 100644 --- a/codex-rs/rollout/src/state_db_tests.rs +++ b/codex-rs/rollout/src/state_db_tests.rs @@ -28,6 +28,22 @@ fn cursor_to_anchor_normalizes_timestamp_format() { .expect("nanosecond"); assert_eq!(anchor.ts, expected_ts); + assert_eq!(anchor.id, None); +} + +#[test] +fn cursor_to_anchor_preserves_recency_tie_breaker() { + let id = ThreadId::from_string("00000000-0000-0000-0000-000000000123") + .expect("thread id should parse"); + let token = format!("2026-01-27T12:34:56Z|{id}"); + let cursor = parse_cursor(&token).expect("cursor should parse"); + let anchor = cursor_to_anchor(Some(&cursor)).expect("anchor should parse"); + + assert_eq!(anchor.id, Some(id)); + assert_eq!( + serde_json::to_string(&cursor).expect("cursor should serialize"), + format!("\"{token}\"") + ); } #[tokio::test] diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index 3c1fc8bb57..0e3ae912dd 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -612,6 +612,7 @@ async fn test_list_conversations_latest_first() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-01-03T12-00-00".into()), + recency_at: updated_times.first().cloned().flatten(), updated_at: updated_times.first().cloned().flatten(), }, ThreadItem { @@ -630,6 +631,7 @@ async fn test_list_conversations_latest_first() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-01-02T12-00-00".into()), + recency_at: updated_times.get(1).cloned().flatten(), updated_at: updated_times.get(1).cloned().flatten(), }, ThreadItem { @@ -648,6 +650,7 @@ async fn test_list_conversations_latest_first() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-01-01T12-00-00".into()), + recency_at: updated_times.get(2).cloned().flatten(), updated_at: updated_times.get(2).cloned().flatten(), }, ], @@ -759,6 +762,7 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-05T09-00-00".into()), + recency_at: updated_page1.first().cloned().flatten(), updated_at: updated_page1.first().cloned().flatten(), }, ThreadItem { @@ -777,6 +781,7 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-04T09-00-00".into()), + recency_at: updated_page1.get(1).cloned().flatten(), updated_at: updated_page1.get(1).cloned().flatten(), }, ], @@ -831,6 +836,7 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-03T09-00-00".into()), + recency_at: updated_page2.first().cloned().flatten(), updated_at: updated_page2.first().cloned().flatten(), }, ThreadItem { @@ -849,6 +855,7 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-02T09-00-00".into()), + recency_at: updated_page2.get(1).cloned().flatten(), updated_at: updated_page2.get(1).cloned().flatten(), }, ], @@ -895,6 +902,7 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-01T09-00-00".into()), + recency_at: updated_page3.first().cloned().flatten(), updated_at: updated_page3.first().cloned().flatten(), }], next_cursor: None, @@ -1066,6 +1074,7 @@ async fn test_get_thread_contents() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.into()), + recency_at: page.items[0].updated_at.clone(), updated_at: page.items[0].updated_at.clone(), }], next_cursor: None, @@ -1421,6 +1430,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.to_string()), + recency_at: updated_page1.first().cloned().flatten(), updated_at: updated_page1.first().cloned().flatten(), }, ThreadItem { @@ -1439,6 +1449,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.to_string()), + recency_at: updated_page1.get(1).cloned().flatten(), updated_at: updated_page1.get(1).cloned().flatten(), }, ], diff --git a/codex-rs/state/migrations/0038_threads_recency_at.sql b/codex-rs/state/migrations/0038_threads_recency_at.sql new file mode 100644 index 0000000000..ccbf79f05f --- /dev/null +++ b/codex-rs/state/migrations/0038_threads_recency_at.sql @@ -0,0 +1,28 @@ +ALTER TABLE threads ADD COLUMN recency_at INTEGER NOT NULL DEFAULT 0; +ALTER TABLE threads ADD COLUMN recency_at_ms INTEGER NOT NULL DEFAULT 0; + +UPDATE threads +SET recency_at = updated_at, + recency_at_ms = updated_at_ms; + +-- Older binaries can open databases migrated by newer binaries. Seed recency +-- when one of those binaries inserts a thread without the new columns. +CREATE TRIGGER threads_recency_at_after_insert +AFTER INSERT ON threads +WHEN NEW.recency_at_ms = 0 +BEGIN + UPDATE threads + SET recency_at = NEW.updated_at, + recency_at_ms = COALESCE(NEW.updated_at_ms, NEW.updated_at * 1000) + WHERE id = NEW.id; +END; + +CREATE INDEX idx_threads_recency_at_ms + ON threads(recency_at_ms DESC, id DESC); + +CREATE INDEX idx_threads_archived_cwd_recency_at_ms + ON threads(archived, cwd, recency_at_ms DESC, id DESC); + +CREATE INDEX idx_threads_visible_recency_at_ms + ON threads(archived, recency_at_ms DESC, id DESC) + WHERE preview <> ''; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 2254b4da89..f2745a553a 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -563,6 +563,7 @@ mod tests { rollout_path: PathBuf::from("/tmp/a.jsonl"), created_at, updated_at: created_at, + recency_at: created_at, source: "cli".to_string(), thread_source: None, agent_path: None, diff --git a/codex-rs/state/src/migrations.rs b/codex-rs/state/src/migrations.rs index 0e12f17c45..8637544fae 100644 --- a/codex-rs/state/src/migrations.rs +++ b/codex-rs/state/src/migrations.rs @@ -39,3 +39,7 @@ pub(crate) fn runtime_goals_migrator() -> Migrator { pub(crate) fn runtime_memories_migrator() -> Migrator { runtime_migrator(&MEMORIES_MIGRATOR) } + +#[cfg(test)] +#[path = "migrations_tests.rs"] +mod tests; diff --git a/codex-rs/state/src/migrations_tests.rs b/codex-rs/state/src/migrations_tests.rs new file mode 100644 index 0000000000..f7f137a5e2 --- /dev/null +++ b/codex-rs/state/src/migrations_tests.rs @@ -0,0 +1,129 @@ +use std::borrow::Cow; + +use sqlx::Row; +use sqlx::migrate::Migrator; +use sqlx::sqlite::SqlitePoolOptions; + +use super::STATE_MIGRATOR; + +fn migrator_through(version: i64) -> Migrator { + Migrator { + migrations: Cow::Owned( + STATE_MIGRATOR + .migrations + .iter() + .filter(|migration| migration.version <= version) + .cloned() + .collect(), + ), + ignore_missing: STATE_MIGRATOR.ignore_missing, + locking: STATE_MIGRATOR.locking, + table_name: STATE_MIGRATOR.table_name.clone(), + create_schemas: STATE_MIGRATOR.create_schemas.clone(), + no_tx: STATE_MIGRATOR.no_tx, + } +} + +#[tokio::test] +async fn recency_migration_backfills_and_seeds_old_binary_inserts() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("in-memory database should open"); + migrator_through(/*version*/ 37) + .run(&pool) + .await + .expect("pre-recency migrations should apply"); + + sqlx::query( + r#" +INSERT INTO threads ( + id, + rollout_path, + created_at, + updated_at, + created_at_ms, + updated_at_ms, + source, + model_provider, + cwd, + title, + sandbox_policy, + approval_mode +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind("00000000-0000-0000-0000-000000000001") + .bind("/tmp/first.jsonl") + .bind(1_700_000_000_i64) + .bind(1_700_000_100_i64) + .bind(1_700_000_000_123_i64) + .bind(1_700_000_100_456_i64) + .bind("cli") + .bind("openai") + .bind("/tmp") + .bind("") + .bind("read-only") + .bind("on-request") + .execute(&pool) + .await + .expect("legacy row should insert"); + + STATE_MIGRATOR + .run(&pool) + .await + .expect("recency migration should apply"); + + let backfilled = sqlx::query( + "SELECT updated_at, updated_at_ms, recency_at, recency_at_ms FROM threads WHERE id = ?", + ) + .bind("00000000-0000-0000-0000-000000000001") + .fetch_one(&pool) + .await + .expect("backfilled row should load"); + assert_eq!(backfilled.get::("recency_at"), 1_700_000_100); + assert_eq!(backfilled.get::("recency_at_ms"), 1_700_000_100_456); + + sqlx::query( + r#" +INSERT INTO threads ( + id, + rollout_path, + created_at, + updated_at, + created_at_ms, + updated_at_ms, + source, + model_provider, + cwd, + title, + sandbox_policy, + approval_mode +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind("00000000-0000-0000-0000-000000000002") + .bind("/tmp/second.jsonl") + .bind(1_700_000_200_i64) + .bind(1_700_000_300_i64) + .bind(1_700_000_200_123_i64) + .bind(1_700_000_300_456_i64) + .bind("cli") + .bind("openai") + .bind("/tmp") + .bind("") + .bind("read-only") + .bind("on-request") + .execute(&pool) + .await + .expect("old-binary row should insert"); + + let seeded = sqlx::query("SELECT recency_at, recency_at_ms FROM threads WHERE id = ?") + .bind("00000000-0000-0000-0000-000000000002") + .fetch_one(&pool) + .await + .expect("old-binary row should load"); + assert_eq!(seeded.get::("recency_at"), 1_700_000_300); + assert_eq!(seeded.get::("recency_at_ms"), 1_700_000_300_456); +} diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index a30f7e479b..c25c458986 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -18,6 +18,8 @@ pub enum SortKey { CreatedAt, /// Sort by the thread's last update timestamp. UpdatedAt, + /// Sort by the thread's product recency timestamp. + RecencyAt, } /// Sort direction to use when listing threads. @@ -32,6 +34,8 @@ pub enum SortDirection { pub struct Anchor { /// The timestamp component of the anchor. pub ts: DateTime, + /// The thread ID component used to disambiguate equal recency timestamps. + pub id: Option, } /// A single page of thread metadata results. @@ -67,6 +71,8 @@ pub struct ThreadMetadata { pub created_at: DateTime, /// The last update timestamp. pub updated_at: DateTime, + /// The product recency timestamp. + pub recency_at: DateTime, /// The session source (stringified enum). pub source: String, /// Optional analytics source classification for this thread. @@ -120,6 +126,8 @@ pub struct ThreadMetadataBuilder { pub created_at: DateTime, /// The last update timestamp, if known. pub updated_at: Option>, + /// The product recency timestamp, if known. + pub recency_at: Option>, /// The session source. pub source: SessionSource, /// Optional analytics source classification for this thread. @@ -163,6 +171,7 @@ impl ThreadMetadataBuilder { rollout_path, created_at, updated_at: None, + recency_at: None, source, thread_source: None, agent_nickname: None, @@ -190,11 +199,16 @@ impl ThreadMetadataBuilder { .updated_at .map(canonicalize_datetime) .unwrap_or(created_at); + let recency_at = self + .recency_at + .map(canonicalize_datetime) + .unwrap_or(updated_at); ThreadMetadata { id: self.id, rollout_path: self.rollout_path.clone(), created_at, updated_at, + recency_at, source, thread_source: self.thread_source.clone(), agent_nickname: self.agent_nickname.clone(), @@ -340,6 +354,7 @@ pub(crate) struct ThreadRow { rollout_path: String, created_at: i64, updated_at: i64, + recency_at: i64, source: String, thread_source: Option, agent_nickname: Option, @@ -369,6 +384,7 @@ impl ThreadRow { rollout_path: row.try_get("rollout_path")?, created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, + recency_at: row.try_get("recency_at")?, source: row.try_get("source")?, thread_source: row.try_get("thread_source")?, agent_nickname: row.try_get("agent_nickname")?, @@ -402,6 +418,7 @@ impl TryFrom for ThreadMetadata { rollout_path, created_at, updated_at, + recency_at, source, thread_source, agent_nickname, @@ -432,6 +449,7 @@ impl TryFrom for ThreadMetadata { rollout_path: PathBuf::from(rollout_path), created_at: epoch_millis_to_datetime(created_at)?, updated_at: epoch_millis_to_datetime(updated_at)?, + recency_at: epoch_millis_to_datetime(recency_at)?, source, thread_source, agent_nickname, @@ -461,8 +479,12 @@ pub(crate) fn anchor_from_item(item: &ThreadMetadata, sort_key: SortKey) -> Opti let ts = match sort_key { SortKey::CreatedAt => item.created_at, SortKey::UpdatedAt => item.updated_at, + SortKey::RecencyAt => item.recency_at, }; - Some(Anchor { ts }) + Some(Anchor { + ts, + id: (sort_key == SortKey::RecencyAt).then_some(item.id), + }) } pub(crate) fn datetime_to_epoch_millis(dt: DateTime) -> i64 { @@ -519,6 +541,7 @@ mod tests { rollout_path: "/tmp/rollout-123.jsonl".to_string(), created_at: 1_700_000_000, updated_at: 1_700_000_100, + recency_at: 1_700_000_100, source: "cli".to_string(), thread_source: None, agent_nickname: None, @@ -549,6 +572,7 @@ mod tests { rollout_path: PathBuf::from("/tmp/rollout-123.jsonl"), created_at: DateTime::::from_timestamp(1_700_000_000, 0).expect("timestamp"), updated_at: DateTime::::from_timestamp(1_700_000_100, 0).expect("timestamp"), + recency_at: DateTime::::from_timestamp(1_700_000_100, 0).expect("timestamp"), source: "cli".to_string(), thread_source: None, agent_nickname: None, diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 0e5dc85586..2ef5248896 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -160,6 +160,7 @@ pub struct StateRuntime { thread_goals: GoalStore, memories: MemoryStore, thread_updated_at_millis: Arc, + thread_recency_at_millis: Arc, } impl StateRuntime { @@ -262,32 +263,36 @@ impl StateRuntime { return Err(err); } let started = Instant::now(); - let thread_updated_at_millis_result: anyhow::Result> = - sqlx::query_scalar("SELECT MAX(threads.updated_at_ms) FROM threads") - .fetch_one(pool.as_ref()) - .await - .map_err(anyhow::Error::from); + let thread_timestamp_millis_result: anyhow::Result<(Option, Option)> = + sqlx::query_as( + "SELECT MAX(threads.updated_at_ms), MAX(threads.recency_at_ms) FROM threads", + ) + .fetch_one(pool.as_ref()) + .await + .map_err(anyhow::Error::from); crate::telemetry::record_init_result( telemetry_override, DbKind::State, "post_init_query", started.elapsed(), - &thread_updated_at_millis_result, + &thread_timestamp_millis_result, ); - let thread_updated_at_millis = match thread_updated_at_millis_result { - Ok(value) => value, - Err(err) => { - close_sqlite_pools(&[ - pool.as_ref(), - logs_pool.as_ref(), - goals_pool.as_ref(), - memories_pool.as_ref(), - ]) - .await; - return Err(err); - } - }; + let (thread_updated_at_millis, thread_recency_at_millis) = + match thread_timestamp_millis_result { + Ok(value) => value, + Err(err) => { + close_sqlite_pools(&[ + pool.as_ref(), + logs_pool.as_ref(), + goals_pool.as_ref(), + memories_pool.as_ref(), + ]) + .await; + return Err(err); + } + }; let thread_updated_at_millis = thread_updated_at_millis.unwrap_or(0); + let thread_recency_at_millis = thread_recency_at_millis.unwrap_or(0); let runtime = Arc::new(Self { thread_goals: GoalStore::new(Arc::clone(&goals_pool)), memories: MemoryStore::new(Arc::clone(&memories_pool), Arc::clone(&pool)), @@ -296,6 +301,7 @@ impl StateRuntime { codex_home, default_provider, thread_updated_at_millis: Arc::new(AtomicI64::new(thread_updated_at_millis)), + thread_recency_at_millis: Arc::new(AtomicI64::new(thread_recency_at_millis)), }); if let Err(err) = runtime.run_logs_startup_maintenance().await { warn!( diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index 895c254350..9b2c337f5f 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -175,6 +175,7 @@ SELECT threads.rollout_path, threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, + threads.recency_at_ms AS recency_at, threads.source, threads.thread_source, threads.agent_path, @@ -545,6 +546,7 @@ SELECT threads.rollout_path, threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, + threads.recency_at_ms AS recency_at, threads.source, threads.thread_source, threads.agent_nickname, diff --git a/codex-rs/state/src/runtime/test_support.rs b/codex-rs/state/src/runtime/test_support.rs index 848a193335..c029a0ee2d 100644 --- a/codex-rs/state/src/runtime/test_support.rs +++ b/codex-rs/state/src/runtime/test_support.rs @@ -47,6 +47,7 @@ pub(super) fn test_thread_metadata( rollout_path: codex_home.join(format!("rollout-{thread_id}.jsonl")), created_at: now, updated_at: now, + recency_at: now, source: "cli".to_string(), thread_source: None, agent_nickname: None, diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 81a490a0cb..7bf3544373 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -1,6 +1,7 @@ use super::*; use crate::SortDirection; use codex_protocol::protocol::SessionSource; +use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; impl StateRuntime { @@ -12,6 +13,7 @@ SELECT threads.rollout_path, threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, + threads.recency_at_ms AS recency_at, threads.source, threads.thread_source, threads.agent_nickname, @@ -498,6 +500,7 @@ ON CONFLICT(child_thread_id) DO NOTHING metadata: &crate::ThreadMetadata, ) -> anyhow::Result { let updated_at = self.allocate_thread_updated_at(metadata.updated_at)?; + let recency_at = self.allocate_thread_recency_at(metadata.recency_at)?; let preview = metadata_preview(metadata); let result = sqlx::query( r#" @@ -506,8 +509,10 @@ INSERT INTO threads ( rollout_path, created_at, updated_at, + recency_at, created_at_ms, updated_at_ms, + recency_at_ms, source, thread_source, agent_nickname, @@ -530,7 +535,7 @@ INSERT INTO threads ( git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING "#, ) @@ -538,8 +543,10 @@ ON CONFLICT(id) DO NOTHING .bind(metadata.rollout_path.display().to_string()) .bind(datetime_to_epoch_seconds(metadata.created_at)) .bind(datetime_to_epoch_seconds(updated_at)) + .bind(datetime_to_epoch_seconds(recency_at)) .bind(datetime_to_epoch_millis(metadata.created_at)) .bind(datetime_to_epoch_millis(updated_at)) + .bind(datetime_to_epoch_millis(recency_at)) .bind(metadata.source.as_str()) .bind( metadata @@ -621,6 +628,32 @@ ON CONFLICT(id) DO NOTHING Ok(result.rows_affected() > 0) } + pub async fn touch_thread_recency_at( + &self, + thread_id: ThreadId, + recency_at: DateTime, + ) -> anyhow::Result { + let recency_at = self.allocate_thread_recency_at(recency_at)?; + let recency_at_seconds = datetime_to_epoch_seconds(recency_at); + let recency_at_millis = datetime_to_epoch_millis(recency_at); + let result = sqlx::query( + r#" +UPDATE threads +SET + recency_at = MAX(?, MAX(?, recency_at_ms + 1) / 1000), + recency_at_ms = MAX(?, recency_at_ms + 1) +WHERE id = ? + "#, + ) + .bind(recency_at_seconds) + .bind(recency_at_millis) + .bind(recency_at_millis) + .bind(thread_id.to_string()) + .execute(self.pool.as_ref()) + .await?; + Ok(result.rows_affected() > 0) + } + /// Allocate a persisted `updated_at` value for thread-list cursor ordering. /// /// We keep a process-local high-water mark so hot rollout writes can get unique, @@ -631,42 +664,56 @@ ON CONFLICT(id) DO NOTHING &self, updated_at: DateTime, ) -> anyhow::Result> { - let candidate = datetime_to_epoch_millis(updated_at); - let allocated = loop { - let current = self.thread_updated_at_millis.load(Ordering::Relaxed); - - // New wall-clock time: advance the process-local high-water mark and use it as-is. - if candidate > current { - if self - .thread_updated_at_millis - .compare_exchange(current, candidate, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - break candidate; - } - continue; - } - - // Older timestamps come from backfill/repair paths that preserve rollout mtimes. - // Do not drag historical rows forward just because this process has seen newer writes. - if candidate.saturating_add(1000) <= current { - break candidate; - } - - // Same hot one-second bucket as the current high-water mark. Allocate the next - // millisecond so updated_at remains unique and cursor-orderable inside the process. - let bumped = current.saturating_add(1); - if self - .thread_updated_at_millis - .compare_exchange(current, bumped, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - break bumped; - } - }; - epoch_millis_to_datetime(allocated) + allocate_thread_timestamp(self.thread_updated_at_millis.as_ref(), updated_at) } + fn allocate_thread_recency_at( + &self, + recency_at: DateTime, + ) -> anyhow::Result> { + allocate_thread_timestamp(self.thread_recency_at_millis.as_ref(), recency_at) + } +} + +fn allocate_thread_timestamp( + high_water_mark: &AtomicI64, + timestamp: DateTime, +) -> anyhow::Result> { + let candidate = datetime_to_epoch_millis(timestamp); + let allocated = loop { + let current = high_water_mark.load(Ordering::Relaxed); + + // New wall-clock time: advance the process-local high-water mark and use it as-is. + if candidate > current { + if high_water_mark + .compare_exchange(current, candidate, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + break candidate; + } + continue; + } + + // Older timestamps come from backfill/repair paths that preserve rollout mtimes. + // Do not drag historical rows forward just because this process has seen newer writes. + if candidate.saturating_add(1000) <= current { + break candidate; + } + + // Same hot one-second bucket as the current high-water mark. Allocate the next + // millisecond so the timestamp remains unique and cursor-orderable inside the process. + let bumped = current.saturating_add(1); + if high_water_mark + .compare_exchange(current, bumped, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + break bumped; + } + }; + epoch_millis_to_datetime(allocated) +} + +impl StateRuntime { pub async fn update_thread_git_info( &self, thread_id: ThreadId, @@ -702,6 +749,7 @@ WHERE id = ? creation_memory_mode: Option<&str>, ) -> anyhow::Result<()> { let updated_at = self.allocate_thread_updated_at(metadata.updated_at)?; + let insert_recency_at = self.allocate_thread_recency_at(metadata.recency_at)?; let preview = metadata_preview(metadata); // Backfill/reconcile callers merge existing git info before upserting, but that // read/modify/write is not atomic. Preserve non-null SQLite git fields here so @@ -713,8 +761,10 @@ INSERT INTO threads ( rollout_path, created_at, updated_at, + recency_at, created_at_ms, updated_at_ms, + recency_at_ms, source, thread_source, agent_nickname, @@ -737,13 +787,15 @@ INSERT INTO threads ( git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET rollout_path = excluded.rollout_path, created_at = excluded.created_at, updated_at = excluded.updated_at, + recency_at = threads.recency_at, created_at_ms = excluded.created_at_ms, updated_at_ms = excluded.updated_at_ms, + recency_at_ms = threads.recency_at_ms, source = excluded.source, thread_source = excluded.thread_source, agent_nickname = excluded.agent_nickname, @@ -771,8 +823,10 @@ ON CONFLICT(id) DO UPDATE SET .bind(metadata.rollout_path.display().to_string()) .bind(datetime_to_epoch_seconds(metadata.created_at)) .bind(datetime_to_epoch_seconds(updated_at)) + .bind(datetime_to_epoch_seconds(insert_recency_at)) .bind(datetime_to_epoch_millis(metadata.created_at)) .bind(datetime_to_epoch_millis(updated_at)) + .bind(datetime_to_epoch_millis(insert_recency_at)) .bind(metadata.source.as_str()) .bind( metadata @@ -1079,6 +1133,7 @@ SELECT threads.rollout_path, threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, + threads.recency_at_ms AS recency_at, threads.source, threads.thread_source, threads.agent_nickname, @@ -1197,6 +1252,7 @@ pub(super) fn push_thread_filters<'a>( let column = match sort_key { SortKey::CreatedAt => "threads.created_at_ms", SortKey::UpdatedAt => "threads.updated_at_ms", + SortKey::RecencyAt => "threads.recency_at_ms", }; let operator = match sort_direction { SortDirection::Asc => ">", @@ -1208,6 +1264,19 @@ pub(super) fn push_thread_filters<'a>( builder.push(operator); builder.push(" "); builder.push_bind(anchor_ts); + if sort_key == SortKey::RecencyAt + && let Some(anchor_id) = anchor.id + { + builder.push(" OR ("); + builder.push(column); + builder.push(" = "); + builder.push_bind(anchor_ts); + builder.push(" AND threads.id "); + builder.push(operator); + builder.push(" "); + builder.push_bind(anchor_id.to_string()); + builder.push(")"); + } builder.push(")"); } } @@ -1232,6 +1301,7 @@ pub(super) fn push_thread_order_and_limit( let order_column = match sort_key { SortKey::CreatedAt => "threads.created_at_ms", SortKey::UpdatedAt => "threads.updated_at_ms", + SortKey::RecencyAt => "threads.recency_at_ms", }; let order_direction = match sort_direction { SortDirection::Asc => "ASC", @@ -1247,6 +1317,10 @@ pub(super) fn push_thread_order_and_limit( builder.push(order_column); builder.push(" "); builder.push(order_direction); + if sort_key == SortKey::RecencyAt { + builder.push(", threads.id "); + builder.push(order_direction); + } builder.push(" LIMIT "); builder.push_bind(limit as i64); } @@ -1521,6 +1595,7 @@ mod tests { let anchor = Anchor { ts: older_updated_at, + id: None, }; let model_providers = ["test-provider".to_string()]; let page = runtime @@ -1547,6 +1622,7 @@ mod tests { Some(Anchor { ts: DateTime::::from_timestamp_millis(1_700_000_200_000) .expect("valid timestamp"), + id: None, }) ); @@ -1631,6 +1707,7 @@ mod tests { Some(Anchor { ts: DateTime::::from_timestamp_millis(1_700_000_300_000) .expect("valid timestamp"), + id: None, }) ); @@ -1693,6 +1770,7 @@ mod tests { ]; let anchor = Anchor { ts: DateTime::::from_timestamp(1_700_000_000, 0).expect("valid timestamp"), + id: None, }; for (sort_key, visible_index, cwd_index) in [ ( @@ -1705,6 +1783,11 @@ mod tests { "idx_threads_visible_updated_at_ms", "idx_threads_archived_cwd_updated_at_ms", ), + ( + SortKey::RecencyAt, + "idx_threads_visible_recency_at_ms", + "idx_threads_archived_cwd_recency_at_ms", + ), ] { for (cwd_filters, anchor, expected_index, expect_temp_sort) in [ (None, None, visible_index, false), @@ -2279,6 +2362,186 @@ mod tests { assert_eq!(persisted.preview.as_deref(), Some("first-user-message")); } + #[tokio::test] + async fn touch_thread_recency_at_is_monotonic_and_survives_stale_upsert() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("state db should initialize"); + let thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000792").expect("valid thread id"); + let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + let original_recency_at = metadata.recency_at; + runtime + .upsert_thread(&metadata) + .await + .expect("initial upsert should succeed"); + + let touched_at = + DateTime::::from_timestamp_millis(1_700_001_111_123).expect("timestamp"); + assert!( + runtime + .touch_thread_recency_at(thread_id, touched_at) + .await + .expect("touch should succeed") + ); + + metadata.updated_at = + DateTime::::from_timestamp_millis(1_700_001_222_456).expect("timestamp"); + metadata.title = "updated metadata".to_string(); + assert_eq!(metadata.recency_at, original_recency_at); + runtime + .upsert_thread(&metadata) + .await + .expect("stale metadata upsert should succeed"); + + let persisted = runtime + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist"); + assert_eq!(persisted.recency_at, touched_at); + assert_eq!(persisted.updated_at, metadata.updated_at); + assert_eq!(persisted.title, "updated metadata"); + + assert!( + runtime + .touch_thread_recency_at(thread_id, original_recency_at) + .await + .expect("older touch should succeed") + ); + let persisted = runtime + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist"); + assert_eq!( + datetime_to_epoch_millis(persisted.recency_at), + datetime_to_epoch_millis(touched_at) + 1 + ); + } + + #[tokio::test] + async fn list_threads_orders_and_pages_by_recency_at() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("state db should initialize"); + let first_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000793").expect("valid thread id"); + let second_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000794").expect("valid thread id"); + let third_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000795").expect("valid thread id"); + let recency_at = + DateTime::::from_timestamp_millis(1_700_002_000_456).expect("timestamp"); + + for thread_id in [first_id, second_id, third_id] { + let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + metadata.recency_at = recency_at; + runtime + .upsert_thread(&metadata) + .await + .expect("thread insert should succeed"); + } + sqlx::query("UPDATE threads SET recency_at = ?, recency_at_ms = ?") + .bind(datetime_to_epoch_seconds(recency_at)) + .bind(datetime_to_epoch_millis(recency_at)) + .execute(runtime.pool.as_ref()) + .await + .expect("recency timestamps should match"); + + let first_page = runtime + .list_threads( + /*page_size*/ 1, + ThreadFilterOptions { + archived_only: false, + allowed_sources: &[], + model_providers: None, + cwd_filters: None, + anchor: None, + sort_key: SortKey::RecencyAt, + sort_direction: SortDirection::Desc, + search_term: None, + }, + ) + .await + .expect("list should succeed"); + assert_eq!( + first_page + .items + .iter() + .map(|item| item.id) + .collect::>(), + vec![third_id] + ); + assert_eq!( + first_page.next_anchor, + Some(Anchor { + ts: recency_at, + id: Some(third_id), + }) + ); + + let second_page = runtime + .list_threads( + /*page_size*/ 1, + ThreadFilterOptions { + archived_only: false, + allowed_sources: &[], + model_providers: None, + cwd_filters: None, + anchor: first_page.next_anchor.as_ref(), + sort_key: SortKey::RecencyAt, + sort_direction: SortDirection::Desc, + search_term: None, + }, + ) + .await + .expect("second list should succeed"); + assert_eq!( + second_page + .items + .iter() + .map(|item| item.id) + .collect::>(), + vec![second_id] + ); + assert_eq!( + second_page.next_anchor, + Some(Anchor { + ts: recency_at, + id: Some(second_id), + }) + ); + + let third_page = runtime + .list_threads( + /*page_size*/ 1, + ThreadFilterOptions { + archived_only: false, + allowed_sources: &[], + model_providers: None, + cwd_filters: None, + anchor: second_page.next_anchor.as_ref(), + sort_key: SortKey::RecencyAt, + sort_direction: SortDirection::Desc, + search_term: None, + }, + ) + .await + .expect("third list should succeed"); + assert_eq!( + third_page + .items + .iter() + .map(|item| item.id) + .collect::>(), + vec![first_id] + ); + assert_eq!(third_page.next_anchor, None); + } + #[tokio::test] async fn thread_updated_at_uses_unique_epoch_millis_and_reads_legacy_seconds() { let codex_home = unique_temp_dir(); @@ -2295,8 +2558,10 @@ mod tests { DateTime::::from_timestamp_millis(1_700_001_111_123).expect("timestamp millis"); let mut first = test_thread_metadata(&codex_home, first_id, codex_home.clone()); first.updated_at = updated_at; + first.recency_at = updated_at; let mut second = test_thread_metadata(&codex_home, second_id, codex_home.clone()); second.updated_at = updated_at; + second.recency_at = updated_at; runtime .upsert_thread(&first) @@ -2325,6 +2590,14 @@ mod tests { datetime_to_epoch_millis(second.updated_at), 1_700_001_111_124 ); + assert_eq!( + datetime_to_epoch_millis(first.recency_at), + 1_700_001_111_123 + ); + assert_eq!( + datetime_to_epoch_millis(second.recency_at), + 1_700_001_111_124 + ); let second_row: (i64, i64, Option, Option) = sqlx::query_as( "SELECT created_at, updated_at, created_at_ms, updated_at_ms FROM threads WHERE id = ?", ) diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index d02b78d237..7eda54a9c0 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -535,6 +535,9 @@ fn stored_thread_from_state( updated_at: metadata .and_then(|metadata| metadata.updated_at) .unwrap_or_else(Utc::now), + recency_at: metadata + .and_then(|metadata| metadata.advance_recency_at.or(metadata.updated_at)) + .unwrap_or_else(Utc::now), archived_at: None, cwd: metadata .and_then(|metadata| metadata.cwd.clone()) diff --git a/codex-rs/thread-store/src/local/archive_thread.rs b/codex-rs/thread-store/src/local/archive_thread.rs index bbe5c3de51..eb4a48b680 100644 --- a/codex-rs/thread-store/src/local/archive_thread.rs +++ b/codex-rs/thread-store/src/local/archive_thread.rs @@ -174,5 +174,6 @@ mod tests { .expect("thread metadata should exist"); assert_eq!(updated.rollout_path, archived_path); assert!(updated.archived_at.is_some()); + assert_eq!(updated.recency_at, metadata.recency_at); } } diff --git a/codex-rs/thread-store/src/local/helpers.rs b/codex-rs/thread-store/src/local/helpers.rs index 5acdcf09b6..f48a0136c0 100644 --- a/codex-rs/thread-store/src/local/helpers.rs +++ b/codex-rs/thread-store/src/local/helpers.rs @@ -106,6 +106,7 @@ pub(super) fn stored_thread_from_rollout_item( .or_else(|| thread_id_from_rollout_path(item.path.as_path()))?; let created_at = parse_rfc3339(item.created_at.as_deref()).unwrap_or_else(Utc::now); let updated_at = parse_rfc3339(item.updated_at.as_deref()).unwrap_or(created_at); + let recency_at = parse_rfc3339(item.recency_at.as_deref()).unwrap_or(updated_at); let archived_at = archived.then_some(updated_at); let git_info = git_info_from_parts( item.git_sha.clone(), @@ -136,6 +137,7 @@ pub(super) fn stored_thread_from_rollout_item( reasoning_effort: None, created_at, updated_at, + recency_at, archived_at, cwd: item.cwd.unwrap_or_default(), cli_version: item.cli_version.unwrap_or_default(), diff --git a/codex-rs/thread-store/src/local/list_threads.rs b/codex-rs/thread-store/src/local/list_threads.rs index fc3b5034ad..03ae163afe 100644 --- a/codex-rs/thread-store/src/local/list_threads.rs +++ b/codex-rs/thread-store/src/local/list_threads.rs @@ -34,6 +34,7 @@ pub(super) async fn list_threads( let sort_key = match params.sort_key { ThreadSortKey::CreatedAt => codex_rollout::ThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_rollout::ThreadSortKey::UpdatedAt, + ThreadSortKey::RecencyAt => codex_rollout::ThreadSortKey::RecencyAt, }; let sort_direction = match params.sort_direction { SortDirection::Asc => codex_rollout::SortDirection::Asc, diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index dd49fd2a59..5670e5638c 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -314,10 +314,16 @@ mod tests { use codex_protocol::ThreadId; use codex_protocol::models::BaseInstructions; + use codex_protocol::models::FunctionCallOutputPayload; + use codex_protocol::models::MessagePhase; + use codex_protocol::models::ResponseItem; + use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadMemoryMode; + use codex_protocol::protocol::TurnCompleteEvent; + use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use tempfile::TempDir; @@ -449,6 +455,91 @@ mod tests { assert_eq!(metadata.title, "observed append"); } + #[tokio::test] + async fn live_thread_output_advances_updated_at_but_not_recency_at() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone()))); + let thread_id = ThreadId::default(); + let live_thread = LiveThread::create(store, create_thread_params(thread_id)) + .await + .expect("create live thread"); + + live_thread + .append_items(&[user_message_item("start thread")]) + .await + .expect("append initial user message"); + live_thread.flush().await.expect("flush thread"); + let before_turn_start = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata"); + + live_thread + .append_items(&[RolloutItem::EventMsg(EventMsg::TurnStarted( + TurnStartedEvent { + turn_id: "turn-1".to_string(), + trace_id: None, + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }, + ))]) + .await + .expect("append turn start"); + live_thread.flush().await.expect("flush thread"); + let after_turn_start = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata"); + assert!(after_turn_start.recency_at > before_turn_start.recency_at); + + live_thread + .append_items(&[ + RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { + message: "commentary".to_string(), + phase: Some(MessagePhase::Commentary), + memory_citation: None, + })), + RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput { + call_id: "call-1".to_string(), + output: FunctionCallOutputPayload::from_text("tool output".to_string()), + }), + RolloutItem::EventMsg(EventMsg::TokenCount( + codex_protocol::protocol::TokenCountEvent { + info: None, + rate_limits: None, + }, + )), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + })), + ]) + .await + .expect("append post-start items"); + live_thread.flush().await.expect("flush thread"); + let completed = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata"); + + assert!(completed.updated_at > after_turn_start.updated_at); + assert_eq!(completed.recency_at, after_turn_start.recency_at); + } + #[tokio::test] async fn live_thread_shutdown_does_not_materialize_empty_thread_metadata() { let home = TempDir::new().expect("temp dir"); diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index 4778dd69d9..a1e1c59190 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -55,6 +55,7 @@ pub(super) async fn read_thread( && (params.include_archived || rollout_thread.archived_at.is_none()) && !rollout_thread.preview.is_empty() { + rollout_thread.recency_at = thread.recency_at; if thread.name.is_some() { rollout_thread.name = thread.name; } @@ -115,6 +116,7 @@ pub(super) async fn read_thread_by_rollout_path( }); } if let Some(metadata) = read_sqlite_metadata(store, thread.thread_id).await { + thread.recency_at = metadata.recency_at; let existing_git_info = thread.git_info.take(); let (fallback_sha, fallback_branch, fallback_origin_url) = match existing_git_info { Some(info) => ( @@ -340,6 +342,7 @@ async fn stored_thread_from_sqlite_metadata( reasoning_effort: metadata.reasoning_effort, created_at: metadata.created_at, updated_at: metadata.updated_at, + recency_at: metadata.recency_at, archived_at: metadata.archived_at, cwd: metadata.cwd, cli_version: metadata.cli_version, @@ -406,6 +409,7 @@ fn stored_thread_from_meta_line( reasoning_effort: None, created_at, updated_at, + recency_at: updated_at, archived_at: archived.then_some(updated_at), cwd: meta_line.meta.cwd, cli_version: meta_line.meta.cli_version, @@ -547,6 +551,10 @@ mod tests { ); builder.model_provider = Some(config.default_model_provider_id.clone()); builder.git_branch = Some("sqlite-branch".to_string()); + let recency_at = chrono::DateTime::parse_from_rfc3339("2026-01-03T12:00:00Z") + .expect("timestamp should parse") + .with_timezone(&Utc); + builder.recency_at = Some(recency_at); runtime .upsert_thread(&builder.build(config.default_model_provider_id.as_str())) .await @@ -562,6 +570,7 @@ mod tests { .expect("read thread by rollout path"); let git_info = thread.git_info.expect("git info should be present"); + assert_eq!(thread.recency_at, recency_at); assert_eq!(git_info.branch.as_deref(), Some("sqlite-branch")); assert_eq!( git_info.commit_hash.as_ref().map(|sha| sha.0.as_str()), diff --git a/codex-rs/thread-store/src/local/search_threads.rs b/codex-rs/thread-store/src/local/search_threads.rs index 1b7f18a32f..fc2ddd6e71 100644 --- a/codex-rs/thread-store/src/local/search_threads.rs +++ b/codex-rs/thread-store/src/local/search_threads.rs @@ -23,6 +23,10 @@ use crate::ThreadSortKey; use crate::ThreadStoreError; use crate::ThreadStoreResult; +#[cfg(test)] +#[path = "search_threads_tests.rs"] +mod tests; + struct ThreadSearchItem { item: codex_rollout::ThreadItem, snippet: String, @@ -50,6 +54,7 @@ pub(super) async fn search_threads( let sort_key = match params.sort_key { ThreadSortKey::CreatedAt => codex_rollout::ThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_rollout::ThreadSortKey::UpdatedAt, + ThreadSortKey::RecencyAt => codex_rollout::ThreadSortKey::RecencyAt, }; let sort_direction = match params.sort_direction { SortDirection::Asc => codex_rollout::SortDirection::Asc, @@ -179,8 +184,17 @@ fn cursor_from_thread_search_item( .updated_at .as_deref() .or(item.item.created_at.as_deref())?, + ThreadSortKey::RecencyAt => item + .item + .recency_at + .as_deref() + .or(item.item.updated_at.as_deref()) + .or(item.item.created_at.as_deref())?, }; - parse_cursor(timestamp) + match sort_key { + ThreadSortKey::RecencyAt => parse_cursor(&format!("{timestamp}|{}", item.item.thread_id?)), + ThreadSortKey::CreatedAt | ThreadSortKey::UpdatedAt => parse_cursor(timestamp), + } } async fn set_thread_search_result_names( diff --git a/codex-rs/thread-store/src/local/search_threads_tests.rs b/codex-rs/thread-store/src/local/search_threads_tests.rs new file mode 100644 index 0000000000..bfcf768276 --- /dev/null +++ b/codex-rs/thread-store/src/local/search_threads_tests.rs @@ -0,0 +1,29 @@ +use codex_protocol::ThreadId; +use codex_rollout::ThreadItem; +use pretty_assertions::assert_eq; + +use super::ThreadSearchItem; +use super::cursor_from_thread_search_item; +use crate::ThreadSortKey; + +#[test] +fn recency_cursor_includes_thread_id_tie_breaker() { + let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000123") + .expect("thread ID should parse"); + let item = ThreadSearchItem { + item: ThreadItem { + thread_id: Some(thread_id), + recency_at: Some("2026-01-27T12:34:56Z".to_string()), + ..Default::default() + }, + snippet: String::new(), + }; + + let cursor = cursor_from_thread_search_item(&item, ThreadSortKey::RecencyAt) + .expect("cursor should build"); + + assert_eq!( + serde_json::to_string(&cursor).expect("cursor should serialize"), + format!("\"2026-01-27T12:34:56Z|{thread_id}\"") + ); +} diff --git a/codex-rs/thread-store/src/local/unarchive_thread.rs b/codex-rs/thread-store/src/local/unarchive_thread.rs index ad41db69ac..ee25b43424 100644 --- a/codex-rs/thread-store/src/local/unarchive_thread.rs +++ b/codex-rs/thread-store/src/local/unarchive_thread.rs @@ -196,5 +196,6 @@ mod tests { .expect("thread metadata should exist"); assert_eq!(updated.rollout_path, restored_path); assert_eq!(updated.archived_at, None); + assert_eq!(updated.recency_at, metadata.recency_at); } } diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index 820cb68918..d1c76ad327 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -204,6 +204,7 @@ async fn apply_metadata_update( .map_err(|err| ThreadStoreError::Internal { message: format!("failed to read thread metadata for {thread_id}: {err}"), })?; + let advance_recency_at = patch.advance_recency_at; if existing.is_none() && rollout_path.is_none() { let resolved = resolve_rollout_path(store, thread_id, include_archived).await?; rollout_path_archived = resolved.archived; @@ -260,6 +261,11 @@ async fn apply_metadata_update( if let Some(updated_at) = patch.updated_at { metadata.updated_at = updated_at; } + if existing.is_none() + && let Some(recency_at) = advance_recency_at + { + metadata.recency_at = recency_at; + } if let Some(source) = patch.source { metadata.source = enum_to_string(&source); } @@ -310,6 +316,18 @@ async fn apply_metadata_update( .map_err(|err| ThreadStoreError::Internal { message: format!("failed to update thread metadata for {thread_id}: {err}"), })?; + if existing.is_some() + && let Some(recency_at) = advance_recency_at + { + state_db + .touch_thread_recency_at(thread_id, recency_at) + .await + .map_err(|err| ThreadStoreError::Internal { + message: format!( + "failed to advance thread recency_at for {thread_id}: {err}" + ), + })?; + } if let Some(memory_mode) = patch.memory_mode { state_db .set_thread_memory_mode(thread_id, memory_mode_as_str(memory_mode)) diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index d4bd8edb76..6705f45742 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -156,7 +156,10 @@ impl ThreadMetadataSync { let affects_metadata = items .iter() .any(codex_state::rollout_item_affects_thread_metadata); - let update = if affects_metadata { + let advances_recency = items + .iter() + .any(|item| matches!(item, RolloutItem::EventMsg(EventMsg::TurnStarted(_)))); + let mut update = if affects_metadata { self.observe_items(items)? } else { Some(thread_updated_at_touch()) @@ -164,6 +167,9 @@ impl ThreadMetadataSync { let Some(update) = update else { return Ok(None); }; + if advances_recency { + update.advance_recency_at = Some(Utc::now()); + } self.merge_pending_update(Some(update)); if !affects_metadata && !self @@ -367,6 +373,7 @@ fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool { || update.model.is_some() || update.reasoning_effort.is_some() || update.created_at.is_some() + || update.advance_recency_at.is_some() || update.source.is_some() || update.thread_source.is_some() || update.agent_nickname.is_some() @@ -399,6 +406,7 @@ mod tests { use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadGoalUpdatedEvent; + use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use pretty_assertions::assert_eq; @@ -522,6 +530,27 @@ mod tests { ); } + #[test] + fn turn_start_advances_recency_at_without_changing_updated_at_behavior() { + let thread_id = ThreadId::new(); + let mut sync = ThreadMetadataSync::for_resume(&resume_params(thread_id, Vec::new())); + + let update = sync + .observe_appended_items(&[RolloutItem::EventMsg(EventMsg::TurnStarted( + TurnStartedEvent { + turn_id: "turn-1".to_string(), + trace_id: None, + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }, + ))]) + .expect("turn start metadata update"); + + assert!(update.patch.updated_at.is_some()); + assert!(update.patch.advance_recency_at.is_some()); + } + #[test] fn resume_history_waits_for_append_before_flushing_metadata() { let thread_id = ThreadId::new(); diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index bb8bf2f6dc..b969c6b957 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -160,6 +160,8 @@ pub enum ThreadSortKey { CreatedAt, /// Sort by the thread last-update timestamp. UpdatedAt, + /// Sort by the thread's product recency timestamp. + RecencyAt, } /// The direction to use when listing stored threads. @@ -397,6 +399,8 @@ pub struct StoredThread { pub created_at: DateTime, /// Thread last-update timestamp. pub updated_at: DateTime, + /// Thread product-recency timestamp. + pub recency_at: DateTime, /// Thread archive timestamp, if archived. pub archived_at: Option>, /// Working directory captured for the thread. @@ -504,6 +508,8 @@ pub struct ThreadMetadataPatch { pub created_at: Option>, /// Last update timestamp for this metadata observation. pub updated_at: Option>, + /// Advance product recency to at least this timestamp. + pub advance_recency_at: Option>, /// Session source. pub source: Option, /// Optional analytics source classification. @@ -586,6 +592,9 @@ impl ThreadMetadataPatch { if next.updated_at.is_some() { self.updated_at = next.updated_at; } + if next.advance_recency_at.is_some() { + self.advance_recency_at = next.advance_recency_at; + } if next.source.is_some() { self.source = next.source; } @@ -639,6 +648,7 @@ impl ThreadMetadataPatch { && self.reasoning_effort.is_none() && self.created_at.is_none() && self.updated_at.is_none() + && self.advance_recency_at.is_none() && self.source.is_none() && self.thread_source.is_none() && self.agent_nickname.is_none() diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index aba08f08d9..6bd260c0b6 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -136,6 +136,7 @@ mod tests { model_provider: "openai".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 87538251aa..d8a29d4eed 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2881,6 +2881,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re model_provider: "agent-provider".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: Some(rollout_path.clone()), cwd: test_path_buf("/tmp/agent").abs(), @@ -2973,6 +2974,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ model_provider: "agent-provider".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/agent").abs(), @@ -3032,6 +3034,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { model_provider: "read-provider".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/read").abs(), @@ -5643,6 +5646,7 @@ async fn thread_rollback_response_discards_queued_active_thread_events() { model_provider: "openai".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/project").abs(), diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index bcc172ef93..ceafb71db9 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -417,6 +417,7 @@ mod tests { model_provider: "read-provider".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/read").abs(), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 5e36fb1c9d..4049ca14a6 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -2319,6 +2319,7 @@ mod tests { model_provider: "openai".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/project").abs(), diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 18313bcca6..08191eaf2b 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -608,7 +608,7 @@ fn spawn_app_server_page_loader( fn sort_key_label(sort_key: ThreadSortKey) -> &'static str { match sort_key { ThreadSortKey::CreatedAt => "Created", - ThreadSortKey::UpdatedAt => "Updated", + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => "Updated", } } @@ -1614,7 +1614,7 @@ impl PickerState { fn toggle_sort_key(&mut self) { self.sort_key = match self.sort_key { ThreadSortKey::CreatedAt => ThreadSortKey::UpdatedAt, - ThreadSortKey::UpdatedAt => ThreadSortKey::CreatedAt, + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => ThreadSortKey::CreatedAt, }; self.start_initial_load(); } @@ -2613,7 +2613,7 @@ fn render_dense_session_lines( let updated = format_relative_time(reference, row.updated_at.or(row.created_at)); let date = match state.sort_key { ThreadSortKey::CreatedAt => created, - ThreadSortKey::UpdatedAt => updated, + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => updated, }; let mut lines = vec![dense_summary_line(DenseSummaryInput { marker, @@ -2742,7 +2742,7 @@ fn render_footer_lines( ) -> Vec> { let date = match sort_key { ThreadSortKey::CreatedAt => created, - ThreadSortKey::UpdatedAt => updated, + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => updated, }; let mut parts = vec![FooterPart::Date(date.to_string())]; if show_cwd { @@ -5728,6 +5728,7 @@ session_picker_view = "dense" model_provider: String::from("openai"), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), @@ -5763,6 +5764,7 @@ session_picker_view = "dense" model_provider: String::from("openai"), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), @@ -5832,6 +5834,7 @@ session_picker_view = "dense" model_provider: String::from("openai"), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), @@ -5890,6 +5893,7 @@ session_picker_view = "dense" model_provider: String::from("openai"), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), From bfe90188add9603c0908501e93b2b112f623f373 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 16 Jun 2026 17:18:20 -0700 Subject: [PATCH 15/70] Revert "Tell codex about PathUri serde compat. (#28595)" (#28627) This reverts commit bd2a78632665006149d6e7df627c52ebc1b3464e, which didn't capture all the nuance we need for this migration. --- .codex/skills/path-types/SKILL.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.codex/skills/path-types/SKILL.md b/.codex/skills/path-types/SKILL.md index de35714dd2..ccf1a9f41d 100644 --- a/.codex/skills/path-types/SKILL.md +++ b/.codex/skills/path-types/SKILL.md @@ -19,13 +19,6 @@ migration; if compliance is difficult, ask the user how to proceed. - Tool call arguments that the model is expected to generate should be deserialized as regular `String`s with feature-specific path handling code. -## Rollout stability - -Assume that rollouts will be parsed by monotonically increasing versions of Codex. - -You can use `PathUri` in place of `AbsolutePathBuf` for rollout fields without jeopardizing -backwards compatibility since `PathUri` allows deserialization from bare absolute paths. - ## Migration requirements Keep these requirements in mind while migrating code to conform with the above guidelines: From 69bc0645acc474452e28f31a227b14b3a3f302cc Mon Sep 17 00:00:00 2001 From: xl-openai Date: Tue, 16 Jun 2026 17:24:48 -0700 Subject: [PATCH 16/70] [codex] Gate remote plugin catalog by auth (#28625) ## Summary - Treat the remote global plugin catalog as active only when `remote_plugin` is enabled and the current auth uses the Codex backend. - Skip the local OpenAI curated marketplace for remote-enabled ChatGPT users while preserving configured marketplaces. - Keep the local curated marketplace for API-key users, unauthenticated fallback, and ChatGPT users with `remote_plugin` disabled. - Apply the same effective-remote gate to the remote installed-marketplace cache. ## Root cause The tool-suggestion discovery path unconditionally included the local OpenAI curated marketplace. For remote-enabled ChatGPT users, that made remote discovery additive: Codex parsed every local curated `plugin.json` before also loading the remote catalog. ## Validation - `just fmt` - `cargo build -p codex-cli --bin codex` - Targeted auth/feature matrix tests pass, including API-key auth with `remote_plugin` enabled. - Manual CLI validation confirmed: - ChatGPT + remote off includes local curated. - ChatGPT + remote on excludes local curated. - API-key auth keeps local curated when remote is enabled. - `just test -p codex-core-plugins`: 235 passed; one unrelated existing marketplace test failed because it loaded the developer's home marketplace configuration. --- codex-rs/core-plugins/src/discoverable.rs | 10 ++++- .../core-plugins/src/discoverable_tests.rs | 45 +++++++++++++++---- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/codex-rs/core-plugins/src/discoverable.rs b/codex-rs/core-plugins/src/discoverable.rs index c6a7146a86..0379819c47 100644 --- a/codex-rs/core-plugins/src/discoverable.rs +++ b/codex-rs/core-plugins/src/discoverable.rs @@ -76,11 +76,17 @@ impl PluginsManager { return Ok(Vec::new()); } + let use_remote_global_catalog = + input.plugins.remote_plugin_enabled && auth.is_some_and(CodexAuth::uses_codex_backend); let marketplaces = self - .list_marketplaces_for_config(&input.plugins, &[], /*include_openai_curated*/ true) + .list_marketplaces_for_config( + &input.plugins, + &[], + /*include_openai_curated*/ !use_remote_global_catalog, + ) .context("failed to list plugin marketplaces for tool suggestions")? .marketplaces; - let remote_installed_marketplaces = if input.plugins.remote_plugin_enabled { + let remote_installed_marketplaces = if use_remote_global_catalog { self.build_remote_installed_plugin_marketplaces_from_cache(&[ REMOTE_GLOBAL_MARKETPLACE_NAME, ]) diff --git a/codex-rs/core-plugins/src/discoverable_tests.rs b/codex-rs/core-plugins/src/discoverable_tests.rs index c1fb04f92c..8465d2431e 100644 --- a/codex-rs/core-plugins/src/discoverable_tests.rs +++ b/codex-rs/core-plugins/src/discoverable_tests.rs @@ -15,6 +15,7 @@ use crate::test_support::write_curated_plugin_sha_with; use crate::test_support::write_file; use crate::test_support::write_openai_api_curated_marketplace; use crate::test_support::write_openai_curated_marketplace; +use codex_app_server_protocol::AuthMode; use codex_config::CONFIG_TOML_FILE; use codex_login::CodexAuth; use codex_utils_absolute_path::AbsolutePathBuf; @@ -34,17 +35,19 @@ use wiremock::matchers::path; use wiremock::matchers::query_param; #[tokio::test] -async fn returns_fallback_plugins_without_installed_apps() { +async fn returns_fallback_plugins_when_remote_disabled_for_codex_auth() { let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace(&curated_root, &["sample", "slack", "openai-developers"]); let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + plugins_manager.set_auth_mode(Some(AuthMode::Chatgpt)); + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let discoverable_plugins = list_discoverable_plugins( &plugins_manager, discovery_input(plugins, &[], &[], &[]), - /*auth*/ None, + Some(&auth), ) .await; @@ -66,8 +69,10 @@ async fn returns_api_curated_fallback_plugins_for_direct_provider_auth() { let curated_root = curated_plugins_repo_path(codex_home.path()); write_openai_api_curated_marketplace(&curated_root, &["sample", "slack", "openai-developers"]); - let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let mut plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + plugins.remote_plugin_enabled = true; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + plugins_manager.set_auth_mode(Some(AuthMode::ApiKey)); let auth = CodexAuth::from_api_key("test-api-key"); let discoverable_plugins = list_discoverable_plugins( &plugins_manager, @@ -121,7 +126,7 @@ async fn returns_microsoft_fallback_plugins() { } #[tokio::test] -async fn includes_openai_curated_when_remote_enabled() { +async fn omits_openai_curated_but_keeps_configured_marketplaces_for_remote_codex_auth() { let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace(&curated_root, &["slack"]); @@ -159,6 +164,33 @@ source = "/tmp/{bundled_marketplace_name}" let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + plugins_manager.set_auth_mode(Some(AuthMode::Chatgpt)); + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let discoverable_plugins = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[], &[], &[]), + Some(&auth), + ) + .await; + + assert_eq!( + discoverable_plugins + .into_iter() + .map(|plugin| plugin.id) + .collect::>(), + vec!["chrome@openai-bundled".to_string()] + ); +} + +#[tokio::test] +async fn includes_openai_curated_when_remote_enabled_without_auth() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + + let mut plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + plugins.remote_plugin_enabled = true; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); let discoverable_plugins = list_discoverable_plugins( &plugins_manager, discovery_input(plugins, &[], &[], &[]), @@ -171,10 +203,7 @@ source = "/tmp/{bundled_marketplace_name}" .into_iter() .map(|plugin| plugin.id) .collect::>(), - vec![ - "chrome@openai-bundled".to_string(), - "slack@openai-curated".to_string(), - ] + vec!["slack@openai-curated".to_string()] ); } From 3d02c443bccd5fc61a22947a05b75b673442fd87 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 16 Jun 2026 19:05:26 -0700 Subject: [PATCH 17/70] [codex] core: restore absolute turn context cwd (#28629) ## Why #28152 jumped the gun on moving the rollout format to store URIs, and would likely break compat with some features that don't go through the same types as the core logic. ## What Make `TurnContextItem.cwd` an `AbsolutePathBuf` again, remove test added for `PathUri` serialization in rollouts. Also drops a bunch of error paths that are no longer needed. --- codex-rs/core/src/codex_thread.rs | 2 +- .../core/src/context/environment_context.rs | 35 ++++----- .../src/context/environment_context_tests.rs | 6 +- .../core/src/context_manager/history_tests.rs | 4 +- codex-rs/core/src/context_manager/updates.rs | 42 +++++------ codex-rs/core/src/prompt_debug.rs | 2 +- codex-rs/core/src/session/mod.rs | 23 +++--- .../session/rollout_reconstruction_tests.rs | 17 +++-- codex-rs/core/src/session/tests.rs | 55 ++++++-------- codex-rs/core/src/session/turn.rs | 12 +--- codex-rs/core/src/session/turn_context.rs | 10 +-- codex-rs/core/tests/suite/resume_warning.rs | 3 +- codex-rs/exec/src/lib.rs | 6 +- codex-rs/protocol/src/protocol.rs | 71 ++++--------------- codex-rs/rollout/src/metadata.rs | 2 +- codex-rs/rollout/src/metadata_tests.rs | 3 +- codex-rs/rollout/src/recorder.rs | 1 - codex-rs/state/src/extract.rs | 55 +++++--------- codex-rs/state/src/runtime/threads.rs | 2 +- codex-rs/thread-store/src/live_thread.rs | 4 +- .../thread-store/src/thread_metadata_sync.rs | 69 ++++++------------ 21 files changed, 142 insertions(+), 282 deletions(-) diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index e19bad0eb8..451b497827 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -458,7 +458,7 @@ impl CodexThread { self.codex .session .record_context_updates_and_set_reference_context_item(turn_context.as_ref()) - .await?; + .await; } self.codex .session diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index fe5f9b1d0d..e93ffb88ff 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -380,12 +380,12 @@ impl EnvironmentContext { pub(crate) fn diff_from_turn_context_item( before: &TurnContextItem, after: &EnvironmentContext, - ) -> std::io::Result { + ) -> Self { let before_network = Self::network_from_turn_context_item(before); - let before_filesystem = Self::filesystem_from_turn_context_item(before)?; + let before_filesystem = Self::filesystem_from_turn_context_item(before); let environments = match &after.environments { EnvironmentContextEnvironments::Single(environment) => { - if before.cwd != environment.cwd { + if PathUri::from_abs_path(&before.cwd) != environment.cwd { EnvironmentContextEnvironments::Single(EnvironmentContextEnvironment::legacy( environment.cwd.clone(), environment.shell.clone(), @@ -409,14 +409,14 @@ impl EnvironmentContext { } else { before_filesystem }; - Ok(EnvironmentContext::new_with_environments( + EnvironmentContext::new_with_environments( environments, after.current_date.clone(), after.timezone.clone(), network, filesystem, /*subagents*/ None, - )) + ) } pub(crate) fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { @@ -440,18 +440,18 @@ impl EnvironmentContext { pub(crate) fn from_turn_context_item( turn_context_item: &TurnContextItem, shell: String, - ) -> std::io::Result { - Ok(Self::new_with_environments( + ) -> Self { + Self::new_with_environments( EnvironmentContextEnvironments::from_vec(vec![EnvironmentContextEnvironment::legacy( - turn_context_item.cwd.clone(), + PathUri::from_abs_path(&turn_context_item.cwd), shell, )]), turn_context_item.current_date.clone(), turn_context_item.timezone.clone(), Self::network_from_turn_context_item(turn_context_item), - Self::filesystem_from_turn_context_item(turn_context_item)?, + Self::filesystem_from_turn_context_item(turn_context_item), /*subagents*/ None, - )) + ) } pub(crate) fn with_subagents(mut self, subagents: String) -> Self { @@ -498,11 +498,11 @@ impl EnvironmentContext { fn filesystem_from_turn_context_item( turn_context_item: &TurnContextItem, - ) -> std::io::Result> { - Ok(Some(FileSystemContext::from_permission_profile( - &turn_context_item.permission_profile()?, + ) -> Option { + Some(FileSystemContext::from_permission_profile( + &turn_context_item.permission_profile(), &workspace_roots_from_turn_context_item(turn_context_item), - ))) + )) } } @@ -513,12 +513,7 @@ fn workspace_roots_from_turn_context_item( return workspace_roots.clone(); } - // Older rollout items did not persist workspace roots. Fall back to the - // legacy cwd binding only when reconstructing that historical context. - match turn_context_item.cwd.to_abs_path() { - Ok(cwd) => vec![cwd], - Err(_) => Vec::new(), - } + vec![turn_context_item.cwd.clone()] } impl ContextualUserFragment for EnvironmentContext { diff --git a/codex-rs/core/src/context/environment_context_tests.rs b/codex-rs/core/src/context/environment_context_tests.rs index 383a38b55c..1204a1d16b 100644 --- a/codex-rs/core/src/context/environment_context_tests.rs +++ b/codex-rs/core/src/context/environment_context_tests.rs @@ -190,7 +190,7 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() { let repo_private = repo.join("private"); let item = TurnContextItem { turn_id: None, - cwd: PathUri::from_abs_path(&test_abs_path("/not-the-workspace")), + cwd: test_abs_path("/not-the-workspace"), workspace_roots: Some(vec![repo.clone(), other_repo.clone()]), current_date: None, timezone: None, @@ -209,9 +209,7 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() { summary: codex_protocol::config_types::ReasoningSummary::Auto, }; - let context = EnvironmentContext::from_turn_context_item(&item, fake_shell_name()) - .expect("turn context should hydrate") - .render(); + let context = EnvironmentContext::from_turn_context_item(&item, fake_shell_name()).render(); assert!( context.contains(&format!( diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 0b538be110..908cb286f0 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -21,9 +21,9 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnContextItem; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::truncate_text; -use codex_utils_path_uri::PathUri; use image::ImageBuffer; use image::ImageFormat; use image::Luma; @@ -127,7 +127,7 @@ fn developer_msg_with_fragments(texts: &[&str]) -> ResponseItem { fn reference_context_item() -> TurnContextItem { TurnContextItem { turn_id: Some("reference-turn".to_string()), - cwd: PathUri::from_path( + cwd: AbsolutePathBuf::try_from( std::env::current_dir() .expect("current directory") .join("reference-cwd"), diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index d9c0b2f03e..0ce02240b0 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -22,44 +22,40 @@ fn build_environment_update_item( previous: Option<&TurnContextItem>, next: &TurnContext, shell: &Shell, -) -> std::io::Result> { +) -> Option { if !next.config.include_environment_context { - return Ok(None); + return None; } - let Some(prev) = previous else { - return Ok(None); - }; - let prev_context = EnvironmentContext::from_turn_context_item(prev, shell.name().to_string())?; + let prev = previous?; + let prev_context = EnvironmentContext::from_turn_context_item(prev, shell.name().to_string()); let next_context = EnvironmentContext::from_turn_context(next, shell); if prev_context.equals_except_shell(&next_context) { - return Ok(None); + return None; } - Ok(Some(ContextualUserFragment::into( - EnvironmentContext::diff_from_turn_context_item(prev, &next_context)?, - ))) + Some(ContextualUserFragment::into( + EnvironmentContext::diff_from_turn_context_item(prev, &next_context), + )) } fn build_permissions_update_item( previous: Option<&TurnContextItem>, next: &TurnContext, exec_policy: &Policy, -) -> std::io::Result> { +) -> Option { if !next.config.include_permissions_instructions { - return Ok(None); + return None; } - let Some(prev) = previous else { - return Ok(None); - }; - if prev.permission_profile()? == next.permission_profile() + let prev = previous?; + if prev.permission_profile() == next.permission_profile() && prev.approval_policy == next.approval_policy.value() { - return Ok(None); + return None; } - Ok(Some( + Some( PermissionsInstructions::from_permission_profile( &next.permission_profile, next.approval_policy.value(), @@ -71,7 +67,7 @@ fn build_permissions_update_item( next.features.enabled(Feature::RequestPermissionsTool), ) .render(), - )) + ) } fn build_collaboration_mode_update_item( @@ -218,17 +214,17 @@ pub(crate) fn build_settings_update_items( shell: &Shell, exec_policy: &Policy, personality_feature_enabled: bool, -) -> std::io::Result> { +) -> Vec { // TODO(ccunningham): build_settings_update_items still does not cover every // model-visible item emitted by build_initial_context. Persist the remaining // inputs or add explicit replay events so fork/resume can diff everything // deterministically. - let contextual_user_message = build_environment_update_item(previous, next, shell)?; + let contextual_user_message = build_environment_update_item(previous, next, shell); let developer_update_sections = [ // Keep model-switch instructions first so model-specific guidance is read before // any other context diffs on this turn. build_model_instructions_update_item(previous_turn_settings, next), - build_permissions_update_item(previous, next, exec_policy)?, + build_permissions_update_item(previous, next, exec_policy), build_collaboration_mode_update_item(previous, next), build_realtime_update_item(previous, previous_turn_settings, next), build_personality_update_item(previous, next, personality_feature_enabled), @@ -244,5 +240,5 @@ pub(crate) fn build_settings_update_items( if let Some(contextual_user_message) = contextual_user_message { items.push(contextual_user_message); } - Ok(items) + items } diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index f3da9b93fe..0da42d8a4e 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -77,7 +77,7 @@ pub(crate) async fn build_prompt_input_from_session( ) -> CodexResult> { let turn_context = sess.new_default_turn().await; sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref()) - .await?; + .await; if !input.is_empty() { let response_item = sess.response_item_from_user_input(turn_context.as_ref(), input); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 5a2e33bbfe..d632609c02 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1621,7 +1621,7 @@ impl Session { &self, reference_context_item: Option<&TurnContextItem>, current_context: &TurnContext, - ) -> CodexResult> { + ) -> Vec { // TODO: Make context updates a pure diff of persisted previous/current TurnContextItem // state so replay/backtracking is deterministic. Runtime inputs that affect model-visible // context (shell, exec policy, feature gates, previous-turn bridge) should be persisted @@ -1632,15 +1632,13 @@ impl Session { }; let shell = self.user_shell(); let exec_policy = self.services.exec_policy.current(); - Ok( - crate::context_manager::updates::build_settings_update_items( - reference_context_item, - previous_turn_settings.as_ref(), - current_context, - shell.as_ref(), - exec_policy.as_ref(), - self.features.enabled(Feature::Personality), - )?, + crate::context_manager::updates::build_settings_update_items( + reference_context_item, + previous_turn_settings.as_ref(), + current_context, + shell.as_ref(), + exec_policy.as_ref(), + self.features.enabled(Feature::Personality), ) } @@ -3188,7 +3186,7 @@ impl Session { pub(crate) async fn record_context_updates_and_set_reference_context_item( &self, turn_context: &TurnContext, - ) -> CodexResult<()> { + ) { let reference_context_item = { let state = self.state.lock().await; state.reference_context_item() @@ -3199,7 +3197,7 @@ impl Session { } else { // Steady-state path: append only context diffs to minimize token overhead. self.build_settings_update_items(reference_context_item.as_ref(), turn_context) - .await? + .await }; let turn_context_item = turn_context.to_turn_context_item(); if !context_items.is_empty() { @@ -3215,7 +3213,6 @@ impl Session { // context items. This keeps later runtime diffing aligned with the current turn state. let mut state = self.state.lock().await; state.set_reference_context_item(Some(turn_context_item)); - Ok(()) } pub(crate) async fn update_token_usage_info( diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 67632aa536..084553f15a 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -9,7 +9,6 @@ use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::ResumedHistory; -use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -89,7 +88,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: PathUri::from_abs_path(&turn_context.cwd), + cwd: turn_context.cwd.clone(), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -129,7 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif let mut previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: PathUri::from_abs_path(&turn_context.cwd), + cwd: turn_context.cwd.clone(), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -990,7 +989,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: PathUri::from_abs_path(&turn_context.cwd), + cwd: turn_context.cwd.clone(), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1072,7 +1071,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis serde_json::to_value(Some(TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: PathUri::from_abs_path(&turn_context.cwd), + cwd: turn_context.cwd.clone(), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1102,7 +1101,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: PathUri::from_abs_path(&turn_context.cwd), + cwd: turn_context.cwd.clone(), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1224,7 +1223,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo let current_context_item = TurnContextItem { turn_id: Some(current_turn_id.clone()), #[allow(deprecated)] - cwd: PathUri::from_abs_path(&turn_context.cwd), + cwd: turn_context.cwd.clone(), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1344,7 +1343,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: PathUri::from_abs_path(&turn_context.cwd), + cwd: turn_context.cwd.clone(), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1507,7 +1506,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: PathUri::from_abs_path(&turn_context.cwd), + cwd: turn_context.cwd.clone(), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 96044ed3ad..b7ddb90b1c 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1874,8 +1874,7 @@ async fn resumed_history_injects_initial_context_on_first_context_update_only() session .record_context_updates_and_set_reference_context_item(&turn_context) - .await - .expect("context updates should hydrate"); + .await; let initial_context = session.build_initial_context(&turn_context).await; expected.extend(initial_context); let history_after_seed = session.clone_history().await; @@ -1883,8 +1882,7 @@ async fn resumed_history_injects_initial_context_on_first_context_update_only() session .record_context_updates_and_set_reference_context_item(&turn_context) - .await - .expect("context updates should hydrate"); + .await; let history_after_second_seed = session.clone_history().await; assert_eq!( history_after_seed.raw_items(), @@ -2675,7 +2673,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: PathUri::from_abs_path(&turn_context.cwd), + cwd: turn_context.cwd.clone(), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -7292,8 +7290,7 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes( let reference_context_item = previous_context.to_turn_context_item(); let update_items = session .build_settings_update_items(Some(&reference_context_item), ¤t_context) - .await - .expect("settings updates should hydrate"); + .await; let environment_update = user_input_texts(&update_items) .into_iter() @@ -7363,8 +7360,7 @@ async fn build_settings_update_items_emits_environment_item_for_time_changes() { let reference_context_item = previous_context.to_turn_context_item(); let update_items = session .build_settings_update_items(Some(&reference_context_item), ¤t_context) - .await - .expect("settings updates should hydrate"); + .await; let environment_update = user_input_texts(&update_items) .into_iter() @@ -7392,8 +7388,7 @@ async fn build_settings_update_items_omits_environment_item_when_disabled() { let reference_context_item = previous_context.to_turn_context_item(); let update_items = session .build_settings_update_items(Some(&reference_context_item), ¤t_context) - .await - .expect("settings updates should hydrate"); + .await; let user_texts = user_input_texts(&update_items); assert!( @@ -7421,8 +7416,7 @@ async fn build_settings_update_items_emits_realtime_start_when_session_becomes_l Some(&previous_context.to_turn_context_item()), ¤t_context, ) - .await - .expect("settings updates should hydrate"); + .await; let developer_texts = developer_input_texts(&update_items); assert!( @@ -7450,8 +7444,7 @@ async fn build_settings_update_items_emits_realtime_end_when_session_stops_being Some(&previous_context.to_turn_context_item()), ¤t_context, ) - .await - .expect("settings updates should hydrate"); + .await; let developer_texts = developer_input_texts(&update_items); assert!( @@ -7485,8 +7478,7 @@ async fn build_settings_update_items_uses_previous_turn_settings_for_realtime_en .await; let update_items = session .build_settings_update_items(Some(&previous_context_item), ¤t_context) - .await - .expect("settings updates should hydrate"); + .await; let developer_texts = developer_input_texts(&update_items); assert!( @@ -8139,18 +8131,20 @@ async fn turn_context_item_uses_turn_context_comp_hash_snapshot() { } #[tokio::test] -async fn turn_context_item_stores_primary_environment_cwd_uri() { +async fn turn_context_item_stores_local_cwd() { let (_session, mut turn_context) = make_session_and_context().await; let environment = turn_context.environments.turn_environments[0].clone(); let cwd = PathUri::parse("file:///C:/windows").expect("Windows cwd URI"); turn_context.environments.turn_environments[0] = TurnEnvironment::new( "remote".to_string(), environment.environment, - cwd.clone(), + cwd, environment.shell, ); - assert_eq!(turn_context.to_turn_context_item().cwd, cwd); + #[allow(deprecated)] + let local_cwd = turn_context.cwd.clone(); + assert_eq!(turn_context.to_turn_context_item().cwd, local_cwd); } #[tokio::test] @@ -8194,8 +8188,7 @@ async fn record_context_updates_and_set_reference_context_item_injects_full_cont let (session, turn_context) = make_session_and_context().await; session .record_context_updates_and_set_reference_context_item(&turn_context) - .await - .expect("context updates should hydrate"); + .await; let history = session.clone_history().await; let initial_context = session.build_initial_context(&turn_context).await; assert_eq!(history.raw_items().to_vec(), initial_context); @@ -8226,8 +8219,7 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co .await; session .record_context_updates_and_set_reference_context_item(&turn_context) - .await - .expect("context updates should hydrate"); + .await; { let mut state = session.state.lock().await; state.set_reference_context_item(/*item*/ None); @@ -8241,8 +8233,7 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co session .record_context_updates_and_set_reference_context_item(&turn_context) - .await - .expect("context updates should hydrate"); + .await; let history = session.clone_history().await; let mut expected_history = vec![compacted_summary]; @@ -8272,14 +8263,12 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline let update_items = session .build_settings_update_items(Some(&previous_context_item), &turn_context) - .await - .expect("settings updates should hydrate"); + .await; assert_eq!(update_items, Vec::new()); session .record_context_updates_and_set_reference_context_item(&turn_context) - .await - .expect("context updates should hydrate"); + .await; assert_eq!( session.clone_history().await.raw_items().to_vec(), @@ -8326,8 +8315,7 @@ async fn record_context_updates_and_set_reference_context_item_persists_split_fi session .record_context_updates_and_set_reference_context_item(&turn_context) - .await - .expect("context updates should hydrate"); + .await; session.ensure_rollout_materialized().await; session.flush_rollout().await.expect("rollout should flush"); @@ -8411,8 +8399,7 @@ async fn record_context_updates_and_set_reference_context_item_persists_full_rei .await; session .record_context_updates_and_set_reference_context_item(&turn_context) - .await - .expect("context updates should hydrate"); + .await; session.ensure_rollout_materialized().await; session.flush_rollout().await.expect("rollout should flush"); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index d70a9cc8de..4b052cb659 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -159,16 +159,8 @@ pub(crate) async fn run_turn( return None; } - if let Err(err) = sess - .record_context_updates_and_set_reference_context_item(turn_context.as_ref()) - .await - { - let error = err.to_codex_protocol_error(); - sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) - .await; - error!(%err, "failed to hydrate persisted turn context"); - return None; - } + sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref()) + .await; let (injection_items, explicitly_enabled_connectors) = build_skills_and_plugins(&sess, turn_context.as_ref(), &input, &cancellation_token).await?; diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 25ad962fd4..526a16bacc 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -396,14 +396,8 @@ impl TurnContext { pub(crate) fn to_turn_context_item(&self) -> TurnContextItem { let workspace_roots = self.config.effective_workspace_roots(); - let cwd = self - .environments - .primary() - .map(|environment| environment.cwd().clone()) - .unwrap_or_else(|| { - #[allow(deprecated)] - PathUri::from_abs_path(&self.cwd) - }); + #[allow(deprecated)] + let cwd = self.cwd.clone(); TurnContextItem { turn_id: Some(self.sub_id.clone()), cwd, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 001c112995..546219ce90 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -14,7 +14,6 @@ use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use codex_protocol::protocol::WarningEvent; -use codex_utils_path_uri::PathUri; use core::time::Duration; use core_test_support::load_default_config_for_test; use core_test_support::wait_for_event; @@ -28,7 +27,7 @@ fn resume_history( let turn_id = "resume-warning-seed-turn".to_string(); let turn_ctx = TurnContextItem { turn_id: Some(turn_id.clone()), - cwd: PathUri::from_abs_path(&config.cwd), + cwd: config.cwd.clone(), workspace_roots: None, current_date: None, timezone: None, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index c7ab8cfa0a..482785c5cf 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1423,11 +1423,7 @@ async fn parse_latest_turn_context_cwd(path: &Path) -> Option { continue; }; if let RolloutItem::TurnContext(item) = rollout_line.item { - return item - .cwd - .to_abs_path() - .ok() - .map(AbsolutePathBuf::into_path_buf); + return Some(item.cwd.into_path_buf()); } } None diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index b3ebbff171..0a9271c9f0 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2988,7 +2988,7 @@ pub struct TurnContextNetworkItem { pub struct TurnContextItem { #[serde(default, skip_serializing_if = "Option::is_none")] pub turn_id: Option, - pub cwd: PathUri, + pub cwd: AbsolutePathBuf, /// Effective workspace roots used to materialize symbolic /// `:workspace_roots` filesystem permissions in `permission_profile`. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -3026,35 +3026,21 @@ pub struct TurnContextItem { } impl TurnContextItem { - pub fn permission_profile(&self) -> std::io::Result { - if let Some(permission_profile) = self.permission_profile.clone() { - return Ok(permission_profile); - } - let file_system_sandbox_policy = match self.file_system_sandbox_policy.clone() { - Some(file_system_sandbox_policy) => file_system_sandbox_policy, - None => { - let cwd = self.cwd.to_abs_path().map_err(|err| { - std::io::Error::new( - err.kind(), - format!( - "cannot hydrate legacy permission profile for cwd {}: {err}", - self.cwd - ), + pub fn permission_profile(&self) -> PermissionProfile { + self.permission_profile.clone().unwrap_or_else(|| { + let file_system_sandbox_policy = + self.file_system_sandbox_policy.clone().unwrap_or_else(|| { + FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( + &self.sandbox_policy, + self.cwd.as_path(), ) - })?; - FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( - &self.sandbox_policy, - cwd.as_path(), - ) - } - }; - Ok( + }); PermissionProfile::from_runtime_permissions_with_enforcement( SandboxEnforcement::from_legacy_sandbox_policy(&self.sandbox_policy), &file_system_sandbox_policy, NetworkSandboxPolicy::from(&self.sandbox_policy), - ), - ) + ) + }) } } @@ -5315,50 +5301,21 @@ mod tests { } #[test] - fn turn_context_item_accepts_legacy_cwd_and_serializes_path_uri() -> Result<()> { - let legacy_cwd = test_path_buf("/tmp"); + fn turn_context_item_deserializes_without_network() -> Result<()> { let item: TurnContextItem = serde_json::from_value(json!({ - "cwd": legacy_cwd, + "cwd": test_path_buf("/tmp"), "approval_policy": "never", "sandbox_policy": { "type": "danger-full-access" }, "model": "gpt-5", "summary": "auto", }))?; - let expected_cwd = PathUri::from_path(legacy_cwd)?; - assert_eq!(item.cwd, expected_cwd); - assert_eq!( - serde_json::to_value(&item)?["cwd"], - json!(expected_cwd.to_string()) - ); assert_eq!(item.network, None); assert_eq!(item.file_system_sandbox_policy, None); assert_eq!(item.comp_hash, None); Ok(()) } - #[test] - fn turn_context_item_rejects_legacy_policy_hydration_for_foreign_cwd() -> Result<()> { - let foreign_cwd = if cfg!(windows) { - "file:///tmp" - } else { - "file://server/share" - }; - let item: TurnContextItem = serde_json::from_value(json!({ - "cwd": foreign_cwd, - "approval_policy": "never", - "sandbox_policy": { "type": "workspace-write" }, - "model": "gpt-5", - "summary": "auto", - }))?; - - let err = item - .permission_profile() - .expect_err("foreign cwd should not hydrate a legacy permission profile"); - assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); - Ok(()) - } - #[test] fn multi_agent_version_uses_newest_present_session_meta_value() -> Result<()> { let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; @@ -5396,7 +5353,7 @@ mod tests { fn turn_context_item_serializes_network_when_present() -> Result<()> { let item = TurnContextItem { turn_id: None, - cwd: PathUri::from_abs_path(&test_path_buf("/tmp").abs()), + cwd: test_path_buf("/tmp").abs(), workspace_roots: None, current_date: None, timezone: None, diff --git a/codex-rs/rollout/src/metadata.rs b/codex-rs/rollout/src/metadata.rs index 61d89f09d7..dcb38b6d2d 100644 --- a/codex-rs/rollout/src/metadata.rs +++ b/codex-rs/rollout/src/metadata.rs @@ -111,7 +111,7 @@ pub async fn extract_metadata_from_rollout( })?; let mut metadata = builder.build(default_provider); for item in &items { - apply_rollout_item(&mut metadata, item, default_provider)?; + apply_rollout_item(&mut metadata, item, default_provider); } if let Some(updated_at) = file_modified_time_utc(rollout_path).await { metadata.updated_at = updated_at; diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index e28ae84df7..01e56f569d 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -69,8 +69,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() { let builder = builder_from_session_meta(&session_meta_line, path.as_path()).expect("builder"); let mut expected = builder.build("openai"); - apply_rollout_item(&mut expected, &rollout_line.item, "openai") - .expect("rollout item should apply"); + apply_rollout_item(&mut expected, &rollout_line.item, "openai"); expected.updated_at = file_modified_time_utc(&path).await.expect("mtime"); expected.recency_at = expected.updated_at; diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 113ee372e1..7ba111e944 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -1827,7 +1827,6 @@ async fn resume_candidate_matches_cwd( | RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) => None, }) - && let Ok(latest_turn_context_cwd) = latest_turn_context_cwd.to_abs_path() { return cwd_matches(latest_turn_context_cwd.as_path(), cwd); } diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index f2745a553a..4d25965c44 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -16,10 +16,10 @@ pub fn apply_rollout_item( metadata: &mut ThreadMetadata, item: &RolloutItem, default_provider: &str, -) -> std::io::Result<()> { +) { match item { RolloutItem::SessionMeta(meta_line) => apply_session_meta_from_item(metadata, meta_line), - RolloutItem::TurnContext(turn_ctx) => apply_turn_context(metadata, turn_ctx)?, + RolloutItem::TurnContext(turn_ctx) => apply_turn_context(metadata, turn_ctx), RolloutItem::EventMsg(event) => apply_event_msg(metadata, event), RolloutItem::ResponseItem(item) => apply_response_item(metadata, item), RolloutItem::InterAgentCommunication(_) => {} @@ -28,7 +28,6 @@ pub fn apply_rollout_item( if metadata.model_provider.is_empty() { metadata.model_provider = default_provider.to_string(); } - Ok(()) } /// Return whether this rollout item can mutate thread metadata stored in SQLite. @@ -73,21 +72,15 @@ fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &Sessi } } -fn apply_turn_context( - metadata: &mut ThreadMetadata, - turn_ctx: &TurnContextItem, -) -> std::io::Result<()> { - if metadata.cwd.as_os_str().is_empty() - && let Ok(cwd) = turn_ctx.cwd.to_abs_path() - { - metadata.cwd = cwd.into_path_buf(); +fn apply_turn_context(metadata: &mut ThreadMetadata, turn_ctx: &TurnContextItem) { + if metadata.cwd.as_os_str().is_empty() { + metadata.cwd = turn_ctx.cwd.clone().into_path_buf(); } metadata.model = Some(turn_ctx.model.clone()); metadata.reasoning_effort = turn_ctx.effort.clone(); metadata.sandbox_policy = - serde_json::to_string(&turn_ctx.permission_profile()?).unwrap_or_default(); + serde_json::to_string(&turn_ctx.permission_profile()).unwrap_or_default(); metadata.approval_mode = enum_to_string(&turn_ctx.approval_policy); - Ok(()) } fn apply_event_msg(metadata: &mut ThreadMetadata, event: &EventMsg) { @@ -201,8 +194,7 @@ mod tests { metadata: None, }); - apply_rollout_item(&mut metadata, &item, "test-provider") - .expect("rollout item should apply"); + apply_rollout_item(&mut metadata, &item, "test-provider"); assert_eq!(metadata.first_user_message, None); assert_eq!(metadata.preview, None); @@ -221,8 +213,7 @@ mod tests { ..Default::default() })); - apply_rollout_item(&mut metadata, &item, "test-provider") - .expect("rollout item should apply"); + apply_rollout_item(&mut metadata, &item, "test-provider"); assert_eq!( metadata.first_user_message.as_deref(), @@ -244,8 +235,7 @@ mod tests { ..Default::default() })); - apply_rollout_item(&mut metadata, &item, "test-provider") - .expect("rollout item should apply"); + apply_rollout_item(&mut metadata, &item, "test-provider"); assert_eq!( metadata.first_user_message.as_deref(), @@ -270,8 +260,7 @@ mod tests { ..Default::default() })); - apply_rollout_item(&mut metadata, &item, "test-provider") - .expect("rollout item should apply"); + apply_rollout_item(&mut metadata, &item, "test-provider"); assert_eq!(metadata.first_user_message, None); assert_eq!(metadata.preview, None); @@ -297,8 +286,7 @@ mod tests { }, })); - apply_rollout_item(&mut metadata, &goal_item, "test-provider") - .expect("rollout item should apply"); + apply_rollout_item(&mut metadata, &goal_item, "test-provider"); assert_eq!(metadata.preview.as_deref(), Some("optimize the benchmark")); assert_eq!(metadata.first_user_message, None); @@ -313,8 +301,7 @@ mod tests { ..Default::default() })); - apply_rollout_item(&mut metadata, &user_item, "test-provider") - .expect("rollout item should apply"); + apply_rollout_item(&mut metadata, &user_item, "test-provider"); assert_eq!(metadata.preview.as_deref(), Some("optimize the benchmark")); assert_eq!( @@ -357,8 +344,7 @@ mod tests { git: None, }), "test-provider", - ) - .expect("rollout item should apply"); + ); apply_rollout_item( &mut metadata, &RolloutItem::TurnContext(TurnContextItem { @@ -387,8 +373,7 @@ mod tests { summary: codex_protocol::config_types::ReasoningSummary::Auto, }), "test-provider", - ) - .expect("rollout item should apply"); + ); assert_eq!(metadata.cwd, PathBuf::from("/child/worktree")); let permission_profile: PermissionProfile = PermissionProfile::Disabled; @@ -432,8 +417,7 @@ mod tests { summary: codex_protocol::config_types::ReasoningSummary::Auto, }), "test-provider", - ) - .expect("rollout item should apply"); + ); assert_eq!( metadata.sandbox_policy, @@ -473,8 +457,7 @@ mod tests { summary: codex_protocol::config_types::ReasoningSummary::Auto, }), "test-provider", - ) - .expect("rollout item should apply"); + ); assert_eq!(metadata.cwd, fallback_cwd); } @@ -511,8 +494,7 @@ mod tests { summary: codex_protocol::config_types::ReasoningSummary::Auto, }), "test-provider", - ) - .expect("rollout item should apply"); + ); assert_eq!(metadata.model.as_deref(), Some("gpt-5")); assert_eq!(metadata.reasoning_effort, Some(ReasoningEffort::High)); @@ -548,8 +530,7 @@ mod tests { git: None, }), "test-provider", - ) - .expect("rollout item should apply"); + ); assert_eq!(metadata.model, None); assert_eq!(metadata.reasoning_effort, None); diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 7bf3544373..576f760264 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -883,7 +883,7 @@ ON CONFLICT(id) DO UPDATE SET .unwrap_or_else(|| builder.build(&self.default_provider)); metadata.rollout_path = builder.rollout_path.clone(); for item in items { - apply_rollout_item(&mut metadata, item, &self.default_provider)?; + apply_rollout_item(&mut metadata, item, &self.default_provider); } if let Some(existing_metadata) = existing_metadata.as_ref() { metadata.prefer_existing_git_info(existing_metadata); diff --git a/codex-rs/thread-store/src/live_thread.rs b/codex-rs/thread-store/src/live_thread.rs index 0e81b165ad..88d347f57b 100644 --- a/codex-rs/thread-store/src/live_thread.rs +++ b/codex-rs/thread-store/src/live_thread.rs @@ -125,7 +125,7 @@ impl LiveThread { } } } - let metadata_sync = ThreadMetadataSync::for_resume(¶ms)?; + let metadata_sync = ThreadMetadataSync::for_resume(¶ms); Ok(Self { thread_id, thread_store, @@ -151,7 +151,7 @@ impl LiveThread { .metadata_sync .lock() .await - .observe_appended_items(canonical_items.as_slice())?; + .observe_appended_items(canonical_items.as_slice()); if let Some(update) = update { self.thread_store .update_thread_metadata(UpdateThreadMetadataParams { diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index 6705f45742..b2953d7ef9 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -18,8 +18,6 @@ use crate::CreateThreadParams; use crate::GitInfoPatch; use crate::ResumeThreadParams; use crate::ThreadMetadataPatch; -use crate::ThreadStoreError; -use crate::ThreadStoreResult; const IMAGE_ONLY_USER_MESSAGE_PLACEHOLDER: &str = "[Image]"; #[cfg(not(test))] @@ -92,7 +90,7 @@ impl ThreadMetadataSync { } } - pub(crate) fn for_resume(params: &ResumeThreadParams) -> ThreadStoreResult { + pub(crate) fn for_resume(params: &ResumeThreadParams) -> Self { let mut sync = Self { thread_id: params.thread_id, cwd_seen: params @@ -110,11 +108,11 @@ impl ThreadMetadataSync { defer_resume_update_until_append: false, }; if let Some(history) = params.history.as_deref() { - let update = sync.observe_resume_history(history)?; + let update = sync.observe_resume_history(history); sync.merge_pending_update(update); sync.defer_resume_update_until_append = sync.pending_update.is_some(); } - Ok(sync) + sync } pub(crate) fn take_pending_update(&self) -> Option { @@ -150,7 +148,7 @@ impl ThreadMetadataSync { pub(crate) fn observe_appended_items( &mut self, items: &[RolloutItem], - ) -> ThreadStoreResult> { + ) -> Option { self.defer_create_update_until_history_exists = false; self.defer_resume_update_until_append = false; let affects_metadata = items @@ -162,10 +160,7 @@ impl ThreadMetadataSync { let mut update = if affects_metadata { self.observe_items(items)? } else { - Some(thread_updated_at_touch()) - }; - let Some(update) = update else { - return Ok(None); + thread_updated_at_touch() }; if advances_recency { update.advance_recency_at = Some(Utc::now()); @@ -180,15 +175,12 @@ impl ThreadMetadataSync { Instant::now().duration_since(last_touch) < THREAD_UPDATED_AT_TOUCH_INTERVAL }) { - return Ok(None); + return None; } - Ok(self.take_pending_update()) + self.take_pending_update() } - fn observe_items( - &mut self, - items: &[RolloutItem], - ) -> ThreadStoreResult> { + fn observe_items(&mut self, items: &[RolloutItem]) -> Option { self.observe_items_with_update( items, ThreadMetadataPatch { @@ -198,10 +190,7 @@ impl ThreadMetadataSync { ) } - fn observe_resume_history( - &mut self, - items: &[RolloutItem], - ) -> ThreadStoreResult> { + fn observe_resume_history(&mut self, items: &[RolloutItem]) -> Option { self.observe_items_with_update(items, ThreadMetadataPatch::default()) } @@ -209,9 +198,9 @@ impl ThreadMetadataSync { &mut self, items: &[RolloutItem], mut update: ThreadMetadataPatch, - ) -> ThreadStoreResult> { + ) -> Option { if items.is_empty() { - return Ok(None); + return None; } for item in items { match item { @@ -244,23 +233,14 @@ impl ThreadMetadataSync { } } RolloutItem::TurnContext(turn_ctx) => { - if !self.cwd_seen - && let Ok(cwd) = turn_ctx.cwd.to_abs_path() - { + if !self.cwd_seen { self.cwd_seen = true; - update.cwd = Some(cwd.into_path_buf()); + update.cwd = Some(turn_ctx.cwd.clone().into_path_buf()); } update.model = Some(turn_ctx.model.clone()); update.reasoning_effort = turn_ctx.effort.clone(); update.approval_mode = Some(turn_ctx.approval_policy); - update.permission_profile = - Some(turn_ctx.permission_profile().map_err(|err| { - ThreadStoreError::Internal { - message: format!( - "failed to hydrate turn permission profile: {err}" - ), - } - })?); + update.permission_profile = Some(turn_ctx.permission_profile()); } RolloutItem::EventMsg(EventMsg::UserMessage(user)) => { if let Some(preview) = user_message_preview(user) { @@ -302,7 +282,7 @@ impl ThreadMetadataSync { | RolloutItem::Compacted(_) => {} } } - Ok(Some(update)) + Some(update) } fn merge_pending_update(&mut self, update: Option) { @@ -422,8 +402,7 @@ mod tests { RolloutItem::SessionMeta(session_meta(thread_id)), RolloutItem::EventMsg(EventMsg::UserMessage(user_message("hello metadata"))), ], - )) - .expect("resume metadata should hydrate"); + )); let update = sync.take_pending_update().expect("pending metadata update"); assert_eq!( @@ -462,8 +441,7 @@ mod tests { ))), RolloutItem::EventMsg(EventMsg::UserMessage(user_message("first user text"))), ], - )) - .expect("resume metadata should hydrate"); + )); let update = sync.take_pending_update().expect("pending metadata update"); assert_eq!(update.patch.preview.as_deref(), Some("ship the refactor")); @@ -482,8 +460,7 @@ mod tests { vec![RolloutItem::EventMsg(EventMsg::UserMessage(user_message( "first user text", )))], - )) - .expect("resume metadata should hydrate"); + )); let pending = sync.take_pending_update().expect("pending resume metadata"); sync.mark_pending_update_applied(&pending); @@ -491,7 +468,6 @@ mod tests { .observe_appended_items(&[RolloutItem::EventMsg(EventMsg::UserMessage(user_message( "later user text", )))]) - .expect("appended metadata should hydrate") .expect("updated_at touch"); assert_eq!(update.patch.preview, None); @@ -503,8 +479,7 @@ mod tests { #[test] fn metadata_irrelevant_items_coalesce_updated_at_touches() { let thread_id = ThreadId::new(); - let mut sync = ThreadMetadataSync::for_resume(&resume_params(thread_id, Vec::new())) - .expect("resume metadata should hydrate"); + let mut sync = ThreadMetadataSync::for_resume(&resume_params(thread_id, Vec::new())); let item = RolloutItem::Compacted(CompactedItem { message: "compacted".to_string(), replacement_history: None, @@ -513,14 +488,12 @@ mod tests { let first = sync .observe_appended_items(std::slice::from_ref(&item)) - .expect("appended metadata should hydrate") .expect("first touch should apply immediately"); assert!(first.patch.updated_at.is_some()); sync.mark_pending_update_applied(&first); assert!( sync.observe_appended_items(std::slice::from_ref(&item)) - .expect("appended metadata should hydrate") .is_none(), "second touch inside the coalescing window should wait for a barrier" ); @@ -560,8 +533,7 @@ mod tests { RolloutItem::SessionMeta(session_meta(thread_id)), RolloutItem::EventMsg(EventMsg::UserMessage(user_message("hello metadata"))), ], - )) - .expect("resume metadata should hydrate"); + )); assert!( sync.take_pending_update_for_existing_history().is_none(), @@ -571,7 +543,6 @@ mod tests { sync.observe_appended_items(&[RolloutItem::EventMsg(EventMsg::UserMessage( user_message("new append"), ))]) - .expect("appended metadata should hydrate") .is_some(), "the first append should flush resume metadata together with append metadata" ); From 6f77491e954cd23eeb2202a46aa4814c0dce5bad Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 16 Jun 2026 19:16:16 -0700 Subject: [PATCH 18/70] thread-store: fix response fixture compilation (#28642) ## Why A `codex-thread-store` test fixture still constructs `ResponseItem::FunctionCallOutput` without its required `metadata` field, preventing the crate's test targets from compiling on `main`. ## What changed - Set the fixture's response-item metadata to `None`. ## Testing - `cargo check -p codex-thread-store --tests` --- codex-rs/thread-store/src/local/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 5670e5638c..550e7d56bb 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -512,6 +512,7 @@ mod tests { RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput { call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_text("tool output".to_string()), + metadata: None, }), RolloutItem::EventMsg(EventMsg::TokenCount( codex_protocol::protocol::TokenCountEvent { From 1883dedc0e3499c8f42e08835540319ad7131d77 Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Tue, 16 Jun 2026 19:22:57 -0700 Subject: [PATCH 19/70] [codex] Support object-valued plugin MCP manifests (#28580) ## Summary This fixes plugin manifest parsing for MCP servers declared as an object directly in `plugin.json`. Before this change, Codex modeled `mcpServers` as only a string path, for example: ```json { "name": "counter-sample", "version": "1.1.1", "mcpServers": "./.mcp.json" } ``` Some migrated plugins instead provide the server map directly in the manifest: ```json { "name": "counter-sample", "version": "1.1.1", "description": "Plugin that declares MCP servers in the manifest", "mcpServers": { "counter": { "type": "http", "url": "https://sample.example/counter/mcp" } } } ``` That object form previously failed during install/load with an error like: ```text failed to parse plugin manifest: invalid type: map, expected a string ``` ## What changed - Add a manifest representation for `mcpServers` as either `Path(Resource)` or `Object(map)`. - Parse `plugin.json` `mcpServers` as either a string path or an object. - Route object-valued MCP server maps through the existing plugin MCP config parser instead of adding a second parser. - Apply existing per-plugin MCP server policy to object-valued MCP servers the same way as file-backed MCP servers. - Include object-valued MCP server names in plugin telemetry/capability metadata. - Support object-valued MCP config for executor plugins without requiring a `.mcp.json` filesystem read. - Update the bundled plugin-creator validator and `plugin-json-spec.md` so generated-plugin validation accepts the same object-valued shape. ## Compatibility Existing plugin manifests that use `"mcpServers": "./.mcp.json"` continue to work. Plugins can now also use the object shape shown above. ## Tests Added coverage for the new manifest attribute shape at the install, normal load, telemetry, and executor-provider layers: - `install_accepts_manifest_mcp_server_objects` - `load_plugins_loads_manifest_mcp_server_objects` - `plugin_telemetry_metadata_uses_manifest_mcp_server_objects` - `reads_manifest_object_config_without_executor_file_system_access` Also smoke-tested the plugin-creator validator against both supported forms: - `mcpServers` as a direct object in `plugin.json` - `mcpServers` as `"./.mcp.json"` with a companion `.mcp.json` ## Validation - `just test -p codex-plugin` - `just test -p codex-core-plugins` - `just test -p codex-mcp-extension` - `just bazel-lock-update` - `just bazel-lock-check` - `just fmt` - `git diff --check` - Focused rename/object-form rerun: `just test -p codex-core-plugins manager::tests::load_plugins_loads_manifest_mcp_server_objects manager::tests::plugin_telemetry_metadata_uses_manifest_mcp_server_objects store::tests::install_accepts_manifest_mcp_server_objects` - Focused executor rerun: `just test -p codex-mcp-extension executor_plugin::provider::tests::reads_manifest_object_config_without_executor_file_system_access` - `python3 codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py /private/tmp/codex-validator-object` - `python3 codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py /private/tmp/codex-validator-path` --- codex-rs/core-plugins/src/loader.rs | 122 +++++++++++++----- codex-rs/core-plugins/src/manager_tests.rs | 105 +++++++++++++++ codex-rs/core-plugins/src/manifest.rs | 40 +++++- codex-rs/core-plugins/src/store_tests.rs | 40 ++++++ .../ext/mcp/src/executor_plugin/provider.rs | 60 ++++++--- .../mcp/src/executor_plugin/provider_tests.rs | 67 +++++++++- codex-rs/plugin/src/manifest.rs | 20 ++- codex-rs/plugin/src/provider_tests.rs | 10 +- .../references/plugin-json-spec.md | 30 ++++- .../plugin-creator/scripts/validate_plugin.py | 53 ++++++-- 10 files changed, 472 insertions(+), 75 deletions(-) diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index ba27209d90..fb542f045f 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -2,6 +2,7 @@ use crate::app_mcp_routing::apply_app_mcp_routing_policy; use crate::app_mcp_routing::apps_route_available; use crate::is_openai_curated_marketplace_name; use crate::manifest::PluginManifestHooks; +use crate::manifest::PluginManifestMcpServers; use crate::manifest::PluginManifestPaths; use crate::manifest::load_plugin_manifest; use crate::marketplace::MarketplacePluginSource; @@ -724,25 +725,12 @@ async fn load_plugin( let has_enabled_skills = resolved_skills.has_enabled_skills(); loaded_plugin.disabled_skill_paths = resolved_skills.disabled_skill_paths; loaded_plugin.has_enabled_skills = has_enabled_skills; - let mut mcp_servers = HashMap::new(); - for mcp_config_path in plugin_mcp_config_paths(plugin_root.as_path(), manifest_paths) { - let plugin_mcp = - load_mcp_servers_from_file(plugin_root.as_path(), &mcp_config_path).await; - for (name, mut config) in plugin_mcp.mcp_servers { - if let Some(policy) = plugin.mcp_servers.get(&name) { - apply_plugin_mcp_server_policy(&mut config, policy); - } - if mcp_servers.insert(name.clone(), config).is_some() { - warn!( - plugin = %plugin_root.display(), - path = %mcp_config_path.display(), - server = name, - "plugin MCP file overwrote an earlier server definition" - ); - } - } - } - loaded_plugin.mcp_servers = mcp_servers; + loaded_plugin.mcp_servers = load_plugin_mcp_servers_from_manifest( + plugin_root.as_path(), + manifest_paths, + Some(&plugin.mcp_servers), + ) + .await; loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await; } PluginLoadScope::HooksOnly => {} @@ -853,7 +841,7 @@ fn plugin_mcp_config_paths( plugin_root: &Path, manifest_paths: &PluginManifestPaths, ) -> Vec { - if let Some(path) = &manifest_paths.mcp_servers { + if let Some(PluginManifestMcpServers::Path(path)) = &manifest_paths.mcp_servers { return vec![path.clone()]; } default_mcp_config_paths(plugin_root) @@ -1094,15 +1082,14 @@ pub async fn plugin_telemetry_metadata_from_root( let manifest_paths = &manifest.paths; let has_skills = !plugin_skill_roots(plugin_root, manifest_paths).is_empty(); - let mut mcp_server_names = Vec::new(); - for path in plugin_mcp_config_paths(plugin_root.as_path(), manifest_paths) { - mcp_server_names.extend( - load_mcp_servers_from_file(plugin_root.as_path(), &path) - .await - .mcp_servers - .into_keys(), - ); - } + let mut mcp_server_names = load_plugin_mcp_servers_from_manifest( + plugin_root.as_path(), + manifest_paths, + /*plugin_policy*/ None, + ) + .await + .into_keys() + .collect::>(); mcp_server_names.sort_unstable(); mcp_server_names.dedup(); @@ -1151,11 +1138,49 @@ async fn load_declared_plugin_mcp_servers(plugin_root: &Path) -> HashMap>, +) -> HashMap { let mut mcp_servers = HashMap::new(); - for mcp_config_path in plugin_mcp_config_paths(plugin_root, &manifest.paths) { - let plugin_mcp = load_mcp_servers_from_file(plugin_root, &mcp_config_path).await; - for (name, config) in plugin_mcp.mcp_servers { - mcp_servers.entry(name).or_insert(config); + match &manifest_paths.mcp_servers { + Some(PluginManifestMcpServers::Object(object_servers)) => { + let plugin_mcp = load_mcp_servers_from_manifest_object(plugin_root, object_servers); + for (name, mut config) in plugin_mcp.mcp_servers { + if let Some(policy) = plugin_policy.and_then(|policy| policy.get(&name)) { + apply_plugin_mcp_server_policy(&mut config, policy); + } + if mcp_servers.insert(name.clone(), config).is_some() { + warn!( + plugin = %plugin_root.display(), + server = name, + "plugin manifest MCP object overwrote an earlier server definition" + ); + } + } + } + Some(PluginManifestMcpServers::Path(_)) | None => { + for mcp_config_path in plugin_mcp_config_paths(plugin_root, manifest_paths) { + let plugin_mcp = load_mcp_servers_from_file(plugin_root, &mcp_config_path).await; + for (name, mut config) in plugin_mcp.mcp_servers { + if let Some(policy) = plugin_policy.and_then(|policy| policy.get(&name)) { + apply_plugin_mcp_server_policy(&mut config, policy); + } + if mcp_servers.insert(name.clone(), config).is_some() { + warn!( + plugin = %plugin_root.display(), + path = %mcp_config_path.display(), + server = name, + "plugin MCP file overwrote an earlier server definition" + ); + } + } + } } } @@ -1212,6 +1237,37 @@ async fn load_mcp_servers_from_file( } } +fn load_mcp_servers_from_manifest_object( + plugin_root: &Path, + object_config: &str, +) -> PluginMcpDiscovery { + let parsed = match parse_plugin_mcp_config( + plugin_root, + object_config, + PluginMcpServerPlacement::Declared, + ) { + Ok(parsed) => parsed, + Err(err) => { + warn!( + plugin = %plugin_root.display(), + "failed to parse plugin manifest MCP object: {err}" + ); + return PluginMcpDiscovery::default(); + } + }; + for error in parsed.errors { + warn!( + plugin = %plugin_root.display(), + server = error.name, + error = error.message, + "failed to parse plugin manifest MCP object server" + ); + } + PluginMcpDiscovery { + mcp_servers: parsed.servers.into_iter().collect(), + } +} + #[derive(Debug, Default)] struct PluginMcpDiscovery { mcp_servers: HashMap, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 7c88d114a2..c2917211d2 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -682,6 +682,70 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() { ); } +#[tokio::test] +async fn load_plugins_loads_manifest_mcp_server_objects() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/counter-sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "counter-sample", + "version": "1.1.1", + "description": "Plugin that declares MCP servers in the manifest", + "mcpServers": { + "counter": { + "type": "http", + "url": "https://sample.example/counter/mcp" + } + } +}"#, + ); + + let config_toml = r#" +[features] +plugins = true + +[plugins."counter-sample@test"] +enabled = true +"#; + let outcome = + load_plugins_from_config(config_toml, codex_home.path(), /*auth_mode*/ None).await; + + assert_eq!(outcome.plugins()[0].error, None); + assert_eq!( + outcome.plugins()[0].mcp_servers, + HashMap::from([( + "counter".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://sample.example/counter/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + environment_id: "local".to_string(), + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth: None, + oauth_resource: None, + tools: HashMap::new(), + }, + )]) + ); +} + #[tokio::test] async fn load_plugins_applies_plugin_mcp_server_policy() { let codex_home = TempDir::new().unwrap(); @@ -1098,6 +1162,47 @@ async fn plugin_telemetry_metadata_uses_default_mcp_config_path() { ); } +#[tokio::test] +async fn plugin_telemetry_metadata_uses_manifest_mcp_server_objects() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/counter-sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "counter-sample", + "version": "1.1.1", + "mcpServers": { + "counter": { + "type": "http", + "url": "https://sample.example/counter/mcp" + } + } +}"#, + ); + + let metadata = plugin_telemetry_metadata_from_root( + &PluginId::parse("counter-sample@test").expect("plugin id should parse"), + &plugin_root.abs(), + ) + .await; + + assert_eq!( + metadata.capability_summary, + Some(PluginCapabilitySummary { + config_name: "counter-sample@test".to_string(), + display_name: "counter-sample".to_string(), + description: None, + has_skills: false, + mcp_server_names: vec!["counter".to_string()], + app_connector_ids: Vec::new(), + }) + ); +} + #[tokio::test] async fn capability_summary_sanitizes_plugin_descriptions_to_one_line() { let codex_home = TempDir::new().unwrap(); diff --git a/codex-rs/core-plugins/src/manifest.rs b/codex-rs/core-plugins/src/manifest.rs index 28a200ab86..fe0bb25644 100644 --- a/codex-rs/core-plugins/src/manifest.rs +++ b/codex-rs/core-plugins/src/manifest.rs @@ -12,6 +12,8 @@ const MAX_DEFAULT_PROMPT_LEN: usize = 128; pub type PluginManifest = codex_plugin::manifest::PluginManifest; pub type PluginManifestHooks = codex_plugin::manifest::PluginManifestHooks; pub type PluginManifestInterface = codex_plugin::manifest::PluginManifestInterface; +pub type PluginManifestMcpServers = + codex_plugin::manifest::PluginManifestMcpServers; pub type PluginManifestPaths = codex_plugin::manifest::PluginManifestPaths; #[derive(Debug, Default, Deserialize)] @@ -30,7 +32,7 @@ struct RawPluginManifest { #[serde(default)] skills: Option, #[serde(default)] - mcp_servers: Option, + mcp_servers: Option, #[serde(default)] apps: Option, #[serde(default)] @@ -97,6 +99,14 @@ enum RawPluginManifestPath { Invalid(JsonValue), } +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RawPluginManifestMcpServers { + Path(String), + Object(std::collections::BTreeMap), + Invalid(JsonValue), +} + #[derive(Debug, Deserialize)] #[serde(untagged)] enum RawPluginManifestHooks { @@ -221,7 +231,7 @@ pub(crate) fn parse_plugin_manifest( keywords, paths: PluginManifestPaths { skills: resolve_manifest_path_value(plugin_root, "skills", skills.as_ref()), - mcp_servers: resolve_manifest_path(plugin_root, "mcpServers", mcp_servers.as_deref()), + mcp_servers: resolve_manifest_mcp_servers(plugin_root, mcp_servers), apps: resolve_manifest_path(plugin_root, "apps", apps.as_deref()), hooks: resolve_manifest_hooks(plugin_root, hooks), }, @@ -259,6 +269,32 @@ fn resolve_manifest_hooks( } } +fn resolve_manifest_mcp_servers( + plugin_root: &Path, + mcp_servers: Option, +) -> Option { + match mcp_servers? { + RawPluginManifestMcpServers::Path(path) => { + resolve_manifest_path(plugin_root, "mcpServers", Some(&path)) + .map(PluginManifestMcpServers::Path) + } + RawPluginManifestMcpServers::Object(servers) => match serde_json::to_string(&servers) { + Ok(servers) => Some(PluginManifestMcpServers::Object(servers)), + Err(err) => { + tracing::warn!("ignoring mcpServers: failed to serialize object: {err}"); + None + } + }, + RawPluginManifestMcpServers::Invalid(value) => { + tracing::warn!( + "ignoring mcpServers: expected a string or object; found {}", + json_value_type(&value) + ); + None + } + } +} + fn resolve_interface_asset_path( plugin_root: &Path, field: &'static str, diff --git a/codex-rs/core-plugins/src/store_tests.rs b/codex-rs/core-plugins/src/store_tests.rs index 200055fe6c..6bf2b93688 100644 --- a/codex-rs/core-plugins/src/store_tests.rs +++ b/codex-rs/core-plugins/src/store_tests.rs @@ -71,6 +71,46 @@ fn install_copies_plugin_into_default_marketplace() { assert!(installed_path.join("skills/SKILL.md").is_file()); } +#[test] +fn install_accepts_manifest_mcp_server_objects() { + let tmp = tempdir().unwrap(); + let plugin_root = tmp.path().join("counter-sample"); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "counter-sample", + "version": "1.1.1", + "mcpServers": { + "counter": { + "type": "http", + "url": "https://sample.example/counter/mcp" + } + } +}"#, + ) + .unwrap(); + let plugin_id = PluginId::new("counter-sample".to_string(), "debug".to_string()).unwrap(); + + let result = PluginStore::new(tmp.path().to_path_buf()) + .install( + AbsolutePathBuf::try_from(plugin_root).unwrap(), + plugin_id.clone(), + ) + .unwrap(); + + let installed_path = tmp.path().join("plugins/cache/debug/counter-sample/1.1.1"); + assert_eq!( + result, + PluginInstallResult { + plugin_id, + plugin_version: "1.1.1".to_string(), + installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(), + } + ); + assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); +} + #[test] fn install_uses_manifest_name_for_destination_and_key() { let tmp = tempdir().unwrap(); diff --git a/codex-rs/ext/mcp/src/executor_plugin/provider.rs b/codex-rs/ext/mcp/src/executor_plugin/provider.rs index bbba63748b..e9d5f803db 100644 --- a/codex-rs/ext/mcp/src/executor_plugin/provider.rs +++ b/codex-rs/ext/mcp/src/executor_plugin/provider.rs @@ -7,6 +7,7 @@ use codex_mcp::parse_plugin_mcp_config; use codex_plugin::PluginResourceLocator; use codex_plugin::ResolvedPlugin; use codex_plugin::ResolvedPluginLocation; +use codex_plugin::manifest::PluginManifestMcpServers; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use std::io; @@ -56,26 +57,47 @@ async fn load_from_file_system( ) -> Result, ExecutorPluginMcpProviderError> { let ResolvedPluginLocation::Environment { environment_id, .. } = plugin.location(); let plugin_id = plugin.selected_root_id(); - let (config_path, is_default) = match plugin.manifest().paths.mcp_servers.as_ref() { - Some(PluginResourceLocator::Environment { path, .. }) => (path.clone(), false), - None => (plugin_root.join(DEFAULT_MCP_CONFIG_FILE), true), - }; - let config_uri = PathUri::from_abs_path(&config_path); - - let contents = match file_system - .read_file_text(&config_uri, /*sandbox*/ None) - .await - { - Ok(contents) => contents, - Err(source) if is_default && source.kind() == io::ErrorKind::NotFound => { - return Ok(Vec::new()); + let (contents, config_path) = match plugin.manifest().paths.mcp_servers.as_ref() { + Some(PluginManifestMcpServers::Path(PluginResourceLocator::Environment { + path, .. + })) => { + let config_uri = PathUri::from_abs_path(path); + ( + file_system + .read_file_text(&config_uri, /*sandbox*/ None) + .await + .map_err(|source| ExecutorPluginMcpProviderError::ReadConfig { + plugin_id: plugin_id.to_string(), + path: path.clone(), + source, + })?, + path.clone(), + ) } - Err(source) => { - return Err(ExecutorPluginMcpProviderError::ReadConfig { - plugin_id: plugin_id.to_string(), - path: config_path.clone(), - source, - }); + Some(PluginManifestMcpServers::Object(object_config)) => ( + object_config.clone(), + plugin_root.join(".codex-plugin/plugin.json"), + ), + None => { + let config_path = plugin_root.join(DEFAULT_MCP_CONFIG_FILE); + let config_uri = PathUri::from_abs_path(&config_path); + let contents = match file_system + .read_file_text(&config_uri, /*sandbox*/ None) + .await + { + Ok(contents) => contents, + Err(source) if source.kind() == io::ErrorKind::NotFound => { + return Ok(Vec::new()); + } + Err(source) => { + return Err(ExecutorPluginMcpProviderError::ReadConfig { + plugin_id: plugin_id.to_string(), + path: config_path.clone(), + source, + }); + } + }; + (contents, config_path) } }; let parsed = parse_plugin_mcp_config( diff --git a/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs b/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs index 3bc9ef5796..dc6a0403d0 100644 --- a/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs +++ b/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs @@ -15,6 +15,7 @@ use codex_exec_server::ReadDirectoryEntry; use codex_exec_server::RemoveOptions; use codex_plugin::ResolvedPlugin; use codex_plugin::manifest::PluginManifest; +use codex_plugin::manifest::PluginManifestMcpServers; use codex_plugin::manifest::PluginManifestPaths; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -144,7 +145,10 @@ async fn reads_declared_config_only_through_executor_file_system() { .expect("absolute plugin root"); assert!(!plugin_root.as_path().exists()); let config_path = plugin_root.join("config/mcp.json"); - let plugin = resolved_plugin(&plugin_root, Some(config_path.clone())); + let plugin = resolved_plugin( + &plugin_root, + Some(PluginManifestMcpServers::Path(config_path.clone())), + ); let file_system = SyntheticExecutorFileSystem { config_path: config_path.clone(), config_contents: Some(MCP_CONFIG_CONTENTS), @@ -187,6 +191,60 @@ async fn reads_declared_config_only_through_executor_file_system() { assert_eq!(reads(&file_system), vec![config_path]); } +#[tokio::test] +async fn reads_manifest_object_config_without_executor_file_system_access() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let plugin_root = AbsolutePathBuf::from_absolute_path_checked(temp_dir.path().join("plugin")) + .expect("absolute plugin root"); + let config_path = plugin_root.join(DEFAULT_MCP_CONFIG_FILE); + let plugin = resolved_plugin( + &plugin_root, + Some(PluginManifestMcpServers::Object( + r#"{"counter":{"command":"counter-mcp","environment_id":"local"}}"#.to_string(), + )), + ); + let file_system = SyntheticExecutorFileSystem { + config_path, + config_contents: None, + reads: Mutex::new(Vec::new()), + }; + + let servers = load_from_file_system(&plugin, &plugin_root, &file_system) + .await + .expect("load manifest object executor MCP config"); + + assert_eq!( + servers, + vec![( + "counter".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "counter-mcp".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: Some(plugin_root.to_path_buf()), + }, + environment_id: "executor-test".to_string(), + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth: None, + oauth_resource: None, + tools: HashMap::new(), + }, + )] + ); + assert_eq!(reads(&file_system), Vec::new()); +} + #[tokio::test] async fn missing_default_config_is_empty() { let temp_dir = tempfile::tempdir().expect("tempdir"); @@ -214,7 +272,10 @@ async fn malformed_declared_config_is_an_error() { let plugin_root = AbsolutePathBuf::from_absolute_path_checked(temp_dir.path().join("plugin")) .expect("absolute plugin root"); let config_path = plugin_root.join("mcp.json"); - let plugin = resolved_plugin(&plugin_root, Some(config_path.clone())); + let plugin = resolved_plugin( + &plugin_root, + Some(PluginManifestMcpServers::Path(config_path.clone())), + ); let file_system = SyntheticExecutorFileSystem { config_path: config_path.clone(), config_contents: Some("{not-json"), @@ -242,7 +303,7 @@ async fn malformed_declared_config_is_an_error() { fn resolved_plugin( plugin_root: &AbsolutePathBuf, - mcp_servers: Option, + mcp_servers: Option>, ) -> ResolvedPlugin { ResolvedPlugin::from_environment( "selected-root".to_string(), diff --git a/codex-rs/plugin/src/manifest.rs b/codex-rs/plugin/src/manifest.rs index b1f04c4c12..4aeec49be8 100644 --- a/codex-rs/plugin/src/manifest.rs +++ b/codex-rs/plugin/src/manifest.rs @@ -18,11 +18,18 @@ pub struct PluginManifest { #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginManifestPaths { pub skills: Option, - pub mcp_servers: Option, + pub mcp_servers: Option>, pub apps: Option, pub hooks: Option>, } +/// MCP server declarations embedded in or referenced by a plugin manifest. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginManifestMcpServers { + Path(Resource), + Object(String), +} + /// Hook declarations embedded in or referenced by a plugin manifest. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PluginManifestHooks { @@ -109,6 +116,15 @@ impl PluginManifest { Some(PluginManifestHooks::Inline(hooks)) => Some(PluginManifestHooks::Inline(hooks)), None => None, }; + let mcp_servers = match mcp_servers { + Some(PluginManifestMcpServers::Path(path)) => { + Some(PluginManifestMcpServers::Path(map(path)?)) + } + Some(PluginManifestMcpServers::Object(servers)) => { + Some(PluginManifestMcpServers::Object(servers)) + } + None => None, + }; let interface = match interface { Some(interface) => { let PluginManifestInterface { @@ -157,7 +173,7 @@ impl PluginManifest { keywords, paths: PluginManifestPaths { skills: skills.map(&mut map).transpose()?, - mcp_servers: mcp_servers.map(&mut map).transpose()?, + mcp_servers, apps: apps.map(&mut map).transpose()?, hooks, }, diff --git a/codex-rs/plugin/src/provider_tests.rs b/codex-rs/plugin/src/provider_tests.rs index 44993a640c..776e63b1c3 100644 --- a/codex-rs/plugin/src/provider_tests.rs +++ b/codex-rs/plugin/src/provider_tests.rs @@ -4,6 +4,7 @@ use super::ResolvedPluginError; use crate::manifest::PluginManifest; use crate::manifest::PluginManifestHooks; use crate::manifest::PluginManifestInterface; +use crate::manifest::PluginManifestMcpServers; use crate::manifest::PluginManifestPaths; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -37,7 +38,7 @@ fn environment_descriptor_binds_every_manifest_resource() { keywords: Vec::new(), paths: PluginManifestPaths { skills: Some(skills.clone()), - mcp_servers: Some(mcp_servers.clone()), + mcp_servers: Some(PluginManifestMcpServers::Path(mcp_servers.clone())), apps: Some(apps.clone()), hooks: Some(PluginManifestHooks::Paths(vec![hooks.clone()])), }, @@ -71,7 +72,10 @@ fn environment_descriptor_binds_every_manifest_resource() { keywords: Vec::new(), paths: PluginManifestPaths { skills: Some(resource("executor-1", skills)), - mcp_servers: Some(resource("executor-1", mcp_servers)), + mcp_servers: Some(PluginManifestMcpServers::Path(resource( + "executor-1", + mcp_servers, + ))), apps: Some(resource("executor-1", apps)), hooks: Some(PluginManifestHooks::Paths(vec![resource( "executor-1", @@ -100,7 +104,7 @@ fn environment_descriptor_rejects_resources_outside_package_root() { keywords: Vec::new(), paths: PluginManifestPaths { skills: None, - mcp_servers: Some(outside.clone()), + mcp_servers: Some(PluginManifestMcpServers::Path(outside.clone())), apps: None, hooks: None, }, diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md index 5eb2251a0e..ec5476ae14 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md +++ b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md @@ -62,10 +62,31 @@ - `keywords` (`array` of `string`): Search/discovery tags. - `skills` (`string`): Relative path to skill directories/files. - `hooks` (`string`): Hook config path. -- `mcpServers` (`string`): MCP config path. +- `mcpServers` (`string` or `object`): MCP config path, or an object whose keys are MCP server names and whose values are MCP server config objects. - `apps` (`string`): App manifest path for plugin integrations. - `interface` (`object`): Interface/UX metadata block for plugin presentation. +`mcpServers` may be declared as a companion file path: + +```json +{ + "mcpServers": "./.mcp.json" +} +``` + +Or as an object directly in `plugin.json`: + +```json +{ + "mcpServers": { + "counter": { + "type": "http", + "url": "https://sample.example/counter/mcp" + } + } +} +``` + ### `interface` fields - `displayName` (`string`): User-facing title shown for the plugin. @@ -91,7 +112,7 @@ ### Path conventions and defaults - Path values should be relative and begin with `./`. -- `skills`, `hooks`, and `mcpServers` are supplemented on top of default component discovery; they do not replace defaults. +- `skills`, `hooks`, and string-valued `mcpServers` are supplemented on top of default component discovery; they do not replace defaults. - Custom path values must follow the plugin root convention and naming/namespacing rules. - This repo’s scaffold writes `.codex-plugin/plugin.json`; treat that as the manifest location this skill generates. @@ -186,8 +207,9 @@ personal marketplace unless the caller explicitly requests a repo-local destinat present. - `composerIcon`, `logo`, and `screenshots` must point to real files inside the plugin archive when present. -- `apps` and `mcpServers` should appear in `plugin.json` only when `.app.json` and `.mcp.json` - actually exist. +- `apps` should appear in `plugin.json` only when `.app.json` actually exists. +- `mcpServers` may point to `.mcp.json` or contain the MCP server object directly in + `plugin.json`. - Validation rejects unsupported manifest fields such as `hooks`, so the scaffold keeps them out of generated manifests. - Run `scripts/validate_plugin.py ` before handing back a generated plugin. It adds one diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py index 6f49cb0fed..5c6fae8e63 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py @@ -126,18 +126,13 @@ def validate_manifest_shape( validate_optional_contract_path(manifest, "skills", "skills", errors) validate_optional_contract_path(manifest, "apps", ".app.json", errors) - validate_optional_contract_path(manifest, "mcpServers", ".mcp.json", errors) + validate_manifest_mcp_servers(plugin_root, manifest, errors) if manifest.get("apps") is not None: validate_app_manifest( plugin_root / ".app.json", errors, ) - if manifest.get("mcpServers") is not None: - validate_mcp_manifest( - plugin_root / ".mcp.json", - errors, - ) validate_skill_manifests(plugin_root, errors) interface = require_object(manifest, "interface", errors) @@ -286,6 +281,32 @@ def validate_optional_contract_path( errors.append(f"plugin.json field `{key}` must resolve to `{expected}`") +def validate_manifest_mcp_servers( + plugin_root: Path, + manifest: dict[str, Any], + errors: list[str], +) -> None: + value = manifest.get("mcpServers") + if value is None: + return + if isinstance(value, str): + validate_optional_contract_path(manifest, "mcpServers", ".mcp.json", errors) + validate_mcp_manifest( + plugin_root / ".mcp.json", + errors, + ) + return + if isinstance(value, dict): + validate_mcp_server_entries( + value, + "plugin.json field `mcpServers`", + "plugin.json field `mcpServers`", + errors, + ) + return + errors.append("plugin.json field `mcpServers` must be a string path or object") + + def normalize_contract_path(raw_path: str) -> str | None: path = Path(raw_path) if path.is_absolute(): @@ -326,14 +347,28 @@ def validate_mcp_manifest(path: Path, errors: list[str]) -> None: return reject_companion_unknown_fields(payload, {"mcpServers"}, "`.mcp.json`", errors) servers = payload.get("mcpServers") + validate_mcp_server_entries( + servers, + "`.mcp.json`", + "`.mcp.json` field `mcpServers`", + errors, + ) + + +def validate_mcp_server_entries( + servers: Any, + source_label: str, + field_label: str, + errors: list[str], +) -> None: if not isinstance(servers, dict): - errors.append("`.mcp.json` field `mcpServers` must be an object") + errors.append(f"{field_label} must be an object") return for key, value in servers.items(): if not isinstance(key, str) or not key.strip(): - errors.append("`.mcp.json` server names must be non-empty strings") + errors.append(f"{source_label} server names must be non-empty strings") if not isinstance(value, dict): - errors.append(f"`.mcp.json` server `{key}` must be an object") + errors.append(f"{source_label} server `{key}` must be an object") def load_companion_json_object( From e2f074e16c522bfa55d9bcd344a5ea0ba5a4580f Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Tue, 16 Jun 2026 19:28:55 -0700 Subject: [PATCH 20/70] code-mode: move cell state into library actor (#28599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A code-mode cell is a single JavaScript execution that can produce output, call tools, wait for asynchronous work, resume, or be terminated. This PR extracts the existing per-cell run loop into a dedicated actor that owns the cell’s lifecycle state. It is primarily an ownership change rather than a new lifecycle contract: existing behavior now has one clear implementation boundary. ### Architecture The session service remains responsible for session-wide concerns: allocating cell IDs, storing shared values, creating cells, and routing requests to them. Once a cell is created, its execution state belongs to its actor. Callers interact with the actor through a handle. The actor receives two kinds of input: runtime events and control requests. A single event loop serializes these inputs and applies the lifecycle rules. It tracks the current observer—the caller waiting for an update—along with accumulated output, outstanding callbacks, runtime state, yield deadlines, and termination progress. Observation, termination, completion, and cleanup therefore have one consistent owner. When the runtime has no immediately runnable work and is waiting only on timers or tool results, the actor can return accumulated output and information about outstanding tool calls while keeping the cell available to resume. On completion or termination, it performs the appropriate callback cleanup before publishing the final result and removing the cell from the session. A small host interface connects the actor to session-owned facilities such as tool dispatch, notifications, stored values, and final cell removal, keeping those responsibilities outside the actor itself. ### Why Previously, cell lifecycle state and coordination lived alongside session management. The actor boundary makes each cell a self-contained state machine with a single writer, while the service becomes a registry and adapter around it. This makes lifecycle behavior easier to reason about and test in isolation. It also establishes a clean boundary for later changing where cells run or how they communicate without recreating their lifecycle rules. --- .../code-mode/src/cell_actor/callbacks.rs | 77 + .../code-mode/src/cell_actor/conversions.rs | 60 + codex-rs/code-mode/src/cell_actor/mod.rs | 476 ++++ codex-rs/code-mode/src/cell_actor/tests.rs | 143 ++ codex-rs/code-mode/src/cell_actor/types.rs | 203 ++ codex-rs/code-mode/src/lib.rs | 1 + codex-rs/code-mode/src/runtime/mod.rs | 6 + codex-rs/code-mode/src/service.rs | 1927 ++--------------- .../code-mode/src/service_contract_tests.rs | 301 +-- codex-rs/code-mode/src/service_tests.rs | 972 +++++++++ 10 files changed, 2261 insertions(+), 1905 deletions(-) create mode 100644 codex-rs/code-mode/src/cell_actor/callbacks.rs create mode 100644 codex-rs/code-mode/src/cell_actor/conversions.rs create mode 100644 codex-rs/code-mode/src/cell_actor/mod.rs create mode 100644 codex-rs/code-mode/src/cell_actor/tests.rs create mode 100644 codex-rs/code-mode/src/cell_actor/types.rs create mode 100644 codex-rs/code-mode/src/service_tests.rs diff --git a/codex-rs/code-mode/src/cell_actor/callbacks.rs b/codex-rs/code-mode/src/cell_actor/callbacks.rs new file mode 100644 index 0000000000..9db66ed3a7 --- /dev/null +++ b/codex-rs/code-mode/src/cell_actor/callbacks.rs @@ -0,0 +1,77 @@ +use std::sync::Arc; + +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; +use tracing::warn; + +use super::CellHost; +use super::CellToolCall; +use crate::runtime::RuntimeCommand; + +#[derive(Clone, Copy)] +pub(super) enum CallbackCompletion { + DrainNotifications, + Cancel, +} + +pub(super) fn spawn_notification( + tasks: &mut JoinSet<()>, + host: Arc, + call_id: String, + text: String, + cancellation_token: CancellationToken, +) { + tasks.spawn(async move { + if let Err(err) = host.notify(call_id, text, cancellation_token).await { + warn!("failed to deliver code mode notification: {err}"); + } + }); +} + +pub(super) fn spawn_tool( + tasks: &mut JoinSet<()>, + host: Arc, + invocation: CellToolCall, + runtime_tx: std::sync::mpsc::Sender, + cancellation_token: CancellationToken, +) { + tasks.spawn(async move { + let id = invocation.id.clone(); + let command = match host.invoke_tool(invocation, cancellation_token).await { + Ok(result) => RuntimeCommand::ToolResponse { id, result }, + Err(error_text) => RuntimeCommand::ToolError { id, error_text }, + }; + let _ = runtime_tx.send(command); + }); +} + +pub(super) async fn finish_callbacks( + cancellation_token: &CancellationToken, + notification_tasks: &mut JoinSet<()>, + tool_tasks: &mut JoinSet<()>, + completion: CallbackCompletion, +) { + if matches!(completion, CallbackCompletion::Cancel) { + cancellation_token.cancel(); + } + drain_tasks(notification_tasks, "notification").await; + cancellation_token.cancel(); + drain_tasks(tool_tasks, "tool").await; +} + +pub(super) fn log_task_result( + task_result: Option>, + description: &str, +) { + if let Some(Err(err)) = task_result + && !err.is_cancelled() + { + warn!("code mode {description} task failed: {err}"); + } +} + +async fn drain_tasks(tasks: &mut JoinSet<()>, description: &str) { + while let Some(result) = tasks.join_next().await { + log_task_result(Some(result), description); + } +} diff --git a/codex-rs/code-mode/src/cell_actor/conversions.rs b/codex-rs/code-mode/src/cell_actor/conversions.rs new file mode 100644 index 0000000000..ebc12d29f5 --- /dev/null +++ b/codex-rs/code-mode/src/cell_actor/conversions.rs @@ -0,0 +1,60 @@ +use codex_code_mode_protocol::CodeModeToolKind; +use codex_code_mode_protocol::ExecuteRequest; +use codex_code_mode_protocol::FunctionCallOutputContentItem; +use codex_code_mode_protocol::ImageDetail; +use codex_code_mode_protocol::ToolDefinition; +use codex_protocol::ToolName; + +use super::CellImageDetail; +use super::CellOutputItem; +use super::CellRequest; +use super::CellToolKind; + +pub(super) fn runtime_request(request: CellRequest) -> ExecuteRequest { + ExecuteRequest { + tool_call_id: request.tool_call_id, + enabled_tools: request + .enabled_tools + .into_iter() + .map(|definition| ToolDefinition { + name: definition.name, + tool_name: ToolName { + name: definition.tool_name.name, + namespace: definition.tool_name.namespace, + }, + description: definition.description, + kind: match definition.kind { + CellToolKind::Function => CodeModeToolKind::Function, + CellToolKind::Freeform => CodeModeToolKind::Freeform, + }, + input_schema: None, + output_schema: None, + }) + .collect(), + source: request.source, + yield_time_ms: None, + max_output_tokens: None, + } +} + +pub(super) fn cell_tool_kind(kind: CodeModeToolKind) -> CellToolKind { + match kind { + CodeModeToolKind::Function => CellToolKind::Function, + CodeModeToolKind::Freeform => CellToolKind::Freeform, + } +} + +pub(super) fn output_item(item: FunctionCallOutputContentItem) -> CellOutputItem { + match item { + FunctionCallOutputContentItem::InputText { text } => CellOutputItem::Text { text }, + FunctionCallOutputContentItem::InputImage { image_url, detail } => CellOutputItem::Image { + image_url, + detail: detail.map(|detail| match detail { + ImageDetail::Auto => CellImageDetail::Auto, + ImageDetail::Low => CellImageDetail::Low, + ImageDetail::High => CellImageDetail::High, + ImageDetail::Original => CellImageDetail::Original, + }), + }, + } +} diff --git a/codex-rs/code-mode/src/cell_actor/mod.rs b/codex-rs/code-mode/src/cell_actor/mod.rs new file mode 100644 index 0000000000..d42ce48a24 --- /dev/null +++ b/codex-rs/code-mode/src/cell_actor/mod.rs @@ -0,0 +1,476 @@ +mod callbacks; +mod conversions; +mod types; + +use std::collections::HashMap; +use std::future::Future; +use std::sync::Arc; + +use serde_json::Value as JsonValue; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; + +use self::callbacks::CallbackCompletion; +use self::callbacks::finish_callbacks; +use self::callbacks::log_task_result; +use self::callbacks::spawn_notification; +use self::callbacks::spawn_tool; +use self::conversions::cell_tool_kind; +use self::conversions::output_item; +use self::conversions::runtime_request; +use self::types::CellCommand; +pub(crate) use self::types::CellError; +pub(crate) use self::types::CellEvent; +pub(crate) use self::types::CellEventFuture; +pub(crate) use self::types::CellHandle; +pub(crate) use self::types::CellHost; +pub(crate) use self::types::CellImageDetail; +pub(crate) use self::types::CellOutputItem; +pub(crate) use self::types::CellRequest; +pub(crate) use self::types::CellToolCall; +pub(crate) use self::types::CellToolDefinition; +pub(crate) use self::types::CellToolKind; +pub(crate) use self::types::CellToolName; +pub(crate) use self::types::ObserveMode; +use crate::runtime::PendingRuntimeMode; +use crate::runtime::RuntimeCommand; +use crate::runtime::RuntimeControlCommand; +use crate::runtime::RuntimeEvent; +use crate::runtime::spawn_runtime; + +pub(crate) struct CellActor; + +impl CellActor { + pub(crate) fn prepare( + request: CellRequest, + stored_values: HashMap, + host: Arc, + initial_observe_mode: ObserveMode, + ) -> Result< + ( + CellHandle, + CellEventFuture, + impl Future + Send + 'static, + ), + String, + > { + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let (command_tx, command_rx) = mpsc::unbounded_channel(); + let (initial_response_tx, initial_response_rx) = oneshot::channel(); + let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = spawn_runtime( + stored_values, + runtime_request(request), + event_tx, + PendingRuntimeMode::PauseUntilResumed, + )?; + let cancellation_token = CancellationToken::new(); + let handle = CellHandle::new(command_tx, cancellation_token.clone()); + let task = run_cell( + host, + CellContext { + runtime_tx, + runtime_control_tx, + runtime_terminate_handle, + cancellation_token, + }, + event_rx, + command_rx, + Observer { + mode: initial_observe_mode, + response_tx: initial_response_tx, + }, + ); + let initial_response = + Box::pin(async move { initial_response_rx.await.unwrap_or(Err(CellError::Closed)) }); + Ok((handle, initial_response, task)) + } +} + +struct CellContext { + runtime_tx: std::sync::mpsc::Sender, + runtime_control_tx: std::sync::mpsc::Sender, + runtime_terminate_handle: v8::IsolateHandle, + cancellation_token: CancellationToken, +} + +struct Observer { + mode: ObserveMode, + response_tx: oneshot::Sender>, +} + +struct Termination { + response_tx: Option>>, +} + +async fn run_cell( + host: Arc, + context: CellContext, + mut event_rx: mpsc::UnboundedReceiver, + mut command_rx: mpsc::UnboundedReceiver, + initial_observer: Observer, +) { + let CellContext { + runtime_tx, + runtime_control_tx, + runtime_terminate_handle, + cancellation_token, + } = context; + let mut content_items = Vec::new(); + let mut pending_tool_call_ids = Vec::new(); + let mut completed_event = None; + let mut observer = Some(initial_observer); + let mut termination: Option = None; + let mut runtime_closed = false; + let mut runtime_paused = false; + let mut yield_timer: Option>> = None; + let mut notification_tasks = JoinSet::new(); + let mut tool_tasks = JoinSet::new(); + + loop { + let yield_deadline_elapsed = yield_timer + .as_ref() + .is_some_and(|yield_timer| yield_timer.deadline() <= tokio::time::Instant::now()); + tokio::select! { + biased; + maybe_command = command_rx.recv() => { + let Some(command) = maybe_command else { + if completed_event.is_some() { + break; + } + termination = Some(Termination { response_tx: None }); + begin_termination( + &runtime_tx, + &runtime_control_tx, + &runtime_terminate_handle, + &cancellation_token, + ); + if runtime_closed { + break; + } + continue; + }; + match command { + CellCommand::Observe { mode, response_tx } => { + if let Some(event) = completed_event.take() { + let _ = response_tx.send(Ok(event)); + break; + } + if observer.is_some() || termination.is_some() { + let _ = response_tx.send(Err(CellError::Busy)); + continue; + } + observer = Some(Observer { mode, response_tx }); + yield_timer = observer.as_ref().and_then(observer_timer); + resume_for_observation( + mode, + &mut runtime_paused, + &runtime_tx, + &runtime_control_tx, + ); + } + CellCommand::Terminate { response_tx } => { + if let Some(event) = completed_event.take() { + if let Some(response_tx) = response_tx { + let _ = response_tx.send(Ok(event)); + } + break; + } + if termination.is_some() { + if let Some(response_tx) = response_tx { + let _ = response_tx.send(Err(CellError::AlreadyTerminating)); + } + continue; + } + termination = Some(Termination { response_tx }); + yield_timer = None; + begin_termination( + &runtime_tx, + &runtime_control_tx, + &runtime_terminate_handle, + &cancellation_token, + ); + if runtime_closed { + finish_callbacks( + &cancellation_token, + &mut notification_tasks, + &mut tool_tasks, + CallbackCompletion::Cancel, + ).await; + send_termination_events( + observer.take(), + termination.take(), + CellEvent::Terminated { + content_items: std::mem::take(&mut content_items), + }, + ); + break; + } + } + } + } + _ = async { + if let Some(yield_timer) = yield_timer.as_mut() { + yield_timer.await; + } else { + std::future::pending::<()>().await; + } + } => { + yield_timer = None; + send_observer_event( + observer.take(), + CellEvent::Yielded { + content_items: std::mem::take(&mut content_items), + }, + ); + } + maybe_event = async { + if runtime_closed { + std::future::pending::>().await + } else { + event_rx.recv().await + } + }, if !yield_deadline_elapsed => { + let Some(event) = maybe_event else { + runtime_closed = true; + if termination.is_some() { + finish_callbacks( + &cancellation_token, + &mut notification_tasks, + &mut tool_tasks, + CallbackCompletion::Cancel, + ).await; + send_termination_events( + observer.take(), + termination.take(), + CellEvent::Terminated { + content_items: std::mem::take(&mut content_items), + }, + ); + break; + } + if completed_event.is_none() { + finish_callbacks( + &cancellation_token, + &mut notification_tasks, + &mut tool_tasks, + CallbackCompletion::DrainNotifications, + ).await; + let event = CellEvent::Completed { + content_items: std::mem::take(&mut content_items), + error_text: Some("exec runtime ended unexpectedly".to_string()), + }; + if send_or_buffer_completion( + event, + &mut observer, + &mut completed_event, + ) { + break; + } + } + continue; + }; + match event { + RuntimeEvent::Started => { + yield_timer = observer.as_ref().and_then(observer_timer); + } + RuntimeEvent::Pending => { + runtime_paused = true; + if matches!( + observer.as_ref().map(|observer| observer.mode), + Some(ObserveMode::PendingFrontier) + ) { + yield_timer = None; + send_observer_event( + observer.take(), + CellEvent::Pending { + content_items: std::mem::take(&mut content_items), + pending_tool_call_ids: std::mem::take( + &mut pending_tool_call_ids, + ), + }, + ); + } else { + pending_tool_call_ids.clear(); + let _ = runtime_control_tx.send(RuntimeControlCommand::Continue); + runtime_paused = false; + } + } + RuntimeEvent::ContentItem(item) => content_items.push(output_item(item)), + RuntimeEvent::YieldRequested => { + if matches!( + observer.as_ref().map(|observer| observer.mode), + Some(ObserveMode::YieldAfter(_)) + ) { + yield_timer = None; + send_observer_event( + observer.take(), + CellEvent::Yielded { + content_items: std::mem::take(&mut content_items), + }, + ); + } + } + RuntimeEvent::Notify { call_id, text } => { + spawn_notification( + &mut notification_tasks, + Arc::clone(&host), + call_id, + text, + cancellation_token.child_token(), + ); + } + RuntimeEvent::ToolCall { id, name, kind, input } => { + pending_tool_call_ids.push(id.clone()); + spawn_tool( + &mut tool_tasks, + Arc::clone(&host), + CellToolCall { + id, + name: CellToolName { + name: name.name, + namespace: name.namespace, + }, + kind: cell_tool_kind(kind), + input, + }, + runtime_tx.clone(), + cancellation_token.child_token(), + ); + } + RuntimeEvent::Result { stored_value_writes, error_text } => { + runtime_closed = true; + yield_timer = None; + if termination.is_some() { + finish_callbacks( + &cancellation_token, + &mut notification_tasks, + &mut tool_tasks, + CallbackCompletion::Cancel, + ).await; + send_termination_events( + observer.take(), + termination.take(), + CellEvent::Terminated { + content_items: std::mem::take(&mut content_items), + }, + ); + break; + } + finish_callbacks( + &cancellation_token, + &mut notification_tasks, + &mut tool_tasks, + CallbackCompletion::DrainNotifications, + ).await; + host.commit_stored_values(stored_value_writes).await; + let event = CellEvent::Completed { + content_items: std::mem::take(&mut content_items), + error_text, + }; + if send_or_buffer_completion( + event, + &mut observer, + &mut completed_event, + ) { + break; + } + } + } + } + task_result = notification_tasks.join_next(), if !notification_tasks.is_empty() => { + log_task_result(task_result, "notification"); + } + task_result = tool_tasks.join_next(), if !tool_tasks.is_empty() => { + log_task_result(task_result, "tool"); + } + } + } + begin_termination( + &runtime_tx, + &runtime_control_tx, + &runtime_terminate_handle, + &cancellation_token, + ); + finish_callbacks( + &cancellation_token, + &mut notification_tasks, + &mut tool_tasks, + CallbackCompletion::Cancel, + ) + .await; + host.closed().await; +} + +fn send_or_buffer_completion( + event: CellEvent, + observer: &mut Option, + completed_event: &mut Option, +) -> bool { + if observer.is_some() { + send_observer_event(observer.take(), event); + true + } else { + *completed_event = Some(event); + false + } +} + +fn send_observer_event(observer: Option, event: CellEvent) { + if let Some(observer) = observer { + let _ = observer.response_tx.send(Ok(event)); + } +} + +fn send_termination_events( + observer: Option, + termination: Option, + event: CellEvent, +) { + send_observer_event(observer, event.clone()); + if let Some(response_tx) = termination.and_then(|termination| termination.response_tx) { + let _ = response_tx.send(Ok(event)); + } +} + +fn observer_timer(observer: &Observer) -> Option>> { + match observer.mode { + ObserveMode::YieldAfter(duration) => Some(Box::pin(tokio::time::sleep(duration))), + ObserveMode::PendingFrontier => None, + } +} + +fn resume_for_observation( + mode: ObserveMode, + runtime_paused: &mut bool, + runtime_tx: &std::sync::mpsc::Sender, + runtime_control_tx: &std::sync::mpsc::Sender, +) { + if *runtime_paused { + let control = match mode { + ObserveMode::YieldAfter(_) => RuntimeControlCommand::Continue, + ObserveMode::PendingFrontier => RuntimeControlCommand::Resume, + }; + let _ = runtime_control_tx.send(control); + *runtime_paused = false; + } else if matches!(mode, ObserveMode::PendingFrontier) { + let _ = runtime_tx.send(RuntimeCommand::ObservePendingFrontier); + } +} + +fn begin_termination( + runtime_tx: &std::sync::mpsc::Sender, + runtime_control_tx: &std::sync::mpsc::Sender, + runtime_terminate_handle: &v8::IsolateHandle, + cancellation_token: &CancellationToken, +) { + cancellation_token.cancel(); + let _ = runtime_tx.send(RuntimeCommand::Terminate); + let _ = runtime_control_tx.send(RuntimeControlCommand::Terminate); + let _ = runtime_terminate_handle.terminate_execution(); +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/codex-rs/code-mode/src/cell_actor/tests.rs b/codex-rs/code-mode/src/cell_actor/tests.rs new file mode 100644 index 0000000000..218f1fcde4 --- /dev/null +++ b/codex-rs/code-mode/src/cell_actor/tests.rs @@ -0,0 +1,143 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use codex_code_mode_protocol::ExecuteRequest; +use codex_code_mode_protocol::FunctionCallOutputContentItem; +use pretty_assertions::assert_eq; +use serde_json::Value as JsonValue; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio_util::sync::CancellationToken; + +use super::*; + +struct TestHost; + +impl CellHost for TestHost { + async fn invoke_tool( + &self, + _invocation: CellToolCall, + _cancellation_token: CancellationToken, + ) -> Result { + Err("unexpected tool call".to_string()) + } + + async fn notify( + &self, + _call_id: String, + _text: String, + _cancellation_token: CancellationToken, + ) -> Result<(), String> { + Ok(()) + } + + async fn commit_stored_values(&self, _stored_value_writes: HashMap) {} + + async fn closed(&self) {} +} + +struct CellActorHarness { + event_tx: mpsc::UnboundedSender, + handle: CellHandle, + initial_event_rx: oneshot::Receiver>, + task: tokio::task::JoinHandle<()>, + _runtime_event_rx: mpsc::UnboundedReceiver, +} + +fn spawn_cell_actor_harness(initial_observe_mode: ObserveMode) -> CellActorHarness { + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let (command_tx, command_rx) = mpsc::unbounded_channel(); + let (initial_event_tx, initial_event_rx) = oneshot::channel(); + let (runtime_event_tx, runtime_event_rx) = mpsc::unbounded_channel(); + let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = spawn_runtime( + HashMap::new(), + ExecuteRequest { + tool_call_id: "call-1".to_string(), + enabled_tools: Vec::new(), + source: "await new Promise(() => {});".to_string(), + yield_time_ms: None, + max_output_tokens: None, + }, + runtime_event_tx, + PendingRuntimeMode::PauseUntilResumed, + ) + .unwrap(); + let handle = CellHandle::new(command_tx, CancellationToken::new()); + let task = tokio::spawn(run_cell( + Arc::new(TestHost), + CellContext { + runtime_tx, + runtime_control_tx, + runtime_terminate_handle, + cancellation_token: CancellationToken::new(), + }, + event_rx, + command_rx, + Observer { + mode: initial_observe_mode, + response_tx: initial_event_tx, + }, + )); + + CellActorHarness { + event_tx, + handle, + initial_event_rx, + task, + _runtime_event_rx: runtime_event_rx, + } +} + +#[tokio::test] +async fn yield_timer_preempts_buffered_runtime_output() { + let harness = spawn_cell_actor_harness(ObserveMode::YieldAfter(Duration::ZERO)); + harness.event_tx.send(RuntimeEvent::Started).unwrap(); + harness + .event_tx + .send(RuntimeEvent::ContentItem( + FunctionCallOutputContentItem::InputText { + text: "queued output".to_string(), + }, + )) + .unwrap(); + + assert_eq!( + harness.initial_event_rx.await.unwrap(), + Ok(CellEvent::Yielded { + content_items: Vec::new(), + }) + ); + + let termination = harness.handle.terminate(); + drop(harness.event_tx); + assert_eq!( + termination.await, + Ok(CellEvent::Terminated { + content_items: vec![CellOutputItem::Text { + text: "queued output".to_string(), + }], + }) + ); + harness.task.await.unwrap(); +} + +#[tokio::test] +async fn queued_termination_preempts_unobserved_runtime_completion() { + let harness = spawn_cell_actor_harness(ObserveMode::YieldAfter(Duration::from_secs(60))); + harness + .event_tx + .send(RuntimeEvent::Result { + stored_value_writes: HashMap::new(), + error_text: None, + }) + .unwrap(); + let termination = harness.handle.terminate(); + + let terminated = Ok(CellEvent::Terminated { + content_items: Vec::new(), + }); + assert_eq!(termination.await, terminated.clone()); + assert_eq!(harness.initial_event_rx.await.unwrap(), terminated); + harness.task.await.unwrap(); +} diff --git a/codex-rs/code-mode/src/cell_actor/types.rs b/codex-rs/code-mode/src/cell_actor/types.rs new file mode 100644 index 0000000000..aef0b5211f --- /dev/null +++ b/codex-rs/code-mode/src/cell_actor/types.rs @@ -0,0 +1,203 @@ +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use serde_json::Value as JsonValue; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio_util::sync::CancellationToken; + +pub(crate) type CellEventFuture = + Pin> + Send + 'static>>; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ObserveMode { + YieldAfter(Duration), + PendingFrontier, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum CellEvent { + Yielded { + content_items: Vec, + }, + Pending { + content_items: Vec, + pending_tool_call_ids: Vec, + }, + Completed { + content_items: Vec, + error_text: Option, + }, + Terminated { + content_items: Vec, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum CellError { + Busy, + AlreadyTerminating, + Closed, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum CellOutputItem { + Text { + text: String, + }, + Image { + image_url: String, + detail: Option, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum CellImageDetail { + Auto, + Low, + High, + Original, +} + +pub(crate) struct CellRequest { + pub(crate) tool_call_id: String, + pub(crate) enabled_tools: Vec, + pub(crate) source: String, +} + +pub(crate) struct CellToolDefinition { + pub(crate) name: String, + pub(crate) tool_name: CellToolName, + pub(crate) description: String, + pub(crate) kind: CellToolKind, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CellToolName { + pub(crate) name: String, + pub(crate) namespace: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum CellToolKind { + Function, + Freeform, +} + +pub(crate) struct CellToolCall { + pub(crate) id: String, + pub(crate) name: CellToolName, + pub(crate) kind: CellToolKind, + pub(crate) input: Option, +} + +/// Connects a cell actor to session-owned callbacks and lifecycle state. +/// +/// Implementations must honor callback cancellation and must not return from +/// `closed` until the session can no longer route requests to the cell. +pub(crate) trait CellHost: Send + Sync + 'static { + fn invoke_tool( + &self, + invocation: CellToolCall, + cancellation_token: CancellationToken, + ) -> impl Future> + Send; + + fn notify( + &self, + call_id: String, + text: String, + cancellation_token: CancellationToken, + ) -> impl Future> + Send; + + fn commit_stored_values( + &self, + stored_value_writes: HashMap, + ) -> impl Future + Send; + + fn closed(&self) -> impl Future + Send; +} + +#[derive(Clone)] +pub(crate) struct CellHandle { + command_tx: mpsc::UnboundedSender, + cancellation_token: CancellationToken, + termination_requested: Arc, +} + +impl CellHandle { + pub(super) fn new( + command_tx: mpsc::UnboundedSender, + cancellation_token: CancellationToken, + ) -> Self { + Self { + command_tx, + cancellation_token, + termination_requested: Arc::new(AtomicBool::new(false)), + } + } + + pub(crate) fn observe(&self, mode: ObserveMode) -> CellEventFuture { + let (response_tx, response_rx) = oneshot::channel(); + if self + .command_tx + .send(CellCommand::Observe { mode, response_tx }) + .is_err() + { + return closed_event(); + } + response_event(response_rx) + } + + pub(crate) fn terminate(&self) -> CellEventFuture { + if self + .termination_requested + .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) + .is_err() + { + return Box::pin(async { Err(CellError::AlreadyTerminating) }); + } + let (response_tx, response_rx) = oneshot::channel(); + if self + .command_tx + .send(CellCommand::Terminate { + response_tx: Some(response_tx), + }) + .is_err() + { + self.termination_requested.store(false, Ordering::Relaxed); + return closed_event(); + } + response_event(response_rx) + } + + pub(crate) fn shutdown(&self) { + self.termination_requested.store(true, Ordering::Relaxed); + self.cancellation_token.cancel(); + let _ = self + .command_tx + .send(CellCommand::Terminate { response_tx: None }); + } +} + +pub(super) enum CellCommand { + Observe { + mode: ObserveMode, + response_tx: oneshot::Sender>, + }, + Terminate { + response_tx: Option>>, + }, +} + +fn response_event(response_rx: oneshot::Receiver>) -> CellEventFuture { + Box::pin(async move { response_rx.await.unwrap_or(Err(CellError::Closed)) }) +} + +fn closed_event() -> CellEventFuture { + Box::pin(async { Err(CellError::Closed) }) +} diff --git a/codex-rs/code-mode/src/lib.rs b/codex-rs/code-mode/src/lib.rs index 2699258271..76f8aa9b97 100644 --- a/codex-rs/code-mode/src/lib.rs +++ b/codex-rs/code-mode/src/lib.rs @@ -1,3 +1,4 @@ +mod cell_actor; mod runtime; mod service; diff --git a/codex-rs/code-mode/src/runtime/mod.rs b/codex-rs/code-mode/src/runtime/mod.rs index 36ffd926cc..bfc54f71be 100644 --- a/codex-rs/code-mode/src/runtime/mod.rs +++ b/codex-rs/code-mode/src/runtime/mod.rs @@ -25,17 +25,20 @@ pub(crate) enum RuntimeCommand { ToolResponse { id: String, result: JsonValue }, ToolError { id: String, error_text: String }, TimeoutFired { id: u64 }, + ObservePendingFrontier, Terminate, } #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) enum PendingRuntimeMode { + #[cfg(test)] Continue, PauseUntilResumed, } #[derive(Debug)] pub(crate) enum RuntimeControlCommand { + Continue, Resume, Terminate, } @@ -245,6 +248,7 @@ fn run_runtime( return; } } + RuntimeCommand::ObservePendingFrontier => {} } scope.perform_microtask_checkpoint(); @@ -283,8 +287,10 @@ fn next_runtime_command( let _ = event_tx.send(RuntimeEvent::Pending); match pending_mode { + #[cfg(test)] PendingRuntimeMode::Continue => return command_rx.recv().ok(), PendingRuntimeMode::PauseUntilResumed => match control_rx.recv().ok()? { + RuntimeControlCommand::Continue => return command_rx.recv().ok(), RuntimeControlCommand::Resume => continue, RuntimeControlCommand::Terminate => return Some(RuntimeCommand::Terminate), }, diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs index db5cb55a63..4c8fa517ae 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode/src/service.rs @@ -12,10 +12,12 @@ use codex_code_mode_protocol::CodeModeSessionDelegate; use codex_code_mode_protocol::CodeModeSessionProvider; use codex_code_mode_protocol::CodeModeSessionProviderFuture; use codex_code_mode_protocol::CodeModeSessionResultFuture; +use codex_code_mode_protocol::CodeModeToolKind; use codex_code_mode_protocol::DEFAULT_EXEC_YIELD_TIME_MS; use codex_code_mode_protocol::ExecuteRequest; use codex_code_mode_protocol::ExecuteToPendingOutcome; use codex_code_mode_protocol::FunctionCallOutputContentItem; +use codex_code_mode_protocol::ImageDetail; use codex_code_mode_protocol::NotificationFuture; use codex_code_mode_protocol::RuntimeResponse; use codex_code_mode_protocol::StartedCell; @@ -26,17 +28,23 @@ use codex_code_mode_protocol::WaitToPendingOutcome; use codex_code_mode_protocol::WaitToPendingRequest; use serde_json::Value as JsonValue; use tokio::sync::Mutex; -use tokio::sync::mpsc; use tokio::sync::oneshot; -use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; -use tracing::warn; -use crate::runtime::PendingRuntimeMode; -use crate::runtime::RuntimeCommand; -use crate::runtime::RuntimeControlCommand; -use crate::runtime::RuntimeEvent; -use crate::runtime::spawn_runtime; +use crate::cell_actor::CellActor; +use crate::cell_actor::CellError; +use crate::cell_actor::CellEvent; +use crate::cell_actor::CellEventFuture; +use crate::cell_actor::CellHandle; +use crate::cell_actor::CellHost; +use crate::cell_actor::CellImageDetail; +use crate::cell_actor::CellOutputItem; +use crate::cell_actor::CellRequest; +use crate::cell_actor::CellToolCall; +use crate::cell_actor::CellToolDefinition; +use crate::cell_actor::CellToolKind; +use crate::cell_actor::CellToolName; +use crate::cell_actor::ObserveMode; pub struct NoopCodeModeSessionDelegate; @@ -81,14 +89,6 @@ impl CodeModeSessionProvider for InProcessCodeModeSessionProvider { } } -#[derive(Clone)] -struct CellHandle { - control_tx: mpsc::UnboundedSender, - runtime_tx: std::sync::mpsc::Sender, - cancellation_token: CancellationToken, - termination_requested: Arc, -} - struct Inner { stored_values: Mutex>, cells: Mutex>, @@ -128,20 +128,24 @@ impl CodeModeService { } pub async fn execute(&self, request: ExecuteRequest) -> Result { - if self.inner.shutting_down.load(Ordering::Acquire) { - return Err("code mode session is shutting down".to_string()); - } - let initial_yield_time_ms = request.yield_time_ms.unwrap_or(DEFAULT_EXEC_YIELD_TIME_MS); - let (response_tx, response_rx) = oneshot::channel(); + let yield_time_ms = request.yield_time_ms.unwrap_or(DEFAULT_EXEC_YIELD_TIME_MS); let cell_id = self.allocate_cell_id(); - self.start_cell( - cell_id.clone(), - request, - CellResponseSender::Runtime(response_tx), - Some(initial_yield_time_ms), - PendingRuntimeMode::Continue, - ) - .await?; + let initial_event = self + .start_cell( + cell_id.clone(), + request, + ObserveMode::YieldAfter(Duration::from_millis(yield_time_ms)), + ) + .await?; + let response_cell_id = cell_id.clone(); + let (response_tx, response_rx) = oneshot::channel(); + tokio::spawn(async move { + let response = initial_event + .await + .map_err(|error| cell_error_text(&response_cell_id, error)) + .and_then(|event| runtime_response(&response_cell_id, event)); + let _ = response_tx.send(response); + }); Ok(StartedCell::from_result_receiver(cell_id, response_rx)) } @@ -150,75 +154,43 @@ impl CodeModeService { &self, request: ExecuteRequest, ) -> Result { - let (response_tx, response_rx) = oneshot::channel(); let cell_id = self.allocate_cell_id(); - self.start_cell( - cell_id, - request, - CellResponseSender::ExecuteToPending(response_tx), - /*initial_yield_time_ms*/ None, - PendingRuntimeMode::PauseUntilResumed, - ) - .await?; - - response_rx + let event = self + .start_cell(cell_id.clone(), request, ObserveMode::PendingFrontier) + .await? .await - .map_err(|_| "exec runtime ended unexpectedly".to_string())? + .map_err(|error| cell_error_text(&cell_id, error))?; + pending_outcome(&cell_id, event) } async fn start_cell( &self, cell_id: CellId, request: ExecuteRequest, - initial_response_tx: CellResponseSender, - initial_yield_time_ms: Option, - pending_mode: PendingRuntimeMode, - ) -> Result<(), String> { - let (event_tx, event_rx) = mpsc::unbounded_channel(); - let (control_tx, control_rx) = mpsc::unbounded_channel(); + initial_observe_mode: ObserveMode, + ) -> Result { let stored_values = self.inner.stored_values.lock().await.clone(); - let cancellation_token = CancellationToken::new(); - let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = { - let mut cells = self.inner.cells.lock().await; - if self.inner.shutting_down.load(Ordering::Acquire) { - return Err("code mode session is shutting down".to_string()); - } - if cells.contains_key(&cell_id) { - return Err(format!("exec cell {cell_id} already exists")); - } - - let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = - spawn_runtime(stored_values, request, event_tx, pending_mode)?; - - cells.insert( - cell_id.clone(), - CellHandle { - control_tx, - runtime_tx: runtime_tx.clone(), - cancellation_token: cancellation_token.clone(), - termination_requested: Arc::new(AtomicBool::new(false)), - }, - ); - (runtime_tx, runtime_control_tx, runtime_terminate_handle) - }; - - tokio::spawn(run_cell_control( - Arc::clone(&self.inner), - CellControlContext { - cell_id, - runtime_tx, - runtime_control_tx, - pending_mode, - runtime_terminate_handle, - cancellation_token, - }, - event_rx, - control_rx, - initial_response_tx, - initial_yield_time_ms, - )); - - Ok(()) + let host = Arc::new(ServiceCellHost { + cell_id: cell_id.clone(), + inner: Arc::clone(&self.inner), + }); + let mut cells = self.inner.cells.lock().await; + if self.inner.shutting_down.load(Ordering::Acquire) { + return Err("code mode session is shutting down".to_string()); + } + if cells.contains_key(&cell_id) { + return Err(format!("exec cell {cell_id} already exists")); + } + let (handle, initial_event, task) = CellActor::prepare( + cell_request(request), + stored_values, + host, + initial_observe_mode, + )?; + cells.insert(cell_id, handle); + drop(cells); + tokio::spawn(task); + Ok(initial_event) } pub async fn wait(&self, request: WaitRequest) -> Result { @@ -237,15 +209,12 @@ impl CodeModeService { let Some(handle) = handle else { return missing_wait(cell_id); }; - let (response_tx, response_rx) = oneshot::channel(); - let control_message = CellControlCommand::Poll { - yield_time_ms, - response_tx, - }; - if handle.control_tx.send(control_message).is_err() { - return missing_wait(cell_id); - } - wait_for_response(cell_id, response_rx) + wait_for_event( + cell_id, + handle.observe(ObserveMode::YieldAfter(Duration::from_millis( + yield_time_ms, + ))), + ) } pub async fn terminate(&self, cell_id: CellId) -> Result { @@ -253,27 +222,7 @@ impl CodeModeService { let Some(handle) = handle else { return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); }; - if handle - .termination_requested - .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) - .is_err() - { - return Err(already_terminating_error(&cell_id)); - } - let (response_tx, response_rx) = oneshot::channel(); - if handle - .control_tx - .send(CellControlCommand::Terminate { response_tx }) - .is_err() - { - handle.termination_requested.store(false, Ordering::Relaxed); - return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); - } - match response_rx.await { - Ok(Ok(response)) => Ok(WaitOutcome::LiveCell(response)), - Ok(Err(error_text)) => Err(error_text), - Err(_) => Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))), - } + wait_for_event(cell_id, handle.terminate()).await } pub async fn wait_to_pending( @@ -287,22 +236,14 @@ impl CodeModeService { cell_id, ))); }; - let (response_tx, response_rx) = oneshot::channel(); - if handle - .control_tx - .send(CellControlCommand::PollToPending { response_tx }) - .is_err() - { - return Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( - cell_id, - ))); - } - match response_rx.await { - Ok(Ok(response)) => Ok(WaitToPendingOutcome::LiveCell(response)), - Ok(Err(error_text)) => Err(error_text), - Err(_) => Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( + match handle.observe(ObserveMode::PendingFrontier).await { + Ok(event) => Ok(WaitToPendingOutcome::LiveCell(pending_outcome( + &cell_id, event, + )?)), + Err(CellError::Closed) => Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( cell_id, ))), + Err(error) => Err(cell_error_text(&cell_id, error)), } } @@ -317,12 +258,7 @@ impl CodeModeService { .cloned() .collect::>(); for handle in handles { - handle.cancellation_token.cancel(); - let (response_tx, _response_rx) = oneshot::channel(); - let _ = handle - .control_tx - .send(CellControlCommand::Terminate { response_tx }); - let _ = handle.runtime_tx.send(RuntimeCommand::Terminate); + handle.shutdown(); } while !self.inner.cells.lock().await.is_empty() { tokio::task::yield_now().await; @@ -342,12 +278,7 @@ impl Drop for CodeModeService { self.inner.shutting_down.store(true, Ordering::Release); if let Ok(cells) = self.inner.cells.try_lock() { for handle in cells.values() { - handle.cancellation_token.cancel(); - let (response_tx, _response_rx) = oneshot::channel(); - let _ = handle - .control_tx - .send(CellControlCommand::Terminate { response_tx }); - let _ = handle.runtime_tx.send(RuntimeCommand::Terminate); + handle.shutdown(); } } } @@ -378,36 +309,161 @@ impl CodeModeSession for CodeModeService { } } -enum CellControlCommand { - Poll { - yield_time_ms: u64, - response_tx: oneshot::Sender>, - }, - PollToPending { - response_tx: oneshot::Sender>, - }, - Terminate { - response_tx: oneshot::Sender>, - }, -} - -enum CellResponseSender { - Runtime(oneshot::Sender>), - ExecuteToPending(oneshot::Sender>), -} - -struct PendingResult { - content_items: Vec, - error_text: Option, -} - -struct CellControlContext { +struct ServiceCellHost { cell_id: CellId, - runtime_tx: std::sync::mpsc::Sender, - runtime_control_tx: std::sync::mpsc::Sender, - pending_mode: PendingRuntimeMode, - runtime_terminate_handle: v8::IsolateHandle, - cancellation_token: CancellationToken, + inner: Arc, +} + +impl CellHost for ServiceCellHost { + async fn invoke_tool( + &self, + invocation: CellToolCall, + cancellation_token: CancellationToken, + ) -> Result { + self.inner + .delegate + .invoke_tool( + CodeModeNestedToolCall { + cell_id: self.cell_id.clone(), + runtime_tool_call_id: invocation.id, + tool_name: codex_protocol::ToolName { + name: invocation.name.name, + namespace: invocation.name.namespace, + }, + tool_kind: match invocation.kind { + CellToolKind::Function => CodeModeToolKind::Function, + CellToolKind::Freeform => CodeModeToolKind::Freeform, + }, + input: invocation.input, + }, + cancellation_token, + ) + .await + } + + async fn notify( + &self, + call_id: String, + text: String, + cancellation_token: CancellationToken, + ) -> Result<(), String> { + self.inner + .delegate + .notify(call_id, self.cell_id.clone(), text, cancellation_token) + .await + } + + async fn commit_stored_values(&self, stored_value_writes: HashMap) { + self.inner + .stored_values + .lock() + .await + .extend(stored_value_writes); + } + + async fn closed(&self) { + self.inner.cells.lock().await.remove(&self.cell_id); + self.inner.delegate.cell_closed(&self.cell_id); + } +} + +fn cell_request(request: ExecuteRequest) -> CellRequest { + CellRequest { + tool_call_id: request.tool_call_id, + enabled_tools: request + .enabled_tools + .into_iter() + .map(|definition| CellToolDefinition { + name: definition.name, + tool_name: CellToolName { + name: definition.tool_name.name, + namespace: definition.tool_name.namespace, + }, + description: definition.description, + kind: match definition.kind { + CodeModeToolKind::Function => CellToolKind::Function, + CodeModeToolKind::Freeform => CellToolKind::Freeform, + }, + }) + .collect(), + source: request.source, + } +} + +fn wait_for_event( + cell_id: CellId, + event: CellEventFuture, +) -> CodeModeSessionResultFuture<'static, WaitOutcome> { + Box::pin(async move { + match event.await { + Ok(event) => Ok(WaitOutcome::LiveCell(runtime_response(&cell_id, event)?)), + Err(CellError::Closed) => Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))), + Err(error) => Err(cell_error_text(&cell_id, error)), + } + }) +} + +fn pending_outcome(cell_id: &CellId, event: CellEvent) -> Result { + match event { + CellEvent::Pending { + content_items, + pending_tool_call_ids, + } => Ok(ExecuteToPendingOutcome::Pending { + cell_id: cell_id.clone(), + content_items: content_items.into_iter().map(output_item).collect(), + pending_tool_call_ids, + }), + event => Ok(ExecuteToPendingOutcome::Completed(runtime_response( + cell_id, event, + )?)), + } +} + +fn runtime_response(cell_id: &CellId, event: CellEvent) -> Result { + match event { + CellEvent::Yielded { content_items } => Ok(RuntimeResponse::Yielded { + cell_id: cell_id.clone(), + content_items: content_items.into_iter().map(output_item).collect(), + }), + CellEvent::Completed { + content_items, + error_text, + } => Ok(RuntimeResponse::Result { + cell_id: cell_id.clone(), + content_items: content_items.into_iter().map(output_item).collect(), + error_text, + }), + CellEvent::Terminated { content_items } => Ok(RuntimeResponse::Terminated { + cell_id: cell_id.clone(), + content_items: content_items.into_iter().map(output_item).collect(), + }), + CellEvent::Pending { .. } => { + Err("cell returned a pending frontier unexpectedly".to_string()) + } + } +} + +fn output_item(item: CellOutputItem) -> FunctionCallOutputContentItem { + match item { + CellOutputItem::Text { text } => FunctionCallOutputContentItem::InputText { text }, + CellOutputItem::Image { image_url, detail } => FunctionCallOutputContentItem::InputImage { + image_url, + detail: detail.map(|detail| match detail { + CellImageDetail::Auto => ImageDetail::Auto, + CellImageDetail::Low => ImageDetail::Low, + CellImageDetail::High => ImageDetail::High, + CellImageDetail::Original => ImageDetail::Original, + }), + }, + } +} + +fn cell_error_text(cell_id: &CellId, error: CellError) -> String { + match error { + CellError::Busy => format!("exec cell {cell_id} already has an active observer"), + CellError::AlreadyTerminating => format!("exec cell {cell_id} is already terminating"), + CellError::Closed => format!("exec cell {cell_id} closed unexpectedly"), + } } fn missing_cell_response(cell_id: CellId) -> RuntimeResponse { @@ -422,1528 +478,9 @@ fn missing_wait(cell_id: CellId) -> CodeModeSessionResultFuture<'static, WaitOut Box::pin(async move { Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))) }) } -fn wait_for_response( - cell_id: CellId, - response_rx: oneshot::Receiver>, -) -> CodeModeSessionResultFuture<'static, WaitOutcome> { - Box::pin(async move { - match response_rx.await { - Ok(Ok(response)) => Ok(WaitOutcome::LiveCell(response)), - Ok(Err(error_text)) => Err(error_text), - Err(_) => Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))), - } - }) -} - -fn busy_observer_error(cell_id: &CellId) -> String { - format!("exec cell {cell_id} already has an active observer") -} - -fn already_terminating_error(cell_id: &CellId) -> String { - format!("exec cell {cell_id} is already terminating") -} - -fn pending_result_response(cell_id: &CellId, result: PendingResult) -> RuntimeResponse { - RuntimeResponse::Result { - cell_id: cell_id.clone(), - content_items: result.content_items, - error_text: result.error_text, - } -} - -fn send_terminal_response(response_tx: CellResponseSender, response: RuntimeResponse) { - match response_tx { - CellResponseSender::Runtime(response_tx) => { - let _ = response_tx.send(Ok(response)); - } - CellResponseSender::ExecuteToPending(response_tx) => { - let _ = response_tx.send(Ok(ExecuteToPendingOutcome::Completed(response))); - } - } -} - -fn send_termination_responses( - response_tx: Option, - termination_response_tx: Option>>, - response: RuntimeResponse, -) { - if let Some(response_tx) = response_tx { - send_terminal_response(response_tx, response.clone()); - } - if let Some(termination_response_tx) = termination_response_tx { - let _ = termination_response_tx.send(Ok(response)); - } -} - -fn send_or_buffer_result( - cell_id: &CellId, - result: PendingResult, - response_tx: &mut Option, - pending_result: &mut Option, -) -> bool { - if let Some(response_tx) = response_tx.take() { - let response = pending_result_response(cell_id, result); - send_terminal_response(response_tx, response); - return true; - } - - *pending_result = Some(result); - false -} - -fn send_yield_response( - cell_id: &CellId, - content_items: &mut Vec, - response_tx: &mut Option, -) { - let Some(current_response_tx) = response_tx.take() else { - return; - }; - match current_response_tx { - CellResponseSender::Runtime(response_tx) => { - let _ = response_tx.send(Ok(RuntimeResponse::Yielded { - cell_id: cell_id.clone(), - content_items: std::mem::take(content_items), - })); - } - CellResponseSender::ExecuteToPending(execute_to_pending_tx) => { - *response_tx = Some(CellResponseSender::ExecuteToPending(execute_to_pending_tx)); - } - } -} - -async fn run_cell_control( - inner: Arc, - context: CellControlContext, - mut event_rx: mpsc::UnboundedReceiver, - mut control_rx: mpsc::UnboundedReceiver, - initial_response_tx: CellResponseSender, - initial_yield_time_ms: Option, -) { - let CellControlContext { - cell_id, - runtime_tx, - runtime_control_tx, - pending_mode, - runtime_terminate_handle, - cancellation_token, - } = context; - let mut content_items = Vec::new(); - let mut pending_tool_call_ids = Vec::new(); - let mut pending_result: Option = None; - let mut response_tx = Some(initial_response_tx); - let mut termination_response_tx = None; - let mut termination_requested = false; - let mut runtime_closed = false; - let mut yield_timer: Option>> = None; - let mut notification_tasks = JoinSet::new(); - let mut tool_tasks = JoinSet::new(); - - loop { - let yield_deadline_elapsed = yield_timer - .as_ref() - .is_some_and(|yield_timer| yield_timer.deadline() <= tokio::time::Instant::now()); - tokio::select! { - biased; - maybe_command = control_rx.recv() => { - let Some(command) = maybe_command else { - break; - }; - match command { - CellControlCommand::Poll { - yield_time_ms, - response_tx: next_response_tx, - } => { - if let Some(result) = pending_result.take() { - let _ = next_response_tx.send(Ok(pending_result_response(&cell_id, result))); - break; - } - if response_tx.is_some() || termination_response_tx.is_some() { - let _ = next_response_tx.send(Err(busy_observer_error(&cell_id))); - continue; - } - response_tx = Some(CellResponseSender::Runtime(next_response_tx)); - yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(yield_time_ms)))); - resume_paused_runtime(&runtime_control_tx, pending_mode); - } - CellControlCommand::PollToPending { - response_tx: next_response_tx, - } => { - if let Some(result) = pending_result.take() { - let response = pending_result_response(&cell_id, result); - let _ = next_response_tx - .send(Ok(ExecuteToPendingOutcome::Completed(response))); - break; - } - if response_tx.is_some() || termination_response_tx.is_some() { - let _ = next_response_tx.send(Err(busy_observer_error(&cell_id))); - continue; - } - response_tx = - Some(CellResponseSender::ExecuteToPending(next_response_tx)); - yield_timer = None; - resume_paused_runtime(&runtime_control_tx, pending_mode); - } - CellControlCommand::Terminate { response_tx: next_response_tx } => { - if let Some(result) = pending_result.take() { - let _ = next_response_tx.send(Ok(pending_result_response(&cell_id, result))); - break; - } - - if termination_response_tx.is_some() { - let _ = next_response_tx.send(Err(already_terminating_error(&cell_id))); - continue; - } - - termination_response_tx = Some(next_response_tx); - termination_requested = true; - cancellation_token.cancel(); - yield_timer = None; - let _ = runtime_tx.send(RuntimeCommand::Terminate); - terminate_paused_runtime(&runtime_control_tx, pending_mode); - let _ = runtime_terminate_handle.terminate_execution(); - if runtime_closed { - finish_callbacks( - &cancellation_token, - &mut notification_tasks, - &mut tool_tasks, - CallbackCompletion::Cancel, - ).await; - let response = RuntimeResponse::Terminated { - cell_id: cell_id.clone(), - content_items: std::mem::take(&mut content_items), - }; - send_termination_responses( - response_tx.take(), - termination_response_tx.take(), - response, - ); - break; - } else { - continue; - } - } - } - } - _ = async { - if let Some(yield_timer) = yield_timer.as_mut() { - yield_timer.await; - } else { - std::future::pending::<()>().await; - } - } => { - yield_timer = None; - send_yield_response(&cell_id, &mut content_items, &mut response_tx); - } - maybe_event = async { - if runtime_closed { - std::future::pending::>().await - } else { - event_rx.recv().await - } - }, if !yield_deadline_elapsed => { - let Some(event) = maybe_event else { - runtime_closed = true; - if termination_requested { - finish_callbacks( - &cancellation_token, - &mut notification_tasks, - &mut tool_tasks, - CallbackCompletion::Cancel, - ).await; - let response = RuntimeResponse::Terminated { - cell_id: cell_id.clone(), - content_items: std::mem::take(&mut content_items), - }; - send_termination_responses( - response_tx.take(), - termination_response_tx.take(), - response, - ); - break; - } - if pending_result.is_none() { - let result = PendingResult { - content_items: std::mem::take(&mut content_items), - error_text: Some("exec runtime ended unexpectedly".to_string()), - }; - if send_or_buffer_result( - &cell_id, - result, - &mut response_tx, - &mut pending_result, - ) { - break; - } - } - continue; - }; - match event { - RuntimeEvent::Started => { - yield_timer = initial_yield_time_ms.map(|initial_yield_time_ms| { - Box::pin(tokio::time::sleep(Duration::from_millis(initial_yield_time_ms))) - }); - } - RuntimeEvent::Pending => { - if let Some(current_response_tx) = response_tx.take() { - match current_response_tx { - CellResponseSender::Runtime(runtime_response_tx) => { - response_tx = - Some(CellResponseSender::Runtime(runtime_response_tx)); - } - CellResponseSender::ExecuteToPending(response_tx) => { - let _ = response_tx.send(Ok(ExecuteToPendingOutcome::Pending { - cell_id: cell_id.clone(), - content_items: std::mem::take(&mut content_items), - pending_tool_call_ids: std::mem::take( - &mut pending_tool_call_ids, - ), - })); - } - } - } - } - RuntimeEvent::ContentItem(item) => { - content_items.push(item); - } - RuntimeEvent::YieldRequested => { - yield_timer = None; - send_yield_response(&cell_id, &mut content_items, &mut response_tx); - } - RuntimeEvent::Notify { call_id, text } => { - let delegate = Arc::clone(&inner.delegate); - let cell_id = cell_id.clone(); - let cancellation_token = cancellation_token.child_token(); - notification_tasks.spawn(async move { - if let Err(err) = delegate - .notify(call_id, cell_id.clone(), text, cancellation_token) - .await - { - warn!( - "failed to deliver code mode notification for cell {cell_id}: {err}" - ); - } - }); - } - RuntimeEvent::ToolCall { - id, - name, - kind, - input, - } => { - if pending_mode == PendingRuntimeMode::PauseUntilResumed { - pending_tool_call_ids.push(id.clone()); - } - let tool_call = CodeModeNestedToolCall { - cell_id: cell_id.clone(), - runtime_tool_call_id: id.clone(), - tool_name: name, - tool_kind: kind, - input, - }; - let delegate = Arc::clone(&inner.delegate); - let runtime_tx = runtime_tx.clone(); - let cancellation_token = cancellation_token.child_token(); - tool_tasks.spawn(async move { - let response = delegate.invoke_tool(tool_call, cancellation_token).await; - let command = match response { - Ok(result) => RuntimeCommand::ToolResponse { id, result }, - Err(error_text) => RuntimeCommand::ToolError { id, error_text }, - }; - let _ = runtime_tx.send(command); - }); - } - RuntimeEvent::Result { - stored_value_writes, - error_text, - } => { - yield_timer = None; - if termination_requested { - finish_callbacks( - &cancellation_token, - &mut notification_tasks, - &mut tool_tasks, - CallbackCompletion::Cancel, - ).await; - let response = RuntimeResponse::Terminated { - cell_id: cell_id.clone(), - content_items: std::mem::take(&mut content_items), - }; - send_termination_responses( - response_tx.take(), - termination_response_tx.take(), - response, - ); - break; - } - finish_callbacks( - &cancellation_token, - &mut notification_tasks, - &mut tool_tasks, - CallbackCompletion::DrainNotifications, - ).await; - inner - .stored_values - .lock() - .await - .extend(stored_value_writes); - let result = PendingResult { - content_items: std::mem::take(&mut content_items), - error_text, - }; - if send_or_buffer_result( - &cell_id, - result, - &mut response_tx, - &mut pending_result, - ) { - break; - } - } - } - } - task_result = notification_tasks.join_next(), if !notification_tasks.is_empty() => { - if let Some(Err(err)) = task_result - && !err.is_cancelled() - { - warn!("code mode notification task failed: {err}"); - } - } - task_result = tool_tasks.join_next(), if !tool_tasks.is_empty() => { - if let Some(Err(err)) = task_result - && !err.is_cancelled() - { - warn!("code mode tool task failed: {err}"); - } - } - } - } - - let _ = runtime_tx.send(RuntimeCommand::Terminate); - cancellation_token.cancel(); - finish_callbacks( - &cancellation_token, - &mut notification_tasks, - &mut tool_tasks, - CallbackCompletion::Cancel, - ) - .await; - terminate_paused_runtime(&runtime_control_tx, pending_mode); - inner.cells.lock().await.remove(&cell_id); - inner.delegate.cell_closed(&cell_id); -} - -#[derive(Clone, Copy)] -enum CallbackCompletion { - DrainNotifications, - Cancel, -} - -async fn finish_callbacks( - cancellation_token: &CancellationToken, - notification_tasks: &mut JoinSet<()>, - tool_tasks: &mut JoinSet<()>, - completion: CallbackCompletion, -) { - if matches!(completion, CallbackCompletion::Cancel) { - cancellation_token.cancel(); - } - drain_tasks(notification_tasks, "notification").await; - cancellation_token.cancel(); - drain_tasks(tool_tasks, "tool").await; -} - -async fn drain_tasks(tasks: &mut JoinSet<()>, description: &str) { - while let Some(result) = tasks.join_next().await { - if let Err(err) = result - && !err.is_cancelled() - { - warn!("code mode {description} task failed: {err}"); - } - } -} - -fn resume_paused_runtime( - runtime_control_tx: &std::sync::mpsc::Sender, - pending_mode: PendingRuntimeMode, -) { - if pending_mode == PendingRuntimeMode::PauseUntilResumed { - let _ = runtime_control_tx.send(RuntimeControlCommand::Resume); - } -} - -fn terminate_paused_runtime( - runtime_control_tx: &std::sync::mpsc::Sender, - pending_mode: PendingRuntimeMode, -) { - if pending_mode == PendingRuntimeMode::PauseUntilResumed { - let _ = runtime_control_tx.send(RuntimeControlCommand::Terminate); - } -} - #[cfg(test)] -mod tests { - use std::collections::HashMap; - use std::sync::Arc; - use std::sync::atomic::AtomicU64; - use std::sync::atomic::Ordering; - use std::time::Duration; - - use codex_protocol::ToolName; - use pretty_assertions::assert_eq; - use tokio::sync::Mutex; - use tokio::sync::mpsc; - use tokio::sync::oneshot; - - use super::CellControlCommand; - use super::CellControlContext; - use super::CellId; - use super::CellResponseSender; - use super::CodeModeService; - use super::Inner; - use super::NoopCodeModeSessionDelegate; - use super::PendingRuntimeMode; - use super::RuntimeCommand; - use super::RuntimeResponse; - use super::WaitOutcome; - use super::WaitRequest; - use super::WaitToPendingOutcome; - use super::WaitToPendingRequest; - use super::run_cell_control; - use crate::CodeModeToolKind; - use crate::ExecuteRequest; - use crate::ExecuteToPendingOutcome; - use crate::FunctionCallOutputContentItem; - use crate::ToolDefinition; - use crate::runtime::RuntimeEvent; - use crate::runtime::spawn_runtime; - - fn execute_request(source: &str) -> ExecuteRequest { - ExecuteRequest { - tool_call_id: "call_1".to_string(), - enabled_tools: Vec::new(), - source: source.to_string(), - yield_time_ms: Some(1), - max_output_tokens: None, - } - } - - fn cell_id(value: &str) -> CellId { - CellId::new(value.to_string()) - } - - async fn execute(service: &CodeModeService, request: ExecuteRequest) -> RuntimeResponse { - service - .execute(request) - .await - .unwrap() - .initial_response() - .await - .unwrap() - } - - fn test_inner() -> Arc { - Arc::new(Inner { - stored_values: Mutex::new(HashMap::new()), - cells: Mutex::new(HashMap::new()), - delegate: Arc::new(NoopCodeModeSessionDelegate), - shutting_down: std::sync::atomic::AtomicBool::new(false), - next_cell_id: AtomicU64::new(1), - }) - } - - #[tokio::test] - async fn synchronous_exit_returns_successfully() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#"text("before"); exit(); text("after");"#.to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "before".to_string(), - }], - error_text: None, - } - ); - } - - #[tokio::test] - async fn stored_values_are_shared_between_cells_but_not_sessions() { - let first_session = CodeModeService::new(); - let second_session = CodeModeService::new(); - - let write_response = execute( - &first_session, - ExecuteRequest { - source: r#"store("key", "visible");"#.to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - let same_session = execute( - &first_session, - ExecuteRequest { - source: r#"text(String(load("key")));"#.to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - let other_session = execute( - &second_session, - ExecuteRequest { - source: r#"text(String(load("key")));"#.to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - write_response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: Vec::new(), - error_text: None, - } - ); - assert_eq!( - same_session, - RuntimeResponse::Result { - cell_id: cell_id("2"), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "visible".to_string(), - }], - error_text: None, - } - ); - assert_eq!( - other_session, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "undefined".to_string(), - }], - error_text: None, - } - ); - } - - #[tokio::test] - async fn shutdown_interrupts_cpu_bound_cells() { - let service = CodeModeService::new(); - - let cell = service - .execute(ExecuteRequest { - source: "while (true) {}".to_string(), - ..execute_request("") - }) - .await - .unwrap(); - assert_eq!( - cell.initial_response().await.unwrap(), - RuntimeResponse::Yielded { - cell_id: cell_id("1"), - content_items: Vec::new(), - } - ); - - tokio::time::timeout(Duration::from_secs(1), service.shutdown()) - .await - .unwrap() - .unwrap(); - } - - #[tokio::test] - async fn start_cell_rejects_new_cell_after_shutdown_begins() { - let service = CodeModeService::new(); - service.inner.shutting_down.store(true, Ordering::Release); - let (response_tx, _response_rx) = oneshot::channel(); - - let error = service - .start_cell( - cell_id("late-cell"), - execute_request(""), - CellResponseSender::Runtime(response_tx), - Some(/*initial_yield_time_ms*/ 1), - PendingRuntimeMode::Continue, - ) - .await - .unwrap_err(); - - assert_eq!(error, "code mode session is shutting down".to_string()); - assert!(service.inner.cells.lock().await.is_empty()); - } - - #[tokio::test] - async fn execute_to_pending_returns_completed_for_synchronous_results() { - let service = CodeModeService::new(); - - let response = service - .execute_to_pending(ExecuteRequest { - source: r#"text("done");"#.to_string(), - yield_time_ms: Some(60_000), - ..execute_request("") - }) - .await - .unwrap(); - - assert_eq!( - response, - ExecuteToPendingOutcome::Completed(RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "done".to_string(), - }], - error_text: None, - }) - ); - } - - #[tokio::test] - async fn execute_to_pending_returns_once_the_runtime_is_quiescent() { - let service = CodeModeService::new(); - - let response = tokio::time::timeout( - Duration::from_secs(1), - service.execute_to_pending(ExecuteRequest { - source: r#"text("before"); await new Promise(() => {});"#.to_string(), - yield_time_ms: Some(60_000), - ..execute_request("") - }), - ) - .await - .unwrap() - .unwrap(); - - assert_eq!( - response, - ExecuteToPendingOutcome::Pending { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "before".to_string(), - }], - pending_tool_call_ids: Vec::new(), - } - ); - - let termination = service.terminate(cell_id("1")).await.unwrap(); - - assert_eq!( - termination, - WaitOutcome::LiveCell(RuntimeResponse::Terminated { - cell_id: cell_id("1"), - content_items: Vec::new(), - }) - ); - } - - #[tokio::test] - async fn execute_to_pending_identifies_tool_calls_in_paused_frontier() { - let service = CodeModeService::new(); - - let response = service - .execute_to_pending(ExecuteRequest { - enabled_tools: vec![ToolDefinition { - name: "echo".to_string(), - tool_name: ToolName::plain("echo"), - description: String::new(), - kind: CodeModeToolKind::Function, - input_schema: None, - output_schema: None, - }], - source: r#" -await Promise.all([ - tools.echo({ value: "first" }), - tools.echo({ value: "second" }), -]); -"# - .to_string(), - yield_time_ms: Some(60_000), - ..execute_request("") - }) - .await - .unwrap(); - - assert_eq!( - response, - ExecuteToPendingOutcome::Pending { - cell_id: cell_id("1"), - content_items: Vec::new(), - pending_tool_call_ids: vec!["tool-1".to_string(), "tool-2".to_string()], - } - ); - - let termination = service.terminate(cell_id("1")).await.unwrap(); - - assert_eq!( - termination, - WaitOutcome::LiveCell(RuntimeResponse::Terminated { - cell_id: cell_id("1"), - content_items: Vec::new(), - }) - ); - } - - #[tokio::test] - async fn execute_to_pending_excludes_delayed_timeout_tool_calls_until_wait() { - let service = CodeModeService::new(); - - let initial_response = service - .execute_to_pending(ExecuteRequest { - enabled_tools: vec![ToolDefinition { - name: "echo".to_string(), - tool_name: ToolName::plain("echo"), - description: String::new(), - kind: CodeModeToolKind::Function, - input_schema: None, - output_schema: None, - }], - source: r#" -setTimeout(() => { - tools.echo({ value: "delayed" }); -}, 1000); -await Promise.all([ - tools.echo({ value: "second" }), - tools.echo({ value: "third" }), -]); -"# - .to_string(), - yield_time_ms: Some(60_000), - ..execute_request("") - }) - .await - .unwrap(); - - assert_eq!( - initial_response, - ExecuteToPendingOutcome::Pending { - cell_id: cell_id("1"), - content_items: Vec::new(), - pending_tool_call_ids: vec!["tool-1".to_string(), "tool-2".to_string()], - } - ); - - let runtime_tx = service - .inner - .cells - .lock() - .await - .get(&cell_id("1")) - .unwrap() - .runtime_tx - .clone(); - runtime_tx - .send(RuntimeCommand::TimeoutFired { id: 1 }) - .unwrap(); - - let resumed_response = tokio::time::timeout( - Duration::from_secs(1), - service.wait_to_pending(WaitToPendingRequest { - cell_id: cell_id("1"), - }), - ) - .await - .unwrap() - .unwrap(); - - assert_eq!( - resumed_response, - WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Pending { - cell_id: cell_id("1"), - content_items: Vec::new(), - pending_tool_call_ids: vec!["tool-3".to_string()], - }) - ); - - let termination = service.terminate(cell_id("1")).await.unwrap(); - - assert_eq!( - termination, - WaitOutcome::LiveCell(RuntimeResponse::Terminated { - cell_id: cell_id("1"), - content_items: Vec::new(), - }) - ); - } - - #[tokio::test] - async fn wait_to_pending_returns_after_resumed_runtime_becomes_quiescent_again() { - let service = CodeModeService::new(); - - let initial_response = service - .execute_to_pending(ExecuteRequest { - source: r#" -await new Promise((resolve) => setTimeout(resolve, 60_000)); -text("after"); -await new Promise(() => {}); -"# - .to_string(), - yield_time_ms: Some(60_000), - ..execute_request("") - }) - .await - .unwrap(); - - assert_eq!( - initial_response, - ExecuteToPendingOutcome::Pending { - cell_id: cell_id("1"), - content_items: Vec::new(), - pending_tool_call_ids: Vec::new(), - } - ); - - let runtime_tx = service - .inner - .cells - .lock() - .await - .get(&cell_id("1")) - .unwrap() - .runtime_tx - .clone(); - runtime_tx - .send(RuntimeCommand::TimeoutFired { id: 1 }) - .unwrap(); - - let resumed_response = tokio::time::timeout( - Duration::from_secs(1), - service.wait_to_pending(WaitToPendingRequest { - cell_id: cell_id("1"), - }), - ) - .await - .unwrap() - .unwrap(); - - assert_eq!( - resumed_response, - WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Pending { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "after".to_string(), - }], - pending_tool_call_ids: Vec::new(), - }) - ); - - let termination = service.terminate(cell_id("1")).await.unwrap(); - - assert_eq!( - termination, - WaitOutcome::LiveCell(RuntimeResponse::Terminated { - cell_id: cell_id("1"), - content_items: Vec::new(), - }) - ); - } - - #[tokio::test] - async fn wait_to_pending_returns_completed_after_resumed_runtime_finishes() { - let service = CodeModeService::new(); - - let initial_response = service - .execute_to_pending(ExecuteRequest { - source: r#" -await new Promise((resolve) => setTimeout(resolve, 60_000)); -text("done"); -"# - .to_string(), - yield_time_ms: Some(60_000), - ..execute_request("") - }) - .await - .unwrap(); - - assert_eq!( - initial_response, - ExecuteToPendingOutcome::Pending { - cell_id: cell_id("1"), - content_items: Vec::new(), - pending_tool_call_ids: Vec::new(), - } - ); - - let runtime_tx = service - .inner - .cells - .lock() - .await - .get(&cell_id("1")) - .unwrap() - .runtime_tx - .clone(); - runtime_tx - .send(RuntimeCommand::TimeoutFired { id: 1 }) - .unwrap(); - - let resumed_response = tokio::time::timeout( - Duration::from_secs(1), - service.wait_to_pending(WaitToPendingRequest { - cell_id: cell_id("1"), - }), - ) - .await - .unwrap() - .unwrap(); - - assert_eq!( - resumed_response, - WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Completed( - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "done".to_string(), - }], - error_text: None, - } - )) - ); - } - - #[tokio::test] - async fn v8_console_is_not_exposed_on_global_this() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#"text(String(Object.hasOwn(globalThis, "console")));"#.to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "false".to_string(), - }], - error_text: None, - } - ); - } - - #[tokio::test] - async fn date_locale_string_formats_with_icu_data() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#" -const value = new Date("2025-01-02T03:04:05Z") - .toLocaleString("fr-FR", { - weekday: "long", - month: "long", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - timeZone: "UTC", - }); -text(value); -"# - .to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "jeudi 2 janvier \u{e0} 03:04:05".to_string(), - }], - error_text: None, - } - ); - } - - #[tokio::test] - async fn intl_date_time_format_formats_with_icu_data() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#" -const formatter = new Intl.DateTimeFormat("fr-FR", { - weekday: "long", - month: "long", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - timeZone: "UTC", -}); -text(formatter.format(new Date("2025-01-02T03:04:05Z"))); -"# - .to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "jeudi 2 janvier \u{e0} 03:04:05".to_string(), - }], - error_text: None, - } - ); - } - - #[tokio::test] - async fn output_helpers_return_undefined() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#" -const returnsUndefined = [ - text("first"), - image("data:image/png;base64,AAA"), - notify("ping"), -].map((value) => value === undefined); -text(JSON.stringify(returnsUndefined)); -"# - .to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![ - FunctionCallOutputContentItem::InputText { - text: "first".to_string(), - }, - FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - detail: Some(crate::DEFAULT_IMAGE_DETAIL), - }, - FunctionCallOutputContentItem::InputText { - text: "[true,true,true]".to_string(), - }, - ], - error_text: None, - } - ); - } - - #[tokio::test] - async fn image_helper_accepts_raw_mcp_image_block_with_original_detail() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#" -image({ - type: "image", - data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", - mimeType: "image/png", - _meta: { "codex/imageDetail": "original" }, -}); -"# - .to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(), - detail: Some(crate::ImageDetail::Original), - }], - error_text: None, - } - ); - } - - #[tokio::test] - async fn generated_image_helper_appends_image_and_output_hint() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#" -generatedImage({ - image_url: "data:image/png;base64,AAA", - output_hint: "generated image save hint", -}); -"# - .to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![ - FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - detail: Some(crate::DEFAULT_IMAGE_DETAIL), - }, - FunctionCallOutputContentItem::InputText { - text: "generated image save hint".to_string(), - }, - ], - error_text: None, - } - ); - } - - #[tokio::test] - async fn image_helper_second_arg_overrides_explicit_object_detail() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#" -image( - { - image_url: "data:image/png;base64,AAA", - detail: "high", - }, - "original", -); -"# - .to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - detail: Some(crate::ImageDetail::Original), - }], - error_text: None, - } - ); - } - - #[tokio::test] - async fn image_helper_second_arg_overrides_raw_mcp_image_detail() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#" -image( - { - type: "image", - data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", - mimeType: "image/png", - _meta: { "codex/imageDetail": "original" }, - }, - "high", -); -"# - .to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(), - detail: Some(crate::ImageDetail::High), - }], - error_text: None, - } - ); - } - - #[tokio::test] - async fn image_helper_accepts_low_detail() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#" -image({ - image_url: "data:image/png;base64,AAA", - detail: "low", -}); -"# - .to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - detail: Some(crate::ImageDetail::Low), - }], - error_text: None, - } - ); - } - - #[tokio::test] - async fn image_helpers_reject_remote_urls() { - for image_url in [ - "http://example.com/image.jpg", - "https://example.com/image.jpg", - ] { - for source in [ - format!("image({image_url:?});"), - format!("generatedImage({{ image_url: {image_url:?} }});"), - ] { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source, - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: Vec::new(), - error_text: Some( - "Tool call failed: remote image URLs are not supported in tool outputs. Pass a base64 data URI instead".to_string(), - ), - } - ); - } - } - } - - #[tokio::test] - async fn image_helper_rejects_unsupported_detail() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#" -image({ - image_url: "data:image/png;base64,AAA", - detail: "medium", -}); -"# - .to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: Vec::new(), - error_text: Some( - "image detail must be one of: auto, low, high, original".to_string() - ), - } - ); - } - - #[tokio::test] - async fn image_helper_rejects_raw_mcp_result_container() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#" -image({ - content: [ - { - type: "image", - data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", - mimeType: "image/png", - _meta: { "codex/imageDetail": "original" }, - }, - ], - isError: false, -}); -"# - .to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: Vec::new(), - error_text: Some( - "image expects a non-empty image URL string, an object with image_url and optional detail, or a raw MCP image block".to_string(), - ), - } - ); - } - - #[tokio::test] - async fn wait_reports_missing_cell_separately_from_runtime_results() { - let service = CodeModeService::new(); - - let response = service - .wait(WaitRequest { - cell_id: cell_id("missing"), - yield_time_ms: 1, - }) - .await - .unwrap(); - - assert_eq!( - response, - WaitOutcome::MissingCell(RuntimeResponse::Result { - cell_id: cell_id("missing"), - content_items: Vec::new(), - error_text: Some("exec cell missing not found".to_string()), - }) - ); - } - - #[tokio::test] - async fn terminate_waits_for_runtime_shutdown_before_responding() { - let inner = test_inner(); - let (event_tx, event_rx) = mpsc::unbounded_channel(); - let (control_tx, control_rx) = mpsc::unbounded_channel(); - let (initial_response_tx, initial_response_rx) = oneshot::channel(); - let (runtime_event_tx, _runtime_event_rx) = mpsc::unbounded_channel(); - let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = spawn_runtime( - HashMap::new(), - ExecuteRequest { - source: "await new Promise(() => {})".to_string(), - yield_time_ms: None, - ..execute_request("") - }, - runtime_event_tx, - PendingRuntimeMode::Continue, - ) - .unwrap(); - - tokio::spawn(run_cell_control( - inner, - CellControlContext { - cell_id: cell_id("cell-1"), - runtime_tx: runtime_tx.clone(), - runtime_control_tx, - pending_mode: PendingRuntimeMode::Continue, - runtime_terminate_handle, - cancellation_token: tokio_util::sync::CancellationToken::new(), - }, - event_rx, - control_rx, - CellResponseSender::Runtime(initial_response_tx), - Some(/*initial_yield_time_ms*/ 60_000), - )); - - event_tx.send(RuntimeEvent::Started).unwrap(); - event_tx.send(RuntimeEvent::YieldRequested).unwrap(); - assert_eq!( - initial_response_rx.await.unwrap(), - Ok(RuntimeResponse::Yielded { - cell_id: cell_id("cell-1"), - content_items: Vec::new(), - }) - ); - - let (terminate_response_tx, terminate_response_rx) = oneshot::channel(); - control_tx - .send(CellControlCommand::Terminate { - response_tx: terminate_response_tx, - }) - .unwrap(); - let terminate_response = async { terminate_response_rx.await.unwrap() }; - tokio::pin!(terminate_response); - assert!( - tokio::time::timeout(Duration::from_millis(100), terminate_response.as_mut()) - .await - .is_err() - ); - - drop(event_tx); - - assert_eq!( - terminate_response.await, - Ok(RuntimeResponse::Terminated { - cell_id: cell_id("cell-1"), - content_items: Vec::new(), - }) - ); - - let _ = runtime_tx.send(RuntimeCommand::Terminate); - } -} +#[path = "service_tests.rs"] +mod tests; #[cfg(test)] #[path = "service_contract_tests.rs"] diff --git a/codex-rs/code-mode/src/service_contract_tests.rs b/codex-rs/code-mode/src/service_contract_tests.rs index 752c6cc6ce..3d814e98cc 100644 --- a/codex-rs/code-mode/src/service_contract_tests.rs +++ b/codex-rs/code-mode/src/service_contract_tests.rs @@ -1,7 +1,5 @@ -use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::AtomicBool; -use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; use std::time::Duration; @@ -9,7 +7,6 @@ use codex_protocol::ToolName; use pretty_assertions::assert_eq; use tokio::sync::Notify; use tokio::sync::mpsc; -use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; use super::*; @@ -29,6 +26,7 @@ struct BlockingDelegate { events_tx: mpsc::UnboundedSender, notification_finished: AtomicBool, tool_finished: AtomicBool, + tool_release: Notify, } struct HeldNotificationDelegate { @@ -88,61 +86,6 @@ impl CodeModeSessionDelegate for HeldNotificationDelegate { } } -struct CellControlHarness { - event_tx: mpsc::UnboundedSender, - control_tx: mpsc::UnboundedSender, - initial_response_rx: oneshot::Receiver>, - task: tokio::task::JoinHandle<()>, - _runtime_event_rx: mpsc::UnboundedReceiver, -} - -fn spawn_cell_control_harness( - initial_yield_time_ms: Option, - delegate: Arc, -) -> CellControlHarness { - let (event_tx, event_rx) = mpsc::unbounded_channel(); - let (control_tx, control_rx) = mpsc::unbounded_channel(); - let (initial_response_tx, initial_response_rx) = oneshot::channel(); - let (runtime_event_tx, runtime_event_rx) = mpsc::unbounded_channel(); - let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = spawn_runtime( - HashMap::new(), - execute_request("await new Promise(() => {});"), - runtime_event_tx, - PendingRuntimeMode::Continue, - ) - .unwrap(); - let inner = Arc::new(Inner { - stored_values: Mutex::new(HashMap::new()), - cells: Mutex::new(HashMap::new()), - delegate, - shutting_down: AtomicBool::new(false), - next_cell_id: AtomicU64::new(1), - }); - let task = tokio::spawn(run_cell_control( - inner, - CellControlContext { - cell_id: cell_id("1"), - runtime_tx, - runtime_control_tx, - pending_mode: PendingRuntimeMode::Continue, - runtime_terminate_handle, - cancellation_token: CancellationToken::new(), - }, - event_rx, - control_rx, - CellResponseSender::Runtime(initial_response_tx), - initial_yield_time_ms, - )); - - CellControlHarness { - event_tx, - control_tx, - initial_response_rx, - task, - _runtime_event_rx: runtime_event_rx, - } -} - impl BlockingDelegate { fn new() -> (Arc, mpsc::UnboundedReceiver) { let (events_tx, events_rx) = mpsc::unbounded_channel(); @@ -151,10 +94,15 @@ impl BlockingDelegate { events_tx, notification_finished: AtomicBool::new(false), tool_finished: AtomicBool::new(false), + tool_release: Notify::new(), }), events_rx, ) } + + fn release_tool(&self) { + self.tool_release.notify_one(); + } } impl CodeModeSessionDelegate for BlockingDelegate { @@ -165,10 +113,17 @@ impl CodeModeSessionDelegate for BlockingDelegate { ) -> ToolInvocationFuture<'a> { Box::pin(async move { let _ = self.events_tx.send(DelegateEvent::ToolStarted); - cancellation_token.cancelled().await; - self.tool_finished.store(true, Ordering::Release); - let _ = self.events_tx.send(DelegateEvent::ToolCancelled); - Err("cancelled".to_string()) + tokio::select! { + _ = self.tool_release.notified() => { + self.tool_finished.store(true, Ordering::Release); + Ok(serde_json::Value::Null) + } + _ = cancellation_token.cancelled() => { + self.tool_finished.store(true, Ordering::Release); + let _ = self.events_tx.send(DelegateEvent::ToolCancelled); + Err("cancelled".to_string()) + } + } }) } @@ -227,87 +182,15 @@ async fn next_event(events_rx: &mut mpsc::UnboundedReceiver) -> D .expect("delegate event channel closed") } -#[tokio::test] -async fn yield_timer_preempts_buffered_runtime_output() { - let harness = spawn_cell_control_harness( - Some(/*initial_yield_time_ms*/ 0), - Arc::new(NoopCodeModeSessionDelegate), - ); - harness.event_tx.send(RuntimeEvent::Started).unwrap(); - harness - .event_tx - .send(RuntimeEvent::ContentItem( - FunctionCallOutputContentItem::InputText { - text: "queued output".to_string(), - }, - )) - .unwrap(); - - assert_eq!( - harness.initial_response_rx.await.unwrap(), - Ok(RuntimeResponse::Yielded { - cell_id: cell_id("1"), - content_items: Vec::new(), - }) - ); - - let (termination_tx, termination_rx) = oneshot::channel(); - harness - .control_tx - .send(CellControlCommand::Terminate { - response_tx: termination_tx, - }) - .unwrap(); - drop(harness.event_tx); - assert_eq!( - termination_rx.await.unwrap(), - Ok(RuntimeResponse::Terminated { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "queued output".to_string(), - }], - }) - ); - harness.task.await.unwrap(); -} - -#[tokio::test] -async fn queued_termination_preempts_unobserved_runtime_completion() { - let harness = spawn_cell_control_harness( - Some(/*initial_yield_time_ms*/ 60_000), - Arc::new(NoopCodeModeSessionDelegate), - ); - harness - .event_tx - .send(RuntimeEvent::Result { - stored_value_writes: HashMap::new(), - error_text: None, - }) - .unwrap(); - let (termination_tx, termination_rx) = oneshot::channel(); - harness - .control_tx - .send(CellControlCommand::Terminate { - response_tx: termination_tx, - }) - .unwrap(); - - let terminated = Ok(RuntimeResponse::Terminated { - cell_id: cell_id("1"), - content_items: Vec::new(), - }); - assert_eq!(termination_rx.await.unwrap(), terminated.clone()); - assert_eq!(harness.initial_response_rx.await.unwrap(), terminated); - harness.task.await.unwrap(); -} - #[tokio::test] async fn yields_and_resumes() { let service = CodeModeService::new(); let cell = service - .execute(execute_request( - r#"text("before"); yield_control(); text("after");"#, - )) + .execute(ExecuteRequest { + source: r#"text("before"); yield_control(); text("after");"#.to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) .await .unwrap(); @@ -324,7 +207,7 @@ async fn yields_and_resumes() { service .wait(WaitRequest { cell_id: cell_id("1"), - yield_time_ms: 1, + yield_time_ms: 60_000, }) .await .unwrap(), @@ -340,35 +223,32 @@ async fn yields_and_resumes() { #[tokio::test] async fn returns_and_resumes_from_the_pending_frontier() { - let service = CodeModeService::new(); + let (delegate, mut events_rx) = BlockingDelegate::new(); + let service = CodeModeService::with_delegate(delegate.clone()); assert_eq!( service - .execute_to_pending(execute_request( - r#" -await new Promise((resolve) => setTimeout(resolve, 60_000)); + .execute_to_pending(ExecuteRequest { + enabled_tools: vec![blocking_tool()], + source: r#" +await tools.block({}); text("after"); -"#, - )) +"# + .to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) .await .unwrap(), ExecuteToPendingOutcome::Pending { cell_id: cell_id("1"), content_items: Vec::new(), - pending_tool_call_ids: Vec::new(), + pending_tool_call_ids: vec!["tool-1".to_string()], } ); - service - .inner - .cells - .lock() - .await - .get(&cell_id("1")) - .unwrap() - .runtime_tx - .send(RuntimeCommand::TimeoutFired { id: 1 }) - .unwrap(); + assert_eq!(next_event(&mut events_rx).await, DelegateEvent::ToolStarted); + delegate.release_tool(); assert_eq!( service @@ -391,55 +271,36 @@ text("after"); #[tokio::test] async fn observed_natural_completion_wins_over_termination() { - let (delegate, mut events_rx) = BlockingDelegate::new(); - let harness = - spawn_cell_control_harness(Some(/*initial_yield_time_ms*/ 60_000), delegate.clone()); - harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap(); + let service = CodeModeService::new(); + let cell = service + .execute(execute_request( + r#"yield_control(); store("finished", true); text("done");"#, + )) + .await + .unwrap(); assert_eq!( - harness.initial_response_rx.await.unwrap(), - Ok(RuntimeResponse::Yielded { + cell.initial_response().await.unwrap(), + RuntimeResponse::Yielded { cell_id: cell_id("1"), content_items: Vec::new(), - }) + } ); - harness - .event_tx - .send(RuntimeEvent::ContentItem( - FunctionCallOutputContentItem::InputText { - text: "done".to_string(), - }, - )) - .unwrap(); - harness - .event_tx - .send(RuntimeEvent::Result { - stored_value_writes: HashMap::new(), - error_text: None, - }) - .unwrap(); - harness - .event_tx - .send(RuntimeEvent::Notify { - call_id: "notify-1".to_string(), - text: "completion observed".to_string(), - }) - .unwrap(); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if service.inner.stored_values.lock().await.get("finished") + == Some(&serde_json::json!(true)) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); assert_eq!( - next_event(&mut events_rx).await, - DelegateEvent::NotificationStarted - ); - - let (termination_tx, termination_rx) = oneshot::channel(); - harness - .control_tx - .send(CellControlCommand::Terminate { - response_tx: termination_tx, - }) - .unwrap(); - assert_eq!( - termination_rx.await.unwrap(), - Ok(RuntimeResponse::Result { + service.terminate(cell_id("1")).await.unwrap(), + WaitOutcome::LiveCell(RuntimeResponse::Result { cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputText { text: "done".to_string(), @@ -447,16 +308,6 @@ async fn observed_natural_completion_wins_over_termination() { error_text: None, }) ); - harness.task.await.unwrap(); - assert!(delegate.notification_finished.load(Ordering::Acquire)); - assert_eq!( - next_event(&mut events_rx).await, - DelegateEvent::NotificationCancelled - ); - assert_eq!( - next_event(&mut events_rx).await, - DelegateEvent::CellClosed(cell_id("1")) - ); } #[tokio::test] @@ -499,6 +350,36 @@ async fn termination_cancels_pending_callbacks_before_responding() { ); } +#[tokio::test] +async fn shutdown_cancels_notifications_while_natural_completion_is_draining() { + let (delegate, mut events_rx) = HeldNotificationDelegate::new(); + let service = Arc::new(CodeModeService::with_delegate(delegate.clone())); + service + .execute(execute_request(r#"notify("pending");"#)) + .await + .unwrap(); + + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::NotificationStarted + ); + + let shutdown_service = Arc::clone(&service); + let shutdown = tokio::spawn(async move { shutdown_service.shutdown().await }); + + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::NotificationCancelled + ); + delegate.release_notification(); + + assert_eq!(shutdown.await.unwrap(), Ok(())); + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::CellClosed(cell_id("1")) + ); +} + #[tokio::test] async fn repeated_termination_is_rejected_while_callback_cleanup_is_pending() { let (delegate, mut events_rx) = HeldNotificationDelegate::new(); diff --git a/codex-rs/code-mode/src/service_tests.rs b/codex-rs/code-mode/src/service_tests.rs new file mode 100644 index 0000000000..688bd89cd2 --- /dev/null +++ b/codex-rs/code-mode/src/service_tests.rs @@ -0,0 +1,972 @@ +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use super::CellId; +use super::CodeModeNestedToolCall; +use super::CodeModeService; +use super::CodeModeSessionDelegate; +use super::NotificationFuture; +use super::ObserveMode; +use super::RuntimeResponse; +use super::ToolInvocationFuture; +use super::WaitOutcome; +use super::WaitRequest; +use super::WaitToPendingOutcome; +use super::WaitToPendingRequest; +use crate::CodeModeToolKind; +use crate::ExecuteRequest; +use crate::ExecuteToPendingOutcome; +use crate::FunctionCallOutputContentItem; +use crate::ToolDefinition; +use codex_protocol::ToolName; +use pretty_assertions::assert_eq; +use serde_json::Value as JsonValue; +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; + +#[derive(Default)] +struct ReleasableToolDelegate { + tool_release: Notify, +} + +impl ReleasableToolDelegate { + fn release_tool(&self) { + self.tool_release.notify_one(); + } +} + +impl CodeModeSessionDelegate for ReleasableToolDelegate { + fn invoke_tool<'a>( + &'a self, + _invocation: CodeModeNestedToolCall, + cancellation_token: CancellationToken, + ) -> ToolInvocationFuture<'a> { + Box::pin(async move { + tokio::select! { + _ = self.tool_release.notified() => Ok(JsonValue::Null), + _ = cancellation_token.cancelled() => Err("cancelled".to_string()), + } + }) + } + + fn notify<'a>( + &'a self, + _call_id: String, + _cell_id: CellId, + _text: String, + _cancellation_token: CancellationToken, + ) -> NotificationFuture<'a> { + Box::pin(async { Ok(()) }) + } + + fn cell_closed(&self, _cell_id: &CellId) {} +} + +fn execute_request(source: &str) -> ExecuteRequest { + ExecuteRequest { + tool_call_id: "call_1".to_string(), + enabled_tools: Vec::new(), + source: source.to_string(), + yield_time_ms: Some(1), + max_output_tokens: None, + } +} + +fn cell_id(value: &str) -> CellId { + CellId::new(value.to_string()) +} + +fn echo_tool() -> ToolDefinition { + ToolDefinition { + name: "echo".to_string(), + tool_name: ToolName::plain("echo"), + description: String::new(), + kind: CodeModeToolKind::Function, + input_schema: None, + output_schema: None, + } +} + +async fn execute(service: &CodeModeService, request: ExecuteRequest) -> RuntimeResponse { + service + .execute(request) + .await + .unwrap() + .initial_response() + .await + .unwrap() +} + +#[tokio::test] +async fn synchronous_exit_returns_successfully() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#"text("before"); exit(); text("after");"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "before".to_string(), + }], + error_text: None, + } + ); +} + +#[tokio::test] +async fn stored_values_are_shared_between_cells_but_not_sessions() { + let first_session = CodeModeService::new(); + let second_session = CodeModeService::new(); + + let write_response = execute( + &first_session, + ExecuteRequest { + source: r#"store("key", "visible");"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + let same_session = execute( + &first_session, + ExecuteRequest { + source: r#"text(String(load("key")));"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + let other_session = execute( + &second_session, + ExecuteRequest { + source: r#"text(String(load("key")));"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + write_response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: Vec::new(), + error_text: None, + } + ); + assert_eq!( + same_session, + RuntimeResponse::Result { + cell_id: cell_id("2"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "visible".to_string(), + }], + error_text: None, + } + ); + assert_eq!( + other_session, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "undefined".to_string(), + }], + error_text: None, + } + ); +} + +#[tokio::test] +async fn shutdown_interrupts_cpu_bound_cells() { + let service = CodeModeService::new(); + + let cell = service + .execute(ExecuteRequest { + source: "while (true) {}".to_string(), + ..execute_request("") + }) + .await + .unwrap(); + assert_eq!( + cell.initial_response().await.unwrap(), + RuntimeResponse::Yielded { + cell_id: cell_id("1"), + content_items: Vec::new(), + } + ); + + tokio::time::timeout(Duration::from_secs(1), service.shutdown()) + .await + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn start_cell_rejects_new_cell_after_shutdown_begins() { + let service = CodeModeService::new(); + service.inner.shutting_down.store(true, Ordering::Release); + + let error = service + .start_cell( + cell_id("late-cell"), + execute_request(""), + ObserveMode::YieldAfter(Duration::from_millis(1)), + ) + .await + .err() + .unwrap(); + + assert_eq!(error, "code mode session is shutting down".to_string()); + assert!(service.inner.cells.lock().await.is_empty()); +} + +#[tokio::test] +async fn execute_to_pending_returns_completed_for_synchronous_results() { + let service = CodeModeService::new(); + + let response = service + .execute_to_pending(ExecuteRequest { + source: r#"text("done");"#.to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + ExecuteToPendingOutcome::Completed(RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "done".to_string(), + }], + error_text: None, + }) + ); +} + +#[tokio::test] +async fn execute_to_pending_returns_once_the_runtime_is_quiescent() { + let service = CodeModeService::new(); + + let response = tokio::time::timeout( + Duration::from_secs(1), + service.execute_to_pending(ExecuteRequest { + source: r#"text("before"); await new Promise(() => {});"#.to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + response, + ExecuteToPendingOutcome::Pending { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "before".to_string(), + }], + pending_tool_call_ids: Vec::new(), + } + ); + + let termination = service.terminate(cell_id("1")).await.unwrap(); + + assert_eq!( + termination, + WaitOutcome::LiveCell(RuntimeResponse::Terminated { + cell_id: cell_id("1"), + content_items: Vec::new(), + }) + ); +} + +#[tokio::test] +async fn execute_to_pending_identifies_tool_calls_in_paused_frontier() { + let service = CodeModeService::new(); + + let response = service + .execute_to_pending(ExecuteRequest { + enabled_tools: vec![echo_tool()], + source: r#" +await Promise.all([ + tools.echo({ value: "first" }), + tools.echo({ value: "second" }), +]); +"# + .to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + ExecuteToPendingOutcome::Pending { + cell_id: cell_id("1"), + content_items: Vec::new(), + pending_tool_call_ids: vec!["tool-1".to_string(), "tool-2".to_string()], + } + ); + + let termination = service.terminate(cell_id("1")).await.unwrap(); + + assert_eq!( + termination, + WaitOutcome::LiveCell(RuntimeResponse::Terminated { + cell_id: cell_id("1"), + content_items: Vec::new(), + }) + ); +} + +#[tokio::test] +async fn execute_to_pending_excludes_delayed_timeout_tool_calls_until_wait() { + let service = CodeModeService::new(); + + let initial_response = service + .execute_to_pending(ExecuteRequest { + enabled_tools: vec![echo_tool()], + source: r#" +setTimeout(() => { + tools.echo({ value: "delayed" }); +}, 1000); +await Promise.all([ + tools.echo({ value: "second" }), + tools.echo({ value: "third" }), +]); +"# + .to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + initial_response, + ExecuteToPendingOutcome::Pending { + cell_id: cell_id("1"), + content_items: Vec::new(), + pending_tool_call_ids: vec!["tool-1".to_string(), "tool-2".to_string()], + } + ); + + tokio::time::sleep(Duration::from_millis(1100)).await; + + let resumed_response = tokio::time::timeout( + Duration::from_secs(1), + service.wait_to_pending(WaitToPendingRequest { + cell_id: cell_id("1"), + }), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + resumed_response, + WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Pending { + cell_id: cell_id("1"), + content_items: Vec::new(), + pending_tool_call_ids: vec!["tool-3".to_string()], + }) + ); + + let termination = service.terminate(cell_id("1")).await.unwrap(); + + assert_eq!( + termination, + WaitOutcome::LiveCell(RuntimeResponse::Terminated { + cell_id: cell_id("1"), + content_items: Vec::new(), + }) + ); +} + +#[tokio::test] +async fn wait_to_pending_returns_after_resumed_runtime_becomes_quiescent_again() { + let delegate = Arc::new(ReleasableToolDelegate::default()); + let service = CodeModeService::with_delegate(delegate.clone()); + + let initial_response = service + .execute_to_pending(ExecuteRequest { + enabled_tools: vec![echo_tool()], + source: r#" +await tools.echo({}); +text("after"); +await new Promise(() => {}); +"# + .to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + initial_response, + ExecuteToPendingOutcome::Pending { + cell_id: cell_id("1"), + content_items: Vec::new(), + pending_tool_call_ids: vec!["tool-1".to_string()], + } + ); + + delegate.release_tool(); + + let resumed_response = tokio::time::timeout( + Duration::from_secs(1), + service.wait_to_pending(WaitToPendingRequest { + cell_id: cell_id("1"), + }), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + resumed_response, + WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Pending { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "after".to_string(), + }], + pending_tool_call_ids: Vec::new(), + }) + ); + + let termination = service.terminate(cell_id("1")).await.unwrap(); + + assert_eq!( + termination, + WaitOutcome::LiveCell(RuntimeResponse::Terminated { + cell_id: cell_id("1"), + content_items: Vec::new(), + }) + ); +} + +#[tokio::test] +async fn wait_to_pending_returns_completed_after_resumed_runtime_finishes() { + let delegate = Arc::new(ReleasableToolDelegate::default()); + let service = CodeModeService::with_delegate(delegate.clone()); + + let initial_response = service + .execute_to_pending(ExecuteRequest { + enabled_tools: vec![echo_tool()], + source: r#" +await tools.echo({}); +text("done"); +"# + .to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + initial_response, + ExecuteToPendingOutcome::Pending { + cell_id: cell_id("1"), + content_items: Vec::new(), + pending_tool_call_ids: vec!["tool-1".to_string()], + } + ); + + delegate.release_tool(); + + let resumed_response = tokio::time::timeout( + Duration::from_secs(1), + service.wait_to_pending(WaitToPendingRequest { + cell_id: cell_id("1"), + }), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + resumed_response, + WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Completed( + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "done".to_string(), + }], + error_text: None, + } + )) + ); +} + +#[tokio::test] +async fn v8_console_is_not_exposed_on_global_this() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#"text(String(Object.hasOwn(globalThis, "console")));"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "false".to_string(), + }], + error_text: None, + } + ); +} + +#[tokio::test] +async fn date_locale_string_formats_with_icu_data() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +const value = new Date("2025-01-02T03:04:05Z") + .toLocaleString("fr-FR", { + weekday: "long", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + timeZone: "UTC", + }); +text(value); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "jeudi 2 janvier \u{e0} 03:04:05".to_string(), + }], + error_text: None, + } + ); +} + +#[tokio::test] +async fn intl_date_time_format_formats_with_icu_data() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +const formatter = new Intl.DateTimeFormat("fr-FR", { + weekday: "long", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + timeZone: "UTC", +}); +text(formatter.format(new Date("2025-01-02T03:04:05Z"))); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "jeudi 2 janvier \u{e0} 03:04:05".to_string(), + }], + error_text: None, + } + ); +} + +#[tokio::test] +async fn output_helpers_return_undefined() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +const returnsUndefined = [ + text("first"), + image("data:image/png;base64,AAA"), + notify("ping"), +].map((value) => value === undefined); +text(JSON.stringify(returnsUndefined)); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![ + FunctionCallOutputContentItem::InputText { + text: "first".to_string(), + }, + FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + detail: Some(crate::DEFAULT_IMAGE_DETAIL), + }, + FunctionCallOutputContentItem::InputText { + text: "[true,true,true]".to_string(), + }, + ], + error_text: None, + } + ); +} + +#[tokio::test] +async fn image_helper_accepts_raw_mcp_image_block_with_original_detail() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +image({ + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", + mimeType: "image/png", + _meta: { "codex/imageDetail": "original" }, +}); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(), + detail: Some(crate::ImageDetail::Original), + }], + error_text: None, + } + ); +} + +#[tokio::test] +async fn generated_image_helper_appends_image_and_output_hint() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +generatedImage({ + image_url: "data:image/png;base64,AAA", + output_hint: "generated image save hint", +}); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![ + FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + detail: Some(crate::DEFAULT_IMAGE_DETAIL), + }, + FunctionCallOutputContentItem::InputText { + text: "generated image save hint".to_string(), + }, + ], + error_text: None, + } + ); +} + +#[tokio::test] +async fn image_helper_second_arg_overrides_explicit_object_detail() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +image( + { + image_url: "data:image/png;base64,AAA", + detail: "high", + }, + "original", +); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + detail: Some(crate::ImageDetail::Original), + }], + error_text: None, + } + ); +} + +#[tokio::test] +async fn image_helper_second_arg_overrides_raw_mcp_image_detail() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +image( + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", + mimeType: "image/png", + _meta: { "codex/imageDetail": "original" }, + }, + "high", +); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(), + detail: Some(crate::ImageDetail::High), + }], + error_text: None, + } + ); +} + +#[tokio::test] +async fn image_helper_accepts_low_detail() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +image({ + image_url: "data:image/png;base64,AAA", + detail: "low", +}); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + detail: Some(crate::ImageDetail::Low), + }], + error_text: None, + } + ); +} + +#[tokio::test] +async fn image_helpers_reject_remote_urls() { + for image_url in [ + "http://example.com/image.jpg", + "https://example.com/image.jpg", + ] { + for source in [ + format!("image({image_url:?});"), + format!("generatedImage({{ image_url: {image_url:?} }});"), + ] { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source, + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: Vec::new(), + error_text: Some( + "Tool call failed: remote image URLs are not supported in tool outputs. Pass a base64 data URI instead".to_string(), + ), + } + ); + } + } +} + +#[tokio::test] +async fn image_helper_rejects_unsupported_detail() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +image({ + image_url: "data:image/png;base64,AAA", + detail: "medium", +}); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: Vec::new(), + error_text: Some("image detail must be one of: auto, low, high, original".to_string()), + } + ); +} + +#[tokio::test] +async fn image_helper_rejects_raw_mcp_result_container() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +image({ + content: [ + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", + mimeType: "image/png", + _meta: { "codex/imageDetail": "original" }, + }, + ], + isError: false, +}); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: Vec::new(), + error_text: Some( + "image expects a non-empty image URL string, an object with image_url and optional detail, or a raw MCP image block".to_string(), + ), + } + ); +} + +#[tokio::test] +async fn wait_reports_missing_cell_separately_from_runtime_results() { + let service = CodeModeService::new(); + + let response = service + .wait(WaitRequest { + cell_id: cell_id("missing"), + yield_time_ms: 1, + }) + .await + .unwrap(); + + assert_eq!( + response, + WaitOutcome::MissingCell(RuntimeResponse::Result { + cell_id: cell_id("missing"), + content_items: Vec::new(), + error_text: Some("exec cell missing not found".to_string()), + }) + ); +} From 0a3ad4c4baca405367d470c9a255fb5642873ec1 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 16 Jun 2026 20:14:23 -0700 Subject: [PATCH 21/70] [codex] Test code-mode variable truncation (#28471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Code mode has two separate truncation points: the nested tool result returned to JavaScript and the code-mode output later recorded for the model. These tests now verify those behaviors independently. - Report whether `result.output` was truncated before printing it. - Verify omitted or sufficiently large nested limits produce `Variable truncated: False`, while allowing the printed value to be truncated downstream. - Verify an explicit nested limit produces `Variable truncated: True` when the command output exceeds it. - Use a token-policy model fixture so downstream truncation is visible as `…N tokens truncated…`. - Align the explicit nested-truncation expectation with the warning header. This PR changes test coverage only; runtime truncation behavior is unchanged. ## Validation - `env -u CODEX_SANDBOX_NETWORK_DISABLED RUST_MIN_STACK=8388608 cargo test -p codex-core --test all code_mode_exec -- --nocapture` (8 passed) --- codex-rs/core/tests/suite/code_mode.rs | 143 ++++++++++++++++--------- 1 file changed, 91 insertions(+), 52 deletions(-) diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 6a4121cc5c..60b476d614 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -940,9 +940,15 @@ text(JSON.stringify(results)); Ok(()) } +// This model uses token-based tool-output truncation, giving the downstream +// history assertions a stable `…N tokens truncated…` marker. +const TOKEN_POLICY_TEST_MODEL: &str = "gpt-5.4"; + +// A nested `exec_command` limit applies to `result.output` inside JavaScript. +// The outer code-mode and history budgets apply after the script calls `text`. #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_exec_command_explicit_max_output_tokens_truncates() -> Result<()> { +async fn code_mode_exec_nested_limit_formats_truncated_result_with_warning() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -964,7 +970,7 @@ text(result.output); &custom_tool_output_items(&second_mock.single_request(), "call-1"), /*index*/ 1 ), - "Total output lines: 1\n\n0123456789…5 tokens truncated…0123456789" + "Warning: truncated output (original token count: 10)\nTotal output lines: 1\n\n0123456789…5 tokens truncated…0123456789" ); Ok(()) @@ -972,7 +978,8 @@ text(result.output); #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_exec_explicit_max_above_default_preserves_output() -> Result<()> { +async fn code_mode_exec_nested_limit_preserves_result_variable_before_default_history_truncation() +-> Result<()> { // TODO(anp): Remove after Wine exec returns complete nested-tool output to code mode. skip_if_wine_exec!( Ok(()), @@ -981,7 +988,7 @@ async fn code_mode_exec_explicit_max_above_default_preserves_output() -> Result< skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; - let (_test, second_mock) = run_code_mode_turn( + let (_test, second_mock) = run_code_mode_turn_with_model_and_config( &server, "use exec_command from code mode", r#"// @exec: {"max_output_tokens": 20000} @@ -989,17 +996,19 @@ const result = await tools.exec_command({ cmd: "python3 -c \"import sys; sys.stdout.write('x' * 50000)\"", max_output_tokens: 20000 }); -text(result.output); +const resultVariableWasTruncated = result.output.length !== 50000; +text(`Variable truncated: ${resultVariableWasTruncated ? "True" : "False"}. Variable: ${result.output}`); "#, + TOKEN_POLICY_TEST_MODEL, + |_| {}, ) .await?; - assert_eq!( - text_item( - &custom_tool_output_items(&second_mock.single_request(), "call-1"), - /*index*/ 1 - ), - "x".repeat(50_000) + let items = custom_tool_output_items(&second_mock.single_request(), "call-1"); + let output = text_item(&items, /*index*/ 1); + assert_regex_match( + r"^Variable truncated: False\. Variable: x+…\d+ tokens truncated…x+$", + output, ); Ok(()) @@ -1007,7 +1016,7 @@ text(result.output); #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_exec_explicit_max_above_default_truncates_larger_output() -> Result<()> { +async fn code_mode_exec_nested_limit_truncates_result_variable_when_exceeded() -> Result<()> { // TODO(anp): Remove after Wine exec returns complete nested-tool output to code mode. skip_if_wine_exec!( Ok(()), @@ -1016,7 +1025,7 @@ async fn code_mode_exec_explicit_max_above_default_truncates_larger_output() -> skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; - let (_test, second_mock) = run_code_mode_turn( + let (_test, second_mock) = run_code_mode_turn_with_model_and_config( &server, "use exec_command from code mode", r#"// @exec: {"max_output_tokens": 25000} @@ -1024,21 +1033,28 @@ const result = await tools.exec_command({ cmd: "python3 -c \"import sys; sys.stdout.write('A' * 90000)\"", max_output_tokens: 20000 }); -text(result.output); +const resultVariableWasTruncated = result.output.includes("…2500 tokens truncated…"); +text(`Variable truncated: ${resultVariableWasTruncated ? "True" : "False"}. Variable: ${result.output}`); "#, + TOKEN_POLICY_TEST_MODEL, + |_| {}, ) .await?; - assert_eq!( - text_item( - &custom_tool_output_items(&second_mock.single_request(), "call-1"), - /*index*/ 1 - ), - format!( - "Warning: truncated output (original token count: 22500)\nTotal output lines: 1\n\n{}…2500 tokens truncated…{}", - "A".repeat(40_000), - "A".repeat(40_000) - ) + let items = custom_tool_output_items(&second_mock.single_request(), "call-1"); + let output = text_item(&items, /*index*/ 1); + // The nested 20,000-token budget leaves about 80,000 characters. This + // ceiling independently proves that history applied its smaller cap. + assert!( + output.len() < 60_000, + "expected history to truncate the emitted value, got {} bytes", + output.len() + ); + // The boolean describes the nested result; the marker below comes from + // history truncating the value emitted with `text` afterward. + assert_regex_match( + r"(?s)^Variable truncated: True\. Variable: .*…\d+ tokens truncated…A+$", + output, ); Ok(()) @@ -1046,7 +1062,8 @@ text(result.output); #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_exec_explicit_max_above_truncation_policy_preserves_output() -> Result<()> { +async fn code_mode_exec_nested_limit_preserves_result_variable_before_configured_history_truncation() +-> Result<()> { // TODO(anp): Remove after Wine exec returns complete nested-tool output to code mode. skip_if_wine_exec!( Ok(()), @@ -1055,7 +1072,7 @@ async fn code_mode_exec_explicit_max_above_truncation_policy_preserves_output() skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; - let (_test, second_mock) = run_code_mode_turn_with_config( + let (_test, second_mock) = run_code_mode_turn_with_model_and_config( &server, "use exec_command from code mode", r#"// @exec: {"max_output_tokens": 20000} @@ -1063,20 +1080,28 @@ const result = await tools.exec_command({ cmd: "python3 -c \"import sys; sys.stdout.write('x' * 50000)\"", max_output_tokens: 20000 }); -text(result.output); +const resultVariableWasTruncated = result.output.length !== 50000; +text(`Variable truncated: ${resultVariableWasTruncated ? "True" : "False"}. Variable: ${result.output}`); "#, + TOKEN_POLICY_TEST_MODEL, |config| { config.tool_output_token_limit = Some(50); }, ) .await?; - assert_eq!( - text_item( - &custom_tool_output_items(&second_mock.single_request(), "call-1"), - /*index*/ 1 - ), - "x".repeat(50_000) + let items = custom_tool_output_items(&second_mock.single_request(), "call-1"); + let output = text_item(&items, /*index*/ 1); + // The 50-token override must shrink this 50,000-character value far below + // what the default 10,000-token history cap would retain. + assert!( + output.len() < 1_000, + "expected configured history cap to truncate the emitted value, got {} bytes", + output.len() + ); + assert_regex_match( + r"^Variable truncated: False\. Variable: x+…\d+ tokens truncated…x+$", + output, ); Ok(()) @@ -1084,7 +1109,8 @@ text(result.output); #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_exec_without_max_preserves_output_beyond_default() -> Result<()> { +async fn code_mode_exec_without_nested_limit_preserves_result_variable_before_default_history_truncation() +-> Result<()> { // TODO(anp): Remove after Wine exec returns complete nested-tool output to code mode. skip_if_wine_exec!( Ok(()), @@ -1093,24 +1119,26 @@ async fn code_mode_exec_without_max_preserves_output_beyond_default() -> Result< skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; - let (_test, second_mock) = run_code_mode_turn( + let (_test, second_mock) = run_code_mode_turn_with_model_and_config( &server, "use exec_command from code mode", r#"// @exec: {"max_output_tokens": 20000} const result = await tools.exec_command({ cmd: "python3 -c \"import sys; sys.stdout.write('x' * 50000)\"" }); -text(result.output); +const resultVariableWasTruncated = result.output.length !== 50000; +text(`Variable truncated: ${resultVariableWasTruncated ? "True" : "False"}. Variable: ${result.output}`); "#, + TOKEN_POLICY_TEST_MODEL, + |_| {}, ) .await?; - assert_eq!( - text_item( - &custom_tool_output_items(&second_mock.single_request(), "call-1"), - /*index*/ 1 - ), - "x".repeat(50_000) + let items = custom_tool_output_items(&second_mock.single_request(), "call-1"); + let output = text_item(&items, /*index*/ 1); + assert_regex_match( + r"^Variable truncated: False\. Variable: x+…\d+ tokens truncated…x+$", + output, ); Ok(()) @@ -1118,7 +1146,8 @@ text(result.output); #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_exec_without_max_preserves_output_beyond_truncation_policy() -> Result<()> { +async fn code_mode_exec_without_nested_limit_preserves_result_variable_before_configured_history_truncation() +-> Result<()> { // TODO(anp): Remove after Wine exec returns complete nested-tool output to code mode. skip_if_wine_exec!( Ok(()), @@ -1127,35 +1156,45 @@ async fn code_mode_exec_without_max_preserves_output_beyond_truncation_policy() skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; - let (_test, second_mock) = run_code_mode_turn_with_config( + let (_test, second_mock) = run_code_mode_turn_with_model_and_config( &server, "use exec_command from code mode", r#"// @exec: {"max_output_tokens": 20000} const result = await tools.exec_command({ cmd: "python3 -c \"import sys; sys.stdout.write('x' * 50000)\"" }); -text(result.output); +const resultVariableWasTruncated = result.output.length !== 50000; +text(`Variable truncated: ${resultVariableWasTruncated ? "True" : "False"}. Variable: ${result.output}`); "#, + TOKEN_POLICY_TEST_MODEL, |config| { config.tool_output_token_limit = Some(50); }, ) .await?; - assert_eq!( - text_item( - &custom_tool_output_items(&second_mock.single_request(), "call-1"), - /*index*/ 1 - ), - "x".repeat(50_000) + let items = custom_tool_output_items(&second_mock.single_request(), "call-1"); + let output = text_item(&items, /*index*/ 1); + // The 50-token override must shrink this 50,000-character value far below + // what the default 10,000-token history cap would retain. + assert!( + output.len() < 1_000, + "expected configured history cap to truncate the emitted value, got {} bytes", + output.len() + ); + assert_regex_match( + r"^Variable truncated: False\. Variable: x+…\d+ tokens truncated…x+$", + output, ); Ok(()) } +// The outer directive limits output after JavaScript emits it; it does not +// limit `result.output` returned by the nested command. #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_exec_explicit_max_output_tokens_truncates() -> Result<()> { +async fn code_mode_exec_outer_limit_truncates_emitted_output() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; From cb15c647608ea848713837e4b49975e5a1481051 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 16 Jun 2026 21:39:30 -0700 Subject: [PATCH 22/70] Revert thread recencyAt for sidebar ordering (#28655) ## Why Revert #27910 to remove the newly introduced thread `recencyAt` persistence and API behavior from `main`. ## What changed This reverts commit `fac3158c2a783095768076489815f361fa9b0db4`, including the state migration, thread-store propagation, app-server API surface, generated schemas, and related tests. ## Validation Not run before opening; relying on CI for the initial fast signal. --- .../analytics/src/analytics_client_tests.rs | 1 - codex-rs/analytics/src/client_tests.rs | 1 - .../schema/json/ClientRequest.json | 3 +- .../schema/json/ServerNotification.json | 8 - .../codex_app_server_protocol.schemas.json | 11 +- .../codex_app_server_protocol.v2.schemas.json | 11 +- .../schema/json/v2/ThreadForkResponse.json | 8 - .../schema/json/v2/ThreadListParams.json | 3 +- .../schema/json/v2/ThreadListResponse.json | 8 - .../json/v2/ThreadMetadataUpdateResponse.json | 8 - .../schema/json/v2/ThreadReadResponse.json | 8 - .../schema/json/v2/ThreadResumeResponse.json | 8 - .../json/v2/ThreadRollbackResponse.json | 8 - .../schema/json/v2/ThreadStartResponse.json | 8 - .../json/v2/ThreadStartedNotification.json | 8 - .../json/v2/ThreadUnarchiveResponse.json | 8 - .../schema/typescript/v2/Thread.ts | 4 - .../schema/typescript/v2/ThreadSortKey.ts | 2 +- .../src/protocol/common.rs | 2 - .../src/protocol/v2/tests.rs | 10 - .../src/protocol/v2/thread.rs | 1 - .../src/protocol/v2/thread_data.rs | 3 - codex-rs/app-server/README.md | 7 +- .../app-server/src/bespoke_event_handling.rs | 1 - .../request_processors/thread_processor.rs | 5 - .../thread_processor_tests.rs | 1 - .../thread_resume_redaction.rs | 1 - .../src/request_processors/thread_summary.rs | 1 - codex-rs/app-server/src/thread_status.rs | 1 - .../app-server/tests/suite/v2/thread_list.rs | 83 ----- .../tests/suite/v2/thread_resume.rs | 26 +- codex-rs/core/src/realtime_context_tests.rs | 4 - codex-rs/exec/src/lib_tests.rs | 2 - codex-rs/rollout/src/list.rs | 71 ++-- codex-rs/rollout/src/metadata.rs | 1 - codex-rs/rollout/src/metadata_tests.rs | 1 - codex-rs/rollout/src/recorder.rs | 67 +--- codex-rs/rollout/src/recorder_tests.rs | 3 - codex-rs/rollout/src/state_db.rs | 7 +- codex-rs/rollout/src/state_db_tests.rs | 16 - codex-rs/rollout/src/tests.rs | 11 - .../migrations/0038_threads_recency_at.sql | 28 -- codex-rs/state/src/extract.rs | 1 - codex-rs/state/src/migrations.rs | 4 - codex-rs/state/src/migrations_tests.rs | 129 ------- codex-rs/state/src/model/thread_metadata.rs | 26 +- codex-rs/state/src/runtime.rs | 44 +-- codex-rs/state/src/runtime/memories.rs | 2 - codex-rs/state/src/runtime/test_support.rs | 1 - codex-rs/state/src/runtime/threads.rs | 337 ++---------------- codex-rs/thread-store/src/in_memory.rs | 3 - .../thread-store/src/local/archive_thread.rs | 1 - codex-rs/thread-store/src/local/helpers.rs | 2 - .../thread-store/src/local/list_threads.rs | 1 - codex-rs/thread-store/src/local/mod.rs | 92 ----- .../thread-store/src/local/read_thread.rs | 9 - .../thread-store/src/local/search_threads.rs | 16 +- .../src/local/search_threads_tests.rs | 29 -- .../src/local/unarchive_thread.rs | 1 - .../src/local/update_thread_metadata.rs | 18 - .../thread-store/src/thread_metadata_sync.rs | 31 +- codex-rs/thread-store/src/types.rs | 10 - codex-rs/tui/src/app/loaded_threads.rs | 1 - codex-rs/tui/src/app/tests.rs | 4 - codex-rs/tui/src/app/thread_session_state.rs | 1 - codex-rs/tui/src/app_server_session.rs | 1 - codex-rs/tui/src/resume_picker.rs | 12 +- 67 files changed, 95 insertions(+), 1150 deletions(-) delete mode 100644 codex-rs/state/migrations/0038_threads_recency_at.sql delete mode 100644 codex-rs/state/src/migrations_tests.rs delete mode 100644 codex-rs/thread-store/src/local/search_threads_tests.rs diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index b18b253b4a..7c634ce370 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -178,7 +178,6 @@ fn sample_thread_with_metadata( model_provider: "openai".to_string(), created_at: 1, updated_at: 2, - recency_at: Some(2), status: AppServerThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 18bb8c2159..1835932e8e 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -285,7 +285,6 @@ fn sample_thread(thread_id: &str) -> Thread { model_provider: "openai".to_string(), created_at: 1, updated_at: 2, - recency_at: Some(2), status: AppServerThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index e7ead53958..3358ea9d63 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -4158,8 +4158,7 @@ "ThreadSortKey": { "enum": [ "created_at", - "updated_at", - "recency_at" + "updated_at" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index a0d49cc548..d64befa0a0 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3582,14 +3582,6 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, - "recencyAt": { - "description": "Unix timestamp (in seconds) used for thread recency ordering.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index bbf68eb315..1e11c76ae4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -16621,14 +16621,6 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, - "recencyAt": { - "description": "Unix timestamp (in seconds) used for thread recency ordering.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -18888,8 +18880,7 @@ "ThreadSortKey": { "enum": [ "created_at", - "updated_at", - "recency_at" + "updated_at" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 238be0011c..326bfdfc9a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -14429,14 +14429,6 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, - "recencyAt": { - "description": "Unix timestamp (in seconds) used for thread recency ordering.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -16696,8 +16688,7 @@ "ThreadSortKey": { "enum": [ "created_at", - "updated_at", - "recency_at" + "updated_at" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 2b7caeca7e..4a666672b6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1055,14 +1055,6 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, - "recencyAt": { - "description": "Unix timestamp (in seconds) used for thread recency ordering.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json index 11f7da47d3..789d9b61fc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json @@ -24,8 +24,7 @@ "ThreadSortKey": { "enum": [ "created_at", - "updated_at", - "recency_at" + "updated_at" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 0caa0daf5d..c1d1b6759a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -870,14 +870,6 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, - "recencyAt": { - "description": "Unix timestamp (in seconds) used for thread recency ordering.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index a98d3586ec..801d1cf131 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -870,14 +870,6 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, - "recencyAt": { - "description": "Unix timestamp (in seconds) used for thread recency ordering.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 51e56f14b8..01b5ed907e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -870,14 +870,6 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, - "recencyAt": { - "description": "Unix timestamp (in seconds) used for thread recency ordering.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index e8c309507e..032d1dd74e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1055,14 +1055,6 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, - "recencyAt": { - "description": "Unix timestamp (in seconds) used for thread recency ordering.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 52cba55edb..c1ad534756 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -870,14 +870,6 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, - "recencyAt": { - "description": "Unix timestamp (in seconds) used for thread recency ordering.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 245cc946d4..33373b9f72 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1055,14 +1055,6 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, - "recencyAt": { - "description": "Unix timestamp (in seconds) used for thread recency ordering.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index bf497ecfa2..577df57e05 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -870,14 +870,6 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, - "recencyAt": { - "description": "Unix timestamp (in seconds) used for thread recency ordering.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 14f25cfcdc..fcc42e6261 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -870,14 +870,6 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, - "recencyAt": { - "description": "Unix timestamp (in seconds) used for thread recency ordering.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 1c288ccbd4..5fa30b64e3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -41,10 +41,6 @@ createdAt: number, * Unix timestamp (in seconds) when the thread was last updated. */ updatedAt: number, -/** - * Unix timestamp (in seconds) used for thread recency ordering. - */ -recencyAt: number | null, /** * Current runtime status for the thread. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts index d93f1c47bf..dbf1b6c40f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ThreadSortKey = "created_at" | "updated_at" | "recency_at"; +export type ThreadSortKey = "created_at" | "updated_at"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 3e9c0aade4..7611c27ff1 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2501,7 +2501,6 @@ mod tests { model_provider: "openai".to_string(), created_at: 1, updated_at: 2, - recency_at: Some(3), status: v2::ThreadStatus::Idle, path: None, cwd: cwd.clone(), @@ -2545,7 +2544,6 @@ mod tests { "modelProvider": "openai", "createdAt": 1, "updatedAt": 2, - "recencyAt": 3, "status": { "type": "idle" }, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 39bc52717a..de138d5c2e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -174,7 +174,6 @@ fn thread_resume_response_round_trips_initial_turns_page() { model_provider: "openai".to_string(), created_at: 1, updated_at: 1, - recency_at: Some(1), status: ThreadStatus::Idle, path: None, cwd: absolute_path("tmp"), @@ -3601,7 +3600,6 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { assert_eq!(start.instruction_sources, Vec::::new()); assert_eq!(start.thread.parent_thread_id, None); - assert_eq!(start.thread.recency_at, None); assert_eq!(resume.instruction_sources, Vec::::new()); assert_eq!(fork.instruction_sources, Vec::::new()); assert_eq!(start.active_permission_profile, None); @@ -3610,14 +3608,6 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { assert_eq!(fork.active_permission_profile, None); } -#[test] -fn thread_recency_sort_key_serializes_as_snake_case() { - assert_eq!( - serde_json::to_value(ThreadSortKey::RecencyAt).expect("sort key should serialize"), - json!("recency_at") - ); -} - #[test] fn turn_start_params_preserve_explicit_null_service_tier() { let params: TurnStartParams = serde_json::from_value(json!({ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index b5809f09b8..0252c4b048 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -1086,7 +1086,6 @@ pub enum ThreadSourceKind { pub enum ThreadSortKey { CreatedAt, UpdatedAt, - RecencyAt, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs index 52a8d7017a..fc0762361f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -152,9 +152,6 @@ pub struct Thread { /// Unix timestamp (in seconds) when the thread was last updated. #[ts(type = "number")] pub updated_at: i64, - /// Unix timestamp (in seconds) used for thread recency ordering. - #[ts(type = "number | null")] - pub recency_at: Option, /// Current runtime status for the thread. pub status: ThreadStatus, /// [UNSTABLE] Path to the thread on disk. diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 8d470b8288..336f769d71 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -364,8 +364,7 @@ Like `thread/resume`, experimental clients can pass `excludeTurns: true` to `thr - `cursor` — opaque string from a prior response; omit for the first page. - `limit` — server defaults to a reasonable page size if unset. -- `sortKey` — `created_at` (default), `updated_at`, or `recency_at`. -- `recencyAt` is initialized when the thread is created and advances when a turn starts. Unlike `updatedAt`, background output and other persisted mutations do not advance it. +- `sortKey` — `created_at` (default) or `updated_at`. - `sortDirection` — `desc` (default) or `asc`. - `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers. - `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`). @@ -387,8 +386,8 @@ Example: } } { "id": 20, "result": { "data": [ - { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "recencyAt": 1730831111, "status": { "type": "notLoaded" }, "agentNickname": "Atlas", "agentRole": "explorer" }, - { "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000, "recencyAt": 1730750000, "status": { "type": "notLoaded" } } + { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "status": { "type": "notLoaded" }, "agentNickname": "Atlas", "agentRole": "explorer" }, + { "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000, "status": { "type": "notLoaded" } } ], "nextCursor": "opaque-token-or-null", "backwardsCursor": "opaque-token-or-null" diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f71401e95c..868cef81fc 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2259,7 +2259,6 @@ mod tests { reasoning_effort: None, created_at, updated_at: created_at, - recency_at: created_at, archived_at: None, cwd: test_path_buf("/tmp").abs().into(), cli_version: "0.0.0".to_string(), diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 907b05a03b..a5299f2c72 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1897,7 +1897,6 @@ impl ThreadRequestProcessor { let store_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) { ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt, - ThreadSortKey::RecencyAt => StoreThreadSortKey::RecencyAt, }; let sort_direction = sort_direction.unwrap_or(SortDirection::Desc); let (stored_threads, next_cursor) = self @@ -1979,7 +1978,6 @@ impl ThreadRequestProcessor { let store_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) { ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt, - ThreadSortKey::RecencyAt => StoreThreadSortKey::RecencyAt, }; let store_sort_direction = sort_direction.unwrap_or(SortDirection::Desc); let (allowed_sources, source_kind_filter) = compute_source_filters(source_kinds); @@ -3697,7 +3695,6 @@ fn thread_backwards_cursor_for_sort_key( let timestamp = match sort_key { StoreThreadSortKey::CreatedAt => thread.created_at, StoreThreadSortKey::UpdatedAt => thread.updated_at, - StoreThreadSortKey::RecencyAt => thread.recency_at, }; // The state DB stores unique millisecond timestamps. Offset the reverse cursor by one // millisecond so the opposite-direction query includes the page anchor. @@ -4142,7 +4139,6 @@ pub(crate) fn thread_from_stored_thread( }, created_at: thread.created_at.timestamp(), updated_at: thread.updated_at.timestamp(), - recency_at: Some(thread.recency_at.timestamp()), status: ThreadStatus::NotLoaded, path, cwd, @@ -4348,7 +4344,6 @@ fn build_thread_from_snapshot( model_provider: config_snapshot.model_provider_id.clone(), created_at: now, updated_at: now, - recency_at: Some(now), status: ThreadStatus::NotLoaded, path, cwd: config_snapshot.cwd().clone(), diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 1f333f1eb1..42711e8570 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -478,7 +478,6 @@ mod thread_processor_behavior_tests { reasoning_effort: None, created_at: created_at.with_timezone(&Utc), updated_at: updated_at.with_timezone(&Utc), - recency_at: updated_at.with_timezone(&Utc), archived_at: None, cwd: PathBuf::from("/tmp"), cli_version: "0.0.0".to_string(), diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index 110d6d5cbf..e970a88a9f 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -178,7 +178,6 @@ mod tests { model_provider: "mock_provider".to_string(), created_at: 0, updated_at: 0, - recency_at: Some(0), status: ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index 67d2d9eabc..0fb1320055 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -320,7 +320,6 @@ pub(crate) fn summary_to_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), - recency_at: updated_at.map(|dt| dt.timestamp()), status: ThreadStatus::NotLoaded, path: (!path.as_os_str().is_empty()).then_some(path), cwd, diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index a5997a58f5..6d66a32d6a 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -897,7 +897,6 @@ mod tests { model_provider: "mock-provider".to_string(), created_at: 0, updated_at: 0, - recency_at: Some(0), status: ThreadStatus::NotLoaded, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 9bdff3bb8b..3f6adc167e 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -1689,89 +1689,6 @@ async fn thread_list_sort_updated_at_orders_by_mtime() -> Result<()> { Ok(()) } -#[tokio::test] -async fn thread_list_sort_recency_at_uses_state_db_order_with_provider_filter() -> Result<()> { - let codex_home = TempDir::new()?; - create_minimal_config(codex_home.path())?; - - let id_old = create_fake_rollout( - codex_home.path(), - "2025-01-01T10-00-00", - "2025-01-01T10:00:00Z", - "Hello", - Some("mock_provider"), - /*git_info*/ None, - )?; - let id_new = create_fake_rollout( - codex_home.path(), - "2025-01-01T11-00-00", - "2025-01-01T11:00:00Z", - "Hello", - Some("mock_provider"), - /*git_info*/ None, - )?; - set_rollout_mtime( - rollout_path(codex_home.path(), "2025-01-01T10-00-00", &id_old).as_path(), - "2025-01-03T00:00:00Z", - )?; - - let state_db = - codex_state::StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()) - .await?; - state_db - .mark_backfill_complete(/*last_watermark*/ None) - .await?; - let rollout_config = codex_rollout::RolloutConfig { - codex_home: codex_home.path().to_path_buf(), - sqlite_home: codex_home.path().to_path_buf(), - cwd: codex_home.path().to_path_buf(), - model_provider_id: "mock_provider".to_string(), - generate_memories: false, - }; - codex_core::RolloutRecorder::list_threads( - Some(state_db.clone()), - &rollout_config, - /*page_size*/ 10, - /*cursor*/ None, - codex_core::ThreadSortKey::CreatedAt, - codex_core::SortDirection::Desc, - codex_core::INTERACTIVE_SESSION_SOURCES.as_slice(), - /*model_providers*/ None, - /*cwd_filters*/ None, - "mock_provider", - /*search_term*/ None, - ) - .await?; - state_db - .touch_thread_recency_at( - ThreadId::from_string(&id_new)?, - DateTime::::from_timestamp(1_800_000_000, 0).expect("timestamp"), - ) - .await?; - - let mut mcp = init_mcp(codex_home.path()).await?; - let ThreadListResponse { data, .. } = list_threads_with_sort( - &mut mcp, - /*cursor*/ None, - Some(10), - Some(vec!["mock_provider".to_string()]), - /*source_kinds*/ None, - Some(ThreadSortKey::RecencyAt), - /*archived*/ None, - ) - .await?; - - assert_eq!( - data.iter() - .map(|thread| thread.id.as_str()) - .collect::>(), - vec![id_new.as_str(), id_old.as_str()] - ); - assert!(data.iter().all(|thread| thread.recency_at.is_some())); - - Ok(()) -} - #[tokio::test] async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> { let codex_home = TempDir::new()?; 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 03e30e373e..e67184bf01 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -2200,7 +2200,6 @@ async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; assert_eq!(thread.updated_at, before_resume.updated_at); - assert_eq!(thread.recency_at, before_resume.recency_at); assert_eq!(thread.status, ThreadStatus::Idle); let after_modified = std::fs::metadata(&rollout.rollout_file_path)?.modified()?; @@ -2235,7 +2234,7 @@ async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { - thread_id: thread_id.clone(), + thread_id, input: vec![UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -2248,29 +2247,6 @@ async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), ) .await??; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/started"), - ) - .await??; - - let read_id = mcp - .send_thread_read_request(ThreadReadParams { - thread_id: thread_id.clone(), - include_turns: false, - }) - .await?; - let read_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(read_id)), - ) - .await??; - let ThreadReadResponse { - thread: after_turn_start, - .. - } = to_response::(read_resp)?; - assert!(after_turn_start.recency_at > before_resume.recency_at); - timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index 352d6ae8e4..f04282a65f 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -47,10 +47,6 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre .timestamp_opt(1_709_251_200, 0) .single() .expect("valid timestamp"), - recency_at: Utc - .timestamp_opt(1_709_251_200, 0) - .single() - .expect("valid timestamp"), archived_at: None, cwd: PathBuf::from(cwd), cli_version: "test".to_string(), diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 526c5fc487..6b27d01116 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -342,7 +342,6 @@ fn turn_items_for_thread_returns_matching_turn_items() { model_provider: "openai".to_string(), created_at: 0, updated_at: 0, - recency_at: Some(0), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/project").abs(), @@ -761,7 +760,6 @@ fn sample_thread_start_response() -> ThreadStartResponse { model_provider: "openai".to_string(), created_at: 0, updated_at: 0, - recency_at: Some(0), status: codex_app_server_protocol::ThreadStatus::Idle, path: Some(PathBuf::from("/tmp/rollout.jsonl")), cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/rollout/src/list.rs b/codex-rs/rollout/src/list.rs index f5013e7aae..845145aaef 100644 --- a/codex-rs/rollout/src/list.rs +++ b/codex-rs/rollout/src/list.rs @@ -77,8 +77,6 @@ pub struct ThreadItem { pub created_at: Option, /// RFC3339 timestamp string for the most recent update (from file mtime). pub updated_at: Option, - /// RFC3339 timestamp string used for product recency ordering. - pub recency_at: Option, } #[allow(dead_code)] @@ -117,7 +115,6 @@ const USER_EVENT_SCAN_LIMIT: usize = 200; pub enum ThreadSortKey { CreatedAt, UpdatedAt, - RecencyAt, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -140,29 +137,20 @@ pub struct ThreadListConfig<'a> { pub layout: ThreadListLayout, } -/// Pagination cursor identifying the last item in a page. +/// Pagination cursor identifying the timestamp of the last item in a page. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Cursor { ts: OffsetDateTime, - id: Option, } impl Cursor { - pub(crate) fn new(ts: OffsetDateTime) -> Self { - Self { ts, id: None } - } - - pub(crate) fn with_thread_id(ts: OffsetDateTime, id: ThreadId) -> Self { - Self { ts, id: Some(id) } + fn new(ts: OffsetDateTime) -> Self { + Self { ts } } pub(crate) fn timestamp(&self) -> OffsetDateTime { self.ts } - - pub(crate) fn thread_id(&self) -> Option { - self.id - } } /// Keeps track of where a paginated listing left off. As the file scan goes newest -> oldest, @@ -299,10 +287,7 @@ impl serde::Serialize for Cursor { .ts .format(&Rfc3339) .map_err(|e| serde::ser::Error::custom(format!("format error: {e}")))?; - match self.id { - Some(id) => serializer.serialize_str(&format!("{ts_str}|{id}")), - None => serializer.serialize_str(&ts_str), - } + serializer.serialize_str(&ts_str) } } @@ -323,7 +308,7 @@ impl From for Cursor { .timestamp_nanos_opt() .and_then(|nanos| OffsetDateTime::from_unix_timestamp_nanos(nanos as i128).ok()) .unwrap_or(OffsetDateTime::UNIX_EPOCH); - Self { ts, id: anchor.id } + Self::new(ts) } } @@ -434,7 +419,7 @@ async fn traverse_directories_for_paths( ) .await } - ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => { + ThreadSortKey::UpdatedAt => { traverse_directories_for_paths_updated( root, page_size, @@ -469,7 +454,7 @@ async fn traverse_flat_paths( ) .await } - ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => { + ThreadSortKey::UpdatedAt => { traverse_flat_paths_updated( root, page_size, @@ -717,48 +702,35 @@ async fn traverse_flat_paths_updated( }) } -/// Pagination cursor token format: an RFC3339 timestamp with an optional thread ID tie-breaker. +/// Pagination cursor token format: an RFC3339 timestamp. pub fn parse_cursor(token: &str) -> Option { - let (timestamp, id) = match token.rsplit_once('|') { - Some((timestamp, id)) => (timestamp, Some(ThreadId::from_string(id).ok()?)), - None => (token, None), - }; + if token.contains('|') { + return None; + } - let ts = OffsetDateTime::parse(timestamp, &Rfc3339) - .ok() - .or_else(|| { - let format: &[FormatItem] = - format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); - PrimitiveDateTime::parse(timestamp, format) - .ok() - .map(PrimitiveDateTime::assume_utc) - })?; + let ts = OffsetDateTime::parse(token, &Rfc3339).ok().or_else(|| { + let format: &[FormatItem] = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); + PrimitiveDateTime::parse(token, format) + .ok() + .map(PrimitiveDateTime::assume_utc) + })?; - Some(Cursor { ts, id }) + Some(Cursor::new(ts)) } fn build_next_cursor(items: &[ThreadItem], sort_key: ThreadSortKey) -> Option { let last = items.last()?; let file_name = last.path.file_name()?.to_string_lossy(); - let (created_ts, id) = parse_timestamp_uuid_from_filename(&file_name)?; + let (created_ts, _id) = parse_timestamp_uuid_from_filename(&file_name)?; let ts = match sort_key { ThreadSortKey::CreatedAt => created_ts, ThreadSortKey::UpdatedAt => { let updated_at = last.updated_at.as_deref()?; OffsetDateTime::parse(updated_at, &Rfc3339).ok()? } - ThreadSortKey::RecencyAt => { - let recency_at = last.recency_at.as_deref().or(last.updated_at.as_deref())?; - OffsetDateTime::parse(recency_at, &Rfc3339).ok()? - } }; - match sort_key { - ThreadSortKey::RecencyAt => Some(Cursor::with_thread_id( - ts, - ThreadId::from_string(&id.to_string()).ok()?, - )), - ThreadSortKey::CreatedAt | ThreadSortKey::UpdatedAt => Some(Cursor::new(ts)), - } + Some(Cursor::new(ts)) } async fn build_thread_item( @@ -834,7 +806,6 @@ async fn build_thread_item( model_provider, cli_version, created_at, - recency_at: summary_updated_at.clone(), updated_at: summary_updated_at, }); } diff --git a/codex-rs/rollout/src/metadata.rs b/codex-rs/rollout/src/metadata.rs index dcb38b6d2d..19e9a9320c 100644 --- a/codex-rs/rollout/src/metadata.rs +++ b/codex-rs/rollout/src/metadata.rs @@ -115,7 +115,6 @@ pub async fn extract_metadata_from_rollout( } if let Some(updated_at) = file_modified_time_utc(rollout_path).await { metadata.updated_at = updated_at; - metadata.recency_at = updated_at; } Ok(ExtractionOutcome { metadata, diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index 01e56f569d..03d251e1b3 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -71,7 +71,6 @@ async fn extract_metadata_from_rollout_uses_session_meta() { let mut expected = builder.build("openai"); apply_rollout_item(&mut expected, &rollout_line.item, "openai"); expected.updated_at = file_modified_time_utc(&path).await.expect("mtime"); - expected.recency_at = expected.updated_at; assert_eq!(outcome.metadata, expected); assert_eq!(outcome.memory_mode, None); diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 7ba111e944..f12593f009 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -543,27 +543,6 @@ impl RolloutRecorder { ) .await; } - if sort_key == ThreadSortKey::RecencyAt { - if let Some(repaired_db_page) = state_db::list_threads_db( - state_db_ctx.as_deref(), - codex_home, - page_size, - cursor, - sort_key, - sort_direction, - allowed_sources, - model_providers, - cwd_filters, - /*parent_thread_id*/ None, - archived, - search_term, - ) - .await - { - return Ok(repaired_db_page.into()); - } - return Ok(db_page.into()); - } codex_state::record_fallback( "list_threads", "metadata_filter", @@ -1006,11 +985,6 @@ fn truncate_fs_page( let cursor_token = match sort_key { ThreadSortKey::CreatedAt => created_at.format(&Rfc3339).ok()?, ThreadSortKey::UpdatedAt => item.updated_at.as_deref()?.to_string(), - ThreadSortKey::RecencyAt => item - .recency_at - .as_deref() - .or(item.updated_at.as_deref())? - .to_string(), }; parse_cursor(cursor_token.as_str()) }); @@ -1075,7 +1049,6 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt cli_version, created_at, updated_at, - recency_at, } = state_item; if item.first_user_message.is_none() { @@ -1120,9 +1093,6 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt if item.updated_at.is_none() { item.updated_at = updated_at; } - if recency_at.is_some() { - item.recency_at = recency_at; - } } #[allow(clippy::too_many_arguments)] @@ -1299,20 +1269,9 @@ async fn list_threads_from_files_asc( .collect::>(); if let Some(cursor) = cursor { - let anchor = ( - cursor.timestamp(), - cursor - .thread_id() - .and_then(|id| uuid::Uuid::parse_str(&id.to_string()).ok()), - ); - all_items.retain(|item| { - thread_item_sort_key(item, sort_key).is_some_and(|key| match anchor.1 { - Some(anchor_id) if sort_key == ThreadSortKey::RecencyAt => { - key > (anchor.0, anchor_id) - } - _ => key.0 > anchor.0, - }) - }); + let anchor = cursor.timestamp(); + all_items + .retain(|item| thread_item_sort_key(item, sort_key).is_some_and(|key| key.0 > anchor)); } let more_matches_available = all_items.len() > page_size || reached_scan_cap; @@ -1370,27 +1329,14 @@ fn thread_item_sort_key( let updated_at = item.updated_at.as_deref().or(item.created_at.as_deref())?; OffsetDateTime::parse(updated_at, &Rfc3339).ok()? } - ThreadSortKey::RecencyAt => { - let recency_at = item - .recency_at - .as_deref() - .or(item.updated_at.as_deref()) - .or(item.created_at.as_deref())?; - OffsetDateTime::parse(recency_at, &Rfc3339).ok()? - } }; Some((timestamp, id)) } fn cursor_from_thread_item(item: &ThreadItem, sort_key: ThreadSortKey) -> Option { - let (timestamp, id) = thread_item_sort_key(item, sort_key)?; - match sort_key { - ThreadSortKey::RecencyAt => Some(Cursor::with_thread_id( - timestamp, - ThreadId::from_string(&id.to_string()).ok()?, - )), - ThreadSortKey::CreatedAt | ThreadSortKey::UpdatedAt => Some(Cursor::new(timestamp)), - } + let (timestamp, _id) = thread_item_sort_key(item, sort_key)?; + let cursor_token = timestamp.format(&Rfc3339).ok()?; + parse_cursor(cursor_token.as_str()) } struct LogFileInfo { @@ -1779,7 +1725,6 @@ fn thread_item_from_state_metadata(item: codex_state::ThreadMetadata) -> ThreadI cli_version: Some(item.cli_version), created_at: Some(item.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)), updated_at: Some(item.updated_at.to_rfc3339_opts(SecondsFormat::Millis, true)), - recency_at: Some(item.recency_at.to_rfc3339_opts(SecondsFormat::Millis, true)), } } diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 2558750f15..c49a477944 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -984,7 +984,6 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi model_provider: None, cli_version: None, created_at: None, - recency_at: Some("2025-01-03T15:59:00.000Z".to_string()), updated_at: None, }; let state_item = ThreadItem { @@ -1003,7 +1002,6 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi model_provider: Some("state-provider".to_string()), cli_version: Some("state-version".to_string()), created_at: Some("2025-01-03T16:00:00Z".to_string()), - recency_at: Some("2025-01-03T16:00:30.001Z".to_string()), updated_at: Some("2025-01-03T16:01:02.003Z".to_string()), }; @@ -1029,7 +1027,6 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi assert_eq!(item.model_provider.as_deref(), Some("state-provider")); assert_eq!(item.cli_version.as_deref(), Some("state-version")); assert_eq!(item.created_at.as_deref(), Some("2025-01-03T16:00:00Z")); - assert_eq!(item.recency_at.as_deref(), Some("2025-01-03T16:00:30.001Z")); assert_eq!(item.updated_at.as_deref(), Some("2025-01-03T16:01:02.003Z")); } diff --git a/codex-rs/rollout/src/state_db.rs b/codex-rs/rollout/src/state_db.rs index 96fbf7af1c..ab93305fa6 100644 --- a/codex-rs/rollout/src/state_db.rs +++ b/codex-rs/rollout/src/state_db.rs @@ -290,10 +290,7 @@ fn cursor_to_anchor(cursor: Option<&Cursor>) -> Option { let millis = cursor.timestamp().unix_timestamp_nanos() / 1_000_000; let millis = i64::try_from(millis).ok()?; let ts = chrono::DateTime::::from_timestamp_millis(millis)?; - Some(codex_state::Anchor { - ts, - id: cursor.thread_id(), - }) + Some(codex_state::Anchor { ts }) } pub fn normalize_cwd_for_state_db(cwd: &Path) -> PathBuf { @@ -339,7 +336,6 @@ pub async fn list_thread_ids_db( match sort_key { ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt, - ThreadSortKey::RecencyAt => codex_state::SortKey::RecencyAt, }, allowed_sources.as_slice(), model_providers.as_deref(), @@ -405,7 +401,6 @@ pub async fn list_threads_db( sort_key: match sort_key { ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt, - ThreadSortKey::RecencyAt => codex_state::SortKey::RecencyAt, }, sort_direction: match sort_direction { SortDirection::Asc => codex_state::SortDirection::Asc, diff --git a/codex-rs/rollout/src/state_db_tests.rs b/codex-rs/rollout/src/state_db_tests.rs index ba92ca27f1..a84ede199b 100644 --- a/codex-rs/rollout/src/state_db_tests.rs +++ b/codex-rs/rollout/src/state_db_tests.rs @@ -28,22 +28,6 @@ fn cursor_to_anchor_normalizes_timestamp_format() { .expect("nanosecond"); assert_eq!(anchor.ts, expected_ts); - assert_eq!(anchor.id, None); -} - -#[test] -fn cursor_to_anchor_preserves_recency_tie_breaker() { - let id = ThreadId::from_string("00000000-0000-0000-0000-000000000123") - .expect("thread id should parse"); - let token = format!("2026-01-27T12:34:56Z|{id}"); - let cursor = parse_cursor(&token).expect("cursor should parse"); - let anchor = cursor_to_anchor(Some(&cursor)).expect("anchor should parse"); - - assert_eq!(anchor.id, Some(id)); - assert_eq!( - serde_json::to_string(&cursor).expect("cursor should serialize"), - format!("\"{token}\"") - ); } #[tokio::test] diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index 0e3ae912dd..3c1fc8bb57 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -612,7 +612,6 @@ async fn test_list_conversations_latest_first() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-01-03T12-00-00".into()), - recency_at: updated_times.first().cloned().flatten(), updated_at: updated_times.first().cloned().flatten(), }, ThreadItem { @@ -631,7 +630,6 @@ async fn test_list_conversations_latest_first() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-01-02T12-00-00".into()), - recency_at: updated_times.get(1).cloned().flatten(), updated_at: updated_times.get(1).cloned().flatten(), }, ThreadItem { @@ -650,7 +648,6 @@ async fn test_list_conversations_latest_first() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-01-01T12-00-00".into()), - recency_at: updated_times.get(2).cloned().flatten(), updated_at: updated_times.get(2).cloned().flatten(), }, ], @@ -762,7 +759,6 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-05T09-00-00".into()), - recency_at: updated_page1.first().cloned().flatten(), updated_at: updated_page1.first().cloned().flatten(), }, ThreadItem { @@ -781,7 +777,6 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-04T09-00-00".into()), - recency_at: updated_page1.get(1).cloned().flatten(), updated_at: updated_page1.get(1).cloned().flatten(), }, ], @@ -836,7 +831,6 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-03T09-00-00".into()), - recency_at: updated_page2.first().cloned().flatten(), updated_at: updated_page2.first().cloned().flatten(), }, ThreadItem { @@ -855,7 +849,6 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-02T09-00-00".into()), - recency_at: updated_page2.get(1).cloned().flatten(), updated_at: updated_page2.get(1).cloned().flatten(), }, ], @@ -902,7 +895,6 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-01T09-00-00".into()), - recency_at: updated_page3.first().cloned().flatten(), updated_at: updated_page3.first().cloned().flatten(), }], next_cursor: None, @@ -1074,7 +1066,6 @@ async fn test_get_thread_contents() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.into()), - recency_at: page.items[0].updated_at.clone(), updated_at: page.items[0].updated_at.clone(), }], next_cursor: None, @@ -1430,7 +1421,6 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.to_string()), - recency_at: updated_page1.first().cloned().flatten(), updated_at: updated_page1.first().cloned().flatten(), }, ThreadItem { @@ -1449,7 +1439,6 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.to_string()), - recency_at: updated_page1.get(1).cloned().flatten(), updated_at: updated_page1.get(1).cloned().flatten(), }, ], diff --git a/codex-rs/state/migrations/0038_threads_recency_at.sql b/codex-rs/state/migrations/0038_threads_recency_at.sql deleted file mode 100644 index ccbf79f05f..0000000000 --- a/codex-rs/state/migrations/0038_threads_recency_at.sql +++ /dev/null @@ -1,28 +0,0 @@ -ALTER TABLE threads ADD COLUMN recency_at INTEGER NOT NULL DEFAULT 0; -ALTER TABLE threads ADD COLUMN recency_at_ms INTEGER NOT NULL DEFAULT 0; - -UPDATE threads -SET recency_at = updated_at, - recency_at_ms = updated_at_ms; - --- Older binaries can open databases migrated by newer binaries. Seed recency --- when one of those binaries inserts a thread without the new columns. -CREATE TRIGGER threads_recency_at_after_insert -AFTER INSERT ON threads -WHEN NEW.recency_at_ms = 0 -BEGIN - UPDATE threads - SET recency_at = NEW.updated_at, - recency_at_ms = COALESCE(NEW.updated_at_ms, NEW.updated_at * 1000) - WHERE id = NEW.id; -END; - -CREATE INDEX idx_threads_recency_at_ms - ON threads(recency_at_ms DESC, id DESC); - -CREATE INDEX idx_threads_archived_cwd_recency_at_ms - ON threads(archived, cwd, recency_at_ms DESC, id DESC); - -CREATE INDEX idx_threads_visible_recency_at_ms - ON threads(archived, recency_at_ms DESC, id DESC) - WHERE preview <> ''; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 4d25965c44..6f5c87b51f 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -544,7 +544,6 @@ mod tests { rollout_path: PathBuf::from("/tmp/a.jsonl"), created_at, updated_at: created_at, - recency_at: created_at, source: "cli".to_string(), thread_source: None, agent_path: None, diff --git a/codex-rs/state/src/migrations.rs b/codex-rs/state/src/migrations.rs index 8637544fae..0e12f17c45 100644 --- a/codex-rs/state/src/migrations.rs +++ b/codex-rs/state/src/migrations.rs @@ -39,7 +39,3 @@ pub(crate) fn runtime_goals_migrator() -> Migrator { pub(crate) fn runtime_memories_migrator() -> Migrator { runtime_migrator(&MEMORIES_MIGRATOR) } - -#[cfg(test)] -#[path = "migrations_tests.rs"] -mod tests; diff --git a/codex-rs/state/src/migrations_tests.rs b/codex-rs/state/src/migrations_tests.rs deleted file mode 100644 index f7f137a5e2..0000000000 --- a/codex-rs/state/src/migrations_tests.rs +++ /dev/null @@ -1,129 +0,0 @@ -use std::borrow::Cow; - -use sqlx::Row; -use sqlx::migrate::Migrator; -use sqlx::sqlite::SqlitePoolOptions; - -use super::STATE_MIGRATOR; - -fn migrator_through(version: i64) -> Migrator { - Migrator { - migrations: Cow::Owned( - STATE_MIGRATOR - .migrations - .iter() - .filter(|migration| migration.version <= version) - .cloned() - .collect(), - ), - ignore_missing: STATE_MIGRATOR.ignore_missing, - locking: STATE_MIGRATOR.locking, - table_name: STATE_MIGRATOR.table_name.clone(), - create_schemas: STATE_MIGRATOR.create_schemas.clone(), - no_tx: STATE_MIGRATOR.no_tx, - } -} - -#[tokio::test] -async fn recency_migration_backfills_and_seeds_old_binary_inserts() { - let pool = SqlitePoolOptions::new() - .max_connections(1) - .connect("sqlite::memory:") - .await - .expect("in-memory database should open"); - migrator_through(/*version*/ 37) - .run(&pool) - .await - .expect("pre-recency migrations should apply"); - - sqlx::query( - r#" -INSERT INTO threads ( - id, - rollout_path, - created_at, - updated_at, - created_at_ms, - updated_at_ms, - source, - model_provider, - cwd, - title, - sandbox_policy, - approval_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - "#, - ) - .bind("00000000-0000-0000-0000-000000000001") - .bind("/tmp/first.jsonl") - .bind(1_700_000_000_i64) - .bind(1_700_000_100_i64) - .bind(1_700_000_000_123_i64) - .bind(1_700_000_100_456_i64) - .bind("cli") - .bind("openai") - .bind("/tmp") - .bind("") - .bind("read-only") - .bind("on-request") - .execute(&pool) - .await - .expect("legacy row should insert"); - - STATE_MIGRATOR - .run(&pool) - .await - .expect("recency migration should apply"); - - let backfilled = sqlx::query( - "SELECT updated_at, updated_at_ms, recency_at, recency_at_ms FROM threads WHERE id = ?", - ) - .bind("00000000-0000-0000-0000-000000000001") - .fetch_one(&pool) - .await - .expect("backfilled row should load"); - assert_eq!(backfilled.get::("recency_at"), 1_700_000_100); - assert_eq!(backfilled.get::("recency_at_ms"), 1_700_000_100_456); - - sqlx::query( - r#" -INSERT INTO threads ( - id, - rollout_path, - created_at, - updated_at, - created_at_ms, - updated_at_ms, - source, - model_provider, - cwd, - title, - sandbox_policy, - approval_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - "#, - ) - .bind("00000000-0000-0000-0000-000000000002") - .bind("/tmp/second.jsonl") - .bind(1_700_000_200_i64) - .bind(1_700_000_300_i64) - .bind(1_700_000_200_123_i64) - .bind(1_700_000_300_456_i64) - .bind("cli") - .bind("openai") - .bind("/tmp") - .bind("") - .bind("read-only") - .bind("on-request") - .execute(&pool) - .await - .expect("old-binary row should insert"); - - let seeded = sqlx::query("SELECT recency_at, recency_at_ms FROM threads WHERE id = ?") - .bind("00000000-0000-0000-0000-000000000002") - .fetch_one(&pool) - .await - .expect("old-binary row should load"); - assert_eq!(seeded.get::("recency_at"), 1_700_000_300); - assert_eq!(seeded.get::("recency_at_ms"), 1_700_000_300_456); -} diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index c25c458986..a30f7e479b 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -18,8 +18,6 @@ pub enum SortKey { CreatedAt, /// Sort by the thread's last update timestamp. UpdatedAt, - /// Sort by the thread's product recency timestamp. - RecencyAt, } /// Sort direction to use when listing threads. @@ -34,8 +32,6 @@ pub enum SortDirection { pub struct Anchor { /// The timestamp component of the anchor. pub ts: DateTime, - /// The thread ID component used to disambiguate equal recency timestamps. - pub id: Option, } /// A single page of thread metadata results. @@ -71,8 +67,6 @@ pub struct ThreadMetadata { pub created_at: DateTime, /// The last update timestamp. pub updated_at: DateTime, - /// The product recency timestamp. - pub recency_at: DateTime, /// The session source (stringified enum). pub source: String, /// Optional analytics source classification for this thread. @@ -126,8 +120,6 @@ pub struct ThreadMetadataBuilder { pub created_at: DateTime, /// The last update timestamp, if known. pub updated_at: Option>, - /// The product recency timestamp, if known. - pub recency_at: Option>, /// The session source. pub source: SessionSource, /// Optional analytics source classification for this thread. @@ -171,7 +163,6 @@ impl ThreadMetadataBuilder { rollout_path, created_at, updated_at: None, - recency_at: None, source, thread_source: None, agent_nickname: None, @@ -199,16 +190,11 @@ impl ThreadMetadataBuilder { .updated_at .map(canonicalize_datetime) .unwrap_or(created_at); - let recency_at = self - .recency_at - .map(canonicalize_datetime) - .unwrap_or(updated_at); ThreadMetadata { id: self.id, rollout_path: self.rollout_path.clone(), created_at, updated_at, - recency_at, source, thread_source: self.thread_source.clone(), agent_nickname: self.agent_nickname.clone(), @@ -354,7 +340,6 @@ pub(crate) struct ThreadRow { rollout_path: String, created_at: i64, updated_at: i64, - recency_at: i64, source: String, thread_source: Option, agent_nickname: Option, @@ -384,7 +369,6 @@ impl ThreadRow { rollout_path: row.try_get("rollout_path")?, created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, - recency_at: row.try_get("recency_at")?, source: row.try_get("source")?, thread_source: row.try_get("thread_source")?, agent_nickname: row.try_get("agent_nickname")?, @@ -418,7 +402,6 @@ impl TryFrom for ThreadMetadata { rollout_path, created_at, updated_at, - recency_at, source, thread_source, agent_nickname, @@ -449,7 +432,6 @@ impl TryFrom for ThreadMetadata { rollout_path: PathBuf::from(rollout_path), created_at: epoch_millis_to_datetime(created_at)?, updated_at: epoch_millis_to_datetime(updated_at)?, - recency_at: epoch_millis_to_datetime(recency_at)?, source, thread_source, agent_nickname, @@ -479,12 +461,8 @@ pub(crate) fn anchor_from_item(item: &ThreadMetadata, sort_key: SortKey) -> Opti let ts = match sort_key { SortKey::CreatedAt => item.created_at, SortKey::UpdatedAt => item.updated_at, - SortKey::RecencyAt => item.recency_at, }; - Some(Anchor { - ts, - id: (sort_key == SortKey::RecencyAt).then_some(item.id), - }) + Some(Anchor { ts }) } pub(crate) fn datetime_to_epoch_millis(dt: DateTime) -> i64 { @@ -541,7 +519,6 @@ mod tests { rollout_path: "/tmp/rollout-123.jsonl".to_string(), created_at: 1_700_000_000, updated_at: 1_700_000_100, - recency_at: 1_700_000_100, source: "cli".to_string(), thread_source: None, agent_nickname: None, @@ -572,7 +549,6 @@ mod tests { rollout_path: PathBuf::from("/tmp/rollout-123.jsonl"), created_at: DateTime::::from_timestamp(1_700_000_000, 0).expect("timestamp"), updated_at: DateTime::::from_timestamp(1_700_000_100, 0).expect("timestamp"), - recency_at: DateTime::::from_timestamp(1_700_000_100, 0).expect("timestamp"), source: "cli".to_string(), thread_source: None, agent_nickname: None, diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 2ef5248896..0e5dc85586 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -160,7 +160,6 @@ pub struct StateRuntime { thread_goals: GoalStore, memories: MemoryStore, thread_updated_at_millis: Arc, - thread_recency_at_millis: Arc, } impl StateRuntime { @@ -263,36 +262,32 @@ impl StateRuntime { return Err(err); } let started = Instant::now(); - let thread_timestamp_millis_result: anyhow::Result<(Option, Option)> = - sqlx::query_as( - "SELECT MAX(threads.updated_at_ms), MAX(threads.recency_at_ms) FROM threads", - ) - .fetch_one(pool.as_ref()) - .await - .map_err(anyhow::Error::from); + let thread_updated_at_millis_result: anyhow::Result> = + sqlx::query_scalar("SELECT MAX(threads.updated_at_ms) FROM threads") + .fetch_one(pool.as_ref()) + .await + .map_err(anyhow::Error::from); crate::telemetry::record_init_result( telemetry_override, DbKind::State, "post_init_query", started.elapsed(), - &thread_timestamp_millis_result, + &thread_updated_at_millis_result, ); - let (thread_updated_at_millis, thread_recency_at_millis) = - match thread_timestamp_millis_result { - Ok(value) => value, - Err(err) => { - close_sqlite_pools(&[ - pool.as_ref(), - logs_pool.as_ref(), - goals_pool.as_ref(), - memories_pool.as_ref(), - ]) - .await; - return Err(err); - } - }; + let thread_updated_at_millis = match thread_updated_at_millis_result { + Ok(value) => value, + Err(err) => { + close_sqlite_pools(&[ + pool.as_ref(), + logs_pool.as_ref(), + goals_pool.as_ref(), + memories_pool.as_ref(), + ]) + .await; + return Err(err); + } + }; let thread_updated_at_millis = thread_updated_at_millis.unwrap_or(0); - let thread_recency_at_millis = thread_recency_at_millis.unwrap_or(0); let runtime = Arc::new(Self { thread_goals: GoalStore::new(Arc::clone(&goals_pool)), memories: MemoryStore::new(Arc::clone(&memories_pool), Arc::clone(&pool)), @@ -301,7 +296,6 @@ impl StateRuntime { codex_home, default_provider, thread_updated_at_millis: Arc::new(AtomicI64::new(thread_updated_at_millis)), - thread_recency_at_millis: Arc::new(AtomicI64::new(thread_recency_at_millis)), }); if let Err(err) = runtime.run_logs_startup_maintenance().await { warn!( diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index 9b2c337f5f..895c254350 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -175,7 +175,6 @@ SELECT threads.rollout_path, threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, - threads.recency_at_ms AS recency_at, threads.source, threads.thread_source, threads.agent_path, @@ -546,7 +545,6 @@ SELECT threads.rollout_path, threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, - threads.recency_at_ms AS recency_at, threads.source, threads.thread_source, threads.agent_nickname, diff --git a/codex-rs/state/src/runtime/test_support.rs b/codex-rs/state/src/runtime/test_support.rs index c029a0ee2d..848a193335 100644 --- a/codex-rs/state/src/runtime/test_support.rs +++ b/codex-rs/state/src/runtime/test_support.rs @@ -47,7 +47,6 @@ pub(super) fn test_thread_metadata( rollout_path: codex_home.join(format!("rollout-{thread_id}.jsonl")), created_at: now, updated_at: now, - recency_at: now, source: "cli".to_string(), thread_source: None, agent_nickname: None, diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 576f760264..6cebf27e87 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -1,7 +1,6 @@ use super::*; use crate::SortDirection; use codex_protocol::protocol::SessionSource; -use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; impl StateRuntime { @@ -13,7 +12,6 @@ SELECT threads.rollout_path, threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, - threads.recency_at_ms AS recency_at, threads.source, threads.thread_source, threads.agent_nickname, @@ -500,7 +498,6 @@ ON CONFLICT(child_thread_id) DO NOTHING metadata: &crate::ThreadMetadata, ) -> anyhow::Result { let updated_at = self.allocate_thread_updated_at(metadata.updated_at)?; - let recency_at = self.allocate_thread_recency_at(metadata.recency_at)?; let preview = metadata_preview(metadata); let result = sqlx::query( r#" @@ -509,10 +506,8 @@ INSERT INTO threads ( rollout_path, created_at, updated_at, - recency_at, created_at_ms, updated_at_ms, - recency_at_ms, source, thread_source, agent_nickname, @@ -535,7 +530,7 @@ INSERT INTO threads ( git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING "#, ) @@ -543,10 +538,8 @@ ON CONFLICT(id) DO NOTHING .bind(metadata.rollout_path.display().to_string()) .bind(datetime_to_epoch_seconds(metadata.created_at)) .bind(datetime_to_epoch_seconds(updated_at)) - .bind(datetime_to_epoch_seconds(recency_at)) .bind(datetime_to_epoch_millis(metadata.created_at)) .bind(datetime_to_epoch_millis(updated_at)) - .bind(datetime_to_epoch_millis(recency_at)) .bind(metadata.source.as_str()) .bind( metadata @@ -628,32 +621,6 @@ ON CONFLICT(id) DO NOTHING Ok(result.rows_affected() > 0) } - pub async fn touch_thread_recency_at( - &self, - thread_id: ThreadId, - recency_at: DateTime, - ) -> anyhow::Result { - let recency_at = self.allocate_thread_recency_at(recency_at)?; - let recency_at_seconds = datetime_to_epoch_seconds(recency_at); - let recency_at_millis = datetime_to_epoch_millis(recency_at); - let result = sqlx::query( - r#" -UPDATE threads -SET - recency_at = MAX(?, MAX(?, recency_at_ms + 1) / 1000), - recency_at_ms = MAX(?, recency_at_ms + 1) -WHERE id = ? - "#, - ) - .bind(recency_at_seconds) - .bind(recency_at_millis) - .bind(recency_at_millis) - .bind(thread_id.to_string()) - .execute(self.pool.as_ref()) - .await?; - Ok(result.rows_affected() > 0) - } - /// Allocate a persisted `updated_at` value for thread-list cursor ordering. /// /// We keep a process-local high-water mark so hot rollout writes can get unique, @@ -664,56 +631,42 @@ WHERE id = ? &self, updated_at: DateTime, ) -> anyhow::Result> { - allocate_thread_timestamp(self.thread_updated_at_millis.as_ref(), updated_at) - } + let candidate = datetime_to_epoch_millis(updated_at); + let allocated = loop { + let current = self.thread_updated_at_millis.load(Ordering::Relaxed); - fn allocate_thread_recency_at( - &self, - recency_at: DateTime, - ) -> anyhow::Result> { - allocate_thread_timestamp(self.thread_recency_at_millis.as_ref(), recency_at) - } -} + // New wall-clock time: advance the process-local high-water mark and use it as-is. + if candidate > current { + if self + .thread_updated_at_millis + .compare_exchange(current, candidate, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + break candidate; + } + continue; + } -fn allocate_thread_timestamp( - high_water_mark: &AtomicI64, - timestamp: DateTime, -) -> anyhow::Result> { - let candidate = datetime_to_epoch_millis(timestamp); - let allocated = loop { - let current = high_water_mark.load(Ordering::Relaxed); - - // New wall-clock time: advance the process-local high-water mark and use it as-is. - if candidate > current { - if high_water_mark - .compare_exchange(current, candidate, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { + // Older timestamps come from backfill/repair paths that preserve rollout mtimes. + // Do not drag historical rows forward just because this process has seen newer writes. + if candidate.saturating_add(1000) <= current { break candidate; } - continue; - } - // Older timestamps come from backfill/repair paths that preserve rollout mtimes. - // Do not drag historical rows forward just because this process has seen newer writes. - if candidate.saturating_add(1000) <= current { - break candidate; - } + // Same hot one-second bucket as the current high-water mark. Allocate the next + // millisecond so updated_at remains unique and cursor-orderable inside the process. + let bumped = current.saturating_add(1); + if self + .thread_updated_at_millis + .compare_exchange(current, bumped, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + break bumped; + } + }; + epoch_millis_to_datetime(allocated) + } - // Same hot one-second bucket as the current high-water mark. Allocate the next - // millisecond so the timestamp remains unique and cursor-orderable inside the process. - let bumped = current.saturating_add(1); - if high_water_mark - .compare_exchange(current, bumped, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - break bumped; - } - }; - epoch_millis_to_datetime(allocated) -} - -impl StateRuntime { pub async fn update_thread_git_info( &self, thread_id: ThreadId, @@ -749,7 +702,6 @@ WHERE id = ? creation_memory_mode: Option<&str>, ) -> anyhow::Result<()> { let updated_at = self.allocate_thread_updated_at(metadata.updated_at)?; - let insert_recency_at = self.allocate_thread_recency_at(metadata.recency_at)?; let preview = metadata_preview(metadata); // Backfill/reconcile callers merge existing git info before upserting, but that // read/modify/write is not atomic. Preserve non-null SQLite git fields here so @@ -761,10 +713,8 @@ INSERT INTO threads ( rollout_path, created_at, updated_at, - recency_at, created_at_ms, updated_at_ms, - recency_at_ms, source, thread_source, agent_nickname, @@ -787,15 +737,13 @@ INSERT INTO threads ( git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET rollout_path = excluded.rollout_path, created_at = excluded.created_at, updated_at = excluded.updated_at, - recency_at = threads.recency_at, created_at_ms = excluded.created_at_ms, updated_at_ms = excluded.updated_at_ms, - recency_at_ms = threads.recency_at_ms, source = excluded.source, thread_source = excluded.thread_source, agent_nickname = excluded.agent_nickname, @@ -823,10 +771,8 @@ ON CONFLICT(id) DO UPDATE SET .bind(metadata.rollout_path.display().to_string()) .bind(datetime_to_epoch_seconds(metadata.created_at)) .bind(datetime_to_epoch_seconds(updated_at)) - .bind(datetime_to_epoch_seconds(insert_recency_at)) .bind(datetime_to_epoch_millis(metadata.created_at)) .bind(datetime_to_epoch_millis(updated_at)) - .bind(datetime_to_epoch_millis(insert_recency_at)) .bind(metadata.source.as_str()) .bind( metadata @@ -1133,7 +1079,6 @@ SELECT threads.rollout_path, threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, - threads.recency_at_ms AS recency_at, threads.source, threads.thread_source, threads.agent_nickname, @@ -1252,7 +1197,6 @@ pub(super) fn push_thread_filters<'a>( let column = match sort_key { SortKey::CreatedAt => "threads.created_at_ms", SortKey::UpdatedAt => "threads.updated_at_ms", - SortKey::RecencyAt => "threads.recency_at_ms", }; let operator = match sort_direction { SortDirection::Asc => ">", @@ -1264,19 +1208,6 @@ pub(super) fn push_thread_filters<'a>( builder.push(operator); builder.push(" "); builder.push_bind(anchor_ts); - if sort_key == SortKey::RecencyAt - && let Some(anchor_id) = anchor.id - { - builder.push(" OR ("); - builder.push(column); - builder.push(" = "); - builder.push_bind(anchor_ts); - builder.push(" AND threads.id "); - builder.push(operator); - builder.push(" "); - builder.push_bind(anchor_id.to_string()); - builder.push(")"); - } builder.push(")"); } } @@ -1301,7 +1232,6 @@ pub(super) fn push_thread_order_and_limit( let order_column = match sort_key { SortKey::CreatedAt => "threads.created_at_ms", SortKey::UpdatedAt => "threads.updated_at_ms", - SortKey::RecencyAt => "threads.recency_at_ms", }; let order_direction = match sort_direction { SortDirection::Asc => "ASC", @@ -1317,10 +1247,6 @@ pub(super) fn push_thread_order_and_limit( builder.push(order_column); builder.push(" "); builder.push(order_direction); - if sort_key == SortKey::RecencyAt { - builder.push(", threads.id "); - builder.push(order_direction); - } builder.push(" LIMIT "); builder.push_bind(limit as i64); } @@ -1595,7 +1521,6 @@ mod tests { let anchor = Anchor { ts: older_updated_at, - id: None, }; let model_providers = ["test-provider".to_string()]; let page = runtime @@ -1622,7 +1547,6 @@ mod tests { Some(Anchor { ts: DateTime::::from_timestamp_millis(1_700_000_200_000) .expect("valid timestamp"), - id: None, }) ); @@ -1707,7 +1631,6 @@ mod tests { Some(Anchor { ts: DateTime::::from_timestamp_millis(1_700_000_300_000) .expect("valid timestamp"), - id: None, }) ); @@ -1770,7 +1693,6 @@ mod tests { ]; let anchor = Anchor { ts: DateTime::::from_timestamp(1_700_000_000, 0).expect("valid timestamp"), - id: None, }; for (sort_key, visible_index, cwd_index) in [ ( @@ -1783,11 +1705,6 @@ mod tests { "idx_threads_visible_updated_at_ms", "idx_threads_archived_cwd_updated_at_ms", ), - ( - SortKey::RecencyAt, - "idx_threads_visible_recency_at_ms", - "idx_threads_archived_cwd_recency_at_ms", - ), ] { for (cwd_filters, anchor, expected_index, expect_temp_sort) in [ (None, None, visible_index, false), @@ -2362,186 +2279,6 @@ mod tests { assert_eq!(persisted.preview.as_deref(), Some("first-user-message")); } - #[tokio::test] - async fn touch_thread_recency_at_is_monotonic_and_survives_stale_upsert() { - let codex_home = unique_temp_dir(); - let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) - .await - .expect("state db should initialize"); - let thread_id = - ThreadId::from_string("00000000-0000-0000-0000-000000000792").expect("valid thread id"); - let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); - let original_recency_at = metadata.recency_at; - runtime - .upsert_thread(&metadata) - .await - .expect("initial upsert should succeed"); - - let touched_at = - DateTime::::from_timestamp_millis(1_700_001_111_123).expect("timestamp"); - assert!( - runtime - .touch_thread_recency_at(thread_id, touched_at) - .await - .expect("touch should succeed") - ); - - metadata.updated_at = - DateTime::::from_timestamp_millis(1_700_001_222_456).expect("timestamp"); - metadata.title = "updated metadata".to_string(); - assert_eq!(metadata.recency_at, original_recency_at); - runtime - .upsert_thread(&metadata) - .await - .expect("stale metadata upsert should succeed"); - - let persisted = runtime - .get_thread(thread_id) - .await - .expect("thread should load") - .expect("thread should exist"); - assert_eq!(persisted.recency_at, touched_at); - assert_eq!(persisted.updated_at, metadata.updated_at); - assert_eq!(persisted.title, "updated metadata"); - - assert!( - runtime - .touch_thread_recency_at(thread_id, original_recency_at) - .await - .expect("older touch should succeed") - ); - let persisted = runtime - .get_thread(thread_id) - .await - .expect("thread should load") - .expect("thread should exist"); - assert_eq!( - datetime_to_epoch_millis(persisted.recency_at), - datetime_to_epoch_millis(touched_at) + 1 - ); - } - - #[tokio::test] - async fn list_threads_orders_and_pages_by_recency_at() { - let codex_home = unique_temp_dir(); - let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) - .await - .expect("state db should initialize"); - let first_id = - ThreadId::from_string("00000000-0000-0000-0000-000000000793").expect("valid thread id"); - let second_id = - ThreadId::from_string("00000000-0000-0000-0000-000000000794").expect("valid thread id"); - let third_id = - ThreadId::from_string("00000000-0000-0000-0000-000000000795").expect("valid thread id"); - let recency_at = - DateTime::::from_timestamp_millis(1_700_002_000_456).expect("timestamp"); - - for thread_id in [first_id, second_id, third_id] { - let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); - metadata.recency_at = recency_at; - runtime - .upsert_thread(&metadata) - .await - .expect("thread insert should succeed"); - } - sqlx::query("UPDATE threads SET recency_at = ?, recency_at_ms = ?") - .bind(datetime_to_epoch_seconds(recency_at)) - .bind(datetime_to_epoch_millis(recency_at)) - .execute(runtime.pool.as_ref()) - .await - .expect("recency timestamps should match"); - - let first_page = runtime - .list_threads( - /*page_size*/ 1, - ThreadFilterOptions { - archived_only: false, - allowed_sources: &[], - model_providers: None, - cwd_filters: None, - anchor: None, - sort_key: SortKey::RecencyAt, - sort_direction: SortDirection::Desc, - search_term: None, - }, - ) - .await - .expect("list should succeed"); - assert_eq!( - first_page - .items - .iter() - .map(|item| item.id) - .collect::>(), - vec![third_id] - ); - assert_eq!( - first_page.next_anchor, - Some(Anchor { - ts: recency_at, - id: Some(third_id), - }) - ); - - let second_page = runtime - .list_threads( - /*page_size*/ 1, - ThreadFilterOptions { - archived_only: false, - allowed_sources: &[], - model_providers: None, - cwd_filters: None, - anchor: first_page.next_anchor.as_ref(), - sort_key: SortKey::RecencyAt, - sort_direction: SortDirection::Desc, - search_term: None, - }, - ) - .await - .expect("second list should succeed"); - assert_eq!( - second_page - .items - .iter() - .map(|item| item.id) - .collect::>(), - vec![second_id] - ); - assert_eq!( - second_page.next_anchor, - Some(Anchor { - ts: recency_at, - id: Some(second_id), - }) - ); - - let third_page = runtime - .list_threads( - /*page_size*/ 1, - ThreadFilterOptions { - archived_only: false, - allowed_sources: &[], - model_providers: None, - cwd_filters: None, - anchor: second_page.next_anchor.as_ref(), - sort_key: SortKey::RecencyAt, - sort_direction: SortDirection::Desc, - search_term: None, - }, - ) - .await - .expect("third list should succeed"); - assert_eq!( - third_page - .items - .iter() - .map(|item| item.id) - .collect::>(), - vec![first_id] - ); - assert_eq!(third_page.next_anchor, None); - } - #[tokio::test] async fn thread_updated_at_uses_unique_epoch_millis_and_reads_legacy_seconds() { let codex_home = unique_temp_dir(); @@ -2558,10 +2295,8 @@ mod tests { DateTime::::from_timestamp_millis(1_700_001_111_123).expect("timestamp millis"); let mut first = test_thread_metadata(&codex_home, first_id, codex_home.clone()); first.updated_at = updated_at; - first.recency_at = updated_at; let mut second = test_thread_metadata(&codex_home, second_id, codex_home.clone()); second.updated_at = updated_at; - second.recency_at = updated_at; runtime .upsert_thread(&first) @@ -2590,14 +2325,6 @@ mod tests { datetime_to_epoch_millis(second.updated_at), 1_700_001_111_124 ); - assert_eq!( - datetime_to_epoch_millis(first.recency_at), - 1_700_001_111_123 - ); - assert_eq!( - datetime_to_epoch_millis(second.recency_at), - 1_700_001_111_124 - ); let second_row: (i64, i64, Option, Option) = sqlx::query_as( "SELECT created_at, updated_at, created_at_ms, updated_at_ms FROM threads WHERE id = ?", ) diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index 7eda54a9c0..d02b78d237 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -535,9 +535,6 @@ fn stored_thread_from_state( updated_at: metadata .and_then(|metadata| metadata.updated_at) .unwrap_or_else(Utc::now), - recency_at: metadata - .and_then(|metadata| metadata.advance_recency_at.or(metadata.updated_at)) - .unwrap_or_else(Utc::now), archived_at: None, cwd: metadata .and_then(|metadata| metadata.cwd.clone()) diff --git a/codex-rs/thread-store/src/local/archive_thread.rs b/codex-rs/thread-store/src/local/archive_thread.rs index eb4a48b680..bbe5c3de51 100644 --- a/codex-rs/thread-store/src/local/archive_thread.rs +++ b/codex-rs/thread-store/src/local/archive_thread.rs @@ -174,6 +174,5 @@ mod tests { .expect("thread metadata should exist"); assert_eq!(updated.rollout_path, archived_path); assert!(updated.archived_at.is_some()); - assert_eq!(updated.recency_at, metadata.recency_at); } } diff --git a/codex-rs/thread-store/src/local/helpers.rs b/codex-rs/thread-store/src/local/helpers.rs index f48a0136c0..5acdcf09b6 100644 --- a/codex-rs/thread-store/src/local/helpers.rs +++ b/codex-rs/thread-store/src/local/helpers.rs @@ -106,7 +106,6 @@ pub(super) fn stored_thread_from_rollout_item( .or_else(|| thread_id_from_rollout_path(item.path.as_path()))?; let created_at = parse_rfc3339(item.created_at.as_deref()).unwrap_or_else(Utc::now); let updated_at = parse_rfc3339(item.updated_at.as_deref()).unwrap_or(created_at); - let recency_at = parse_rfc3339(item.recency_at.as_deref()).unwrap_or(updated_at); let archived_at = archived.then_some(updated_at); let git_info = git_info_from_parts( item.git_sha.clone(), @@ -137,7 +136,6 @@ pub(super) fn stored_thread_from_rollout_item( reasoning_effort: None, created_at, updated_at, - recency_at, archived_at, cwd: item.cwd.unwrap_or_default(), cli_version: item.cli_version.unwrap_or_default(), diff --git a/codex-rs/thread-store/src/local/list_threads.rs b/codex-rs/thread-store/src/local/list_threads.rs index 03ae163afe..fc3b5034ad 100644 --- a/codex-rs/thread-store/src/local/list_threads.rs +++ b/codex-rs/thread-store/src/local/list_threads.rs @@ -34,7 +34,6 @@ pub(super) async fn list_threads( let sort_key = match params.sort_key { ThreadSortKey::CreatedAt => codex_rollout::ThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_rollout::ThreadSortKey::UpdatedAt, - ThreadSortKey::RecencyAt => codex_rollout::ThreadSortKey::RecencyAt, }; let sort_direction = match params.sort_direction { SortDirection::Asc => codex_rollout::SortDirection::Asc, diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 550e7d56bb..dd49fd2a59 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -314,16 +314,10 @@ mod tests { use codex_protocol::ThreadId; use codex_protocol::models::BaseInstructions; - use codex_protocol::models::FunctionCallOutputPayload; - use codex_protocol::models::MessagePhase; - use codex_protocol::models::ResponseItem; - use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadMemoryMode; - use codex_protocol::protocol::TurnCompleteEvent; - use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use tempfile::TempDir; @@ -455,92 +449,6 @@ mod tests { assert_eq!(metadata.title, "observed append"); } - #[tokio::test] - async fn live_thread_output_advances_updated_at_but_not_recency_at() { - let home = TempDir::new().expect("temp dir"); - let config = test_config(home.path()); - let runtime = codex_state::StateRuntime::init( - config.sqlite_home.clone(), - config.default_model_provider_id.clone(), - ) - .await - .expect("state db should initialize"); - let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone()))); - let thread_id = ThreadId::default(); - let live_thread = LiveThread::create(store, create_thread_params(thread_id)) - .await - .expect("create live thread"); - - live_thread - .append_items(&[user_message_item("start thread")]) - .await - .expect("append initial user message"); - live_thread.flush().await.expect("flush thread"); - let before_turn_start = runtime - .get_thread(thread_id) - .await - .expect("sqlite metadata read") - .expect("sqlite metadata"); - - live_thread - .append_items(&[RolloutItem::EventMsg(EventMsg::TurnStarted( - TurnStartedEvent { - turn_id: "turn-1".to_string(), - trace_id: None, - started_at: None, - model_context_window: None, - collaboration_mode_kind: Default::default(), - }, - ))]) - .await - .expect("append turn start"); - live_thread.flush().await.expect("flush thread"); - let after_turn_start = runtime - .get_thread(thread_id) - .await - .expect("sqlite metadata read") - .expect("sqlite metadata"); - assert!(after_turn_start.recency_at > before_turn_start.recency_at); - - live_thread - .append_items(&[ - RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { - message: "commentary".to_string(), - phase: Some(MessagePhase::Commentary), - memory_citation: None, - })), - RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput { - call_id: "call-1".to_string(), - output: FunctionCallOutputPayload::from_text("tool output".to_string()), - metadata: None, - }), - RolloutItem::EventMsg(EventMsg::TokenCount( - codex_protocol::protocol::TokenCountEvent { - info: None, - rate_limits: None, - }, - )), - RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - completed_at: None, - duration_ms: None, - time_to_first_token_ms: None, - })), - ]) - .await - .expect("append post-start items"); - live_thread.flush().await.expect("flush thread"); - let completed = runtime - .get_thread(thread_id) - .await - .expect("sqlite metadata read") - .expect("sqlite metadata"); - - assert!(completed.updated_at > after_turn_start.updated_at); - assert_eq!(completed.recency_at, after_turn_start.recency_at); - } - #[tokio::test] async fn live_thread_shutdown_does_not_materialize_empty_thread_metadata() { let home = TempDir::new().expect("temp dir"); diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index a1e1c59190..4778dd69d9 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -55,7 +55,6 @@ pub(super) async fn read_thread( && (params.include_archived || rollout_thread.archived_at.is_none()) && !rollout_thread.preview.is_empty() { - rollout_thread.recency_at = thread.recency_at; if thread.name.is_some() { rollout_thread.name = thread.name; } @@ -116,7 +115,6 @@ pub(super) async fn read_thread_by_rollout_path( }); } if let Some(metadata) = read_sqlite_metadata(store, thread.thread_id).await { - thread.recency_at = metadata.recency_at; let existing_git_info = thread.git_info.take(); let (fallback_sha, fallback_branch, fallback_origin_url) = match existing_git_info { Some(info) => ( @@ -342,7 +340,6 @@ async fn stored_thread_from_sqlite_metadata( reasoning_effort: metadata.reasoning_effort, created_at: metadata.created_at, updated_at: metadata.updated_at, - recency_at: metadata.recency_at, archived_at: metadata.archived_at, cwd: metadata.cwd, cli_version: metadata.cli_version, @@ -409,7 +406,6 @@ fn stored_thread_from_meta_line( reasoning_effort: None, created_at, updated_at, - recency_at: updated_at, archived_at: archived.then_some(updated_at), cwd: meta_line.meta.cwd, cli_version: meta_line.meta.cli_version, @@ -551,10 +547,6 @@ mod tests { ); builder.model_provider = Some(config.default_model_provider_id.clone()); builder.git_branch = Some("sqlite-branch".to_string()); - let recency_at = chrono::DateTime::parse_from_rfc3339("2026-01-03T12:00:00Z") - .expect("timestamp should parse") - .with_timezone(&Utc); - builder.recency_at = Some(recency_at); runtime .upsert_thread(&builder.build(config.default_model_provider_id.as_str())) .await @@ -570,7 +562,6 @@ mod tests { .expect("read thread by rollout path"); let git_info = thread.git_info.expect("git info should be present"); - assert_eq!(thread.recency_at, recency_at); assert_eq!(git_info.branch.as_deref(), Some("sqlite-branch")); assert_eq!( git_info.commit_hash.as_ref().map(|sha| sha.0.as_str()), diff --git a/codex-rs/thread-store/src/local/search_threads.rs b/codex-rs/thread-store/src/local/search_threads.rs index fc2ddd6e71..1b7f18a32f 100644 --- a/codex-rs/thread-store/src/local/search_threads.rs +++ b/codex-rs/thread-store/src/local/search_threads.rs @@ -23,10 +23,6 @@ use crate::ThreadSortKey; use crate::ThreadStoreError; use crate::ThreadStoreResult; -#[cfg(test)] -#[path = "search_threads_tests.rs"] -mod tests; - struct ThreadSearchItem { item: codex_rollout::ThreadItem, snippet: String, @@ -54,7 +50,6 @@ pub(super) async fn search_threads( let sort_key = match params.sort_key { ThreadSortKey::CreatedAt => codex_rollout::ThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_rollout::ThreadSortKey::UpdatedAt, - ThreadSortKey::RecencyAt => codex_rollout::ThreadSortKey::RecencyAt, }; let sort_direction = match params.sort_direction { SortDirection::Asc => codex_rollout::SortDirection::Asc, @@ -184,17 +179,8 @@ fn cursor_from_thread_search_item( .updated_at .as_deref() .or(item.item.created_at.as_deref())?, - ThreadSortKey::RecencyAt => item - .item - .recency_at - .as_deref() - .or(item.item.updated_at.as_deref()) - .or(item.item.created_at.as_deref())?, }; - match sort_key { - ThreadSortKey::RecencyAt => parse_cursor(&format!("{timestamp}|{}", item.item.thread_id?)), - ThreadSortKey::CreatedAt | ThreadSortKey::UpdatedAt => parse_cursor(timestamp), - } + parse_cursor(timestamp) } async fn set_thread_search_result_names( diff --git a/codex-rs/thread-store/src/local/search_threads_tests.rs b/codex-rs/thread-store/src/local/search_threads_tests.rs deleted file mode 100644 index bfcf768276..0000000000 --- a/codex-rs/thread-store/src/local/search_threads_tests.rs +++ /dev/null @@ -1,29 +0,0 @@ -use codex_protocol::ThreadId; -use codex_rollout::ThreadItem; -use pretty_assertions::assert_eq; - -use super::ThreadSearchItem; -use super::cursor_from_thread_search_item; -use crate::ThreadSortKey; - -#[test] -fn recency_cursor_includes_thread_id_tie_breaker() { - let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000123") - .expect("thread ID should parse"); - let item = ThreadSearchItem { - item: ThreadItem { - thread_id: Some(thread_id), - recency_at: Some("2026-01-27T12:34:56Z".to_string()), - ..Default::default() - }, - snippet: String::new(), - }; - - let cursor = cursor_from_thread_search_item(&item, ThreadSortKey::RecencyAt) - .expect("cursor should build"); - - assert_eq!( - serde_json::to_string(&cursor).expect("cursor should serialize"), - format!("\"2026-01-27T12:34:56Z|{thread_id}\"") - ); -} diff --git a/codex-rs/thread-store/src/local/unarchive_thread.rs b/codex-rs/thread-store/src/local/unarchive_thread.rs index ee25b43424..ad41db69ac 100644 --- a/codex-rs/thread-store/src/local/unarchive_thread.rs +++ b/codex-rs/thread-store/src/local/unarchive_thread.rs @@ -196,6 +196,5 @@ mod tests { .expect("thread metadata should exist"); assert_eq!(updated.rollout_path, restored_path); assert_eq!(updated.archived_at, None); - assert_eq!(updated.recency_at, metadata.recency_at); } } diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index d1c76ad327..820cb68918 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -204,7 +204,6 @@ async fn apply_metadata_update( .map_err(|err| ThreadStoreError::Internal { message: format!("failed to read thread metadata for {thread_id}: {err}"), })?; - let advance_recency_at = patch.advance_recency_at; if existing.is_none() && rollout_path.is_none() { let resolved = resolve_rollout_path(store, thread_id, include_archived).await?; rollout_path_archived = resolved.archived; @@ -261,11 +260,6 @@ async fn apply_metadata_update( if let Some(updated_at) = patch.updated_at { metadata.updated_at = updated_at; } - if existing.is_none() - && let Some(recency_at) = advance_recency_at - { - metadata.recency_at = recency_at; - } if let Some(source) = patch.source { metadata.source = enum_to_string(&source); } @@ -316,18 +310,6 @@ async fn apply_metadata_update( .map_err(|err| ThreadStoreError::Internal { message: format!("failed to update thread metadata for {thread_id}: {err}"), })?; - if existing.is_some() - && let Some(recency_at) = advance_recency_at - { - state_db - .touch_thread_recency_at(thread_id, recency_at) - .await - .map_err(|err| ThreadStoreError::Internal { - message: format!( - "failed to advance thread recency_at for {thread_id}: {err}" - ), - })?; - } if let Some(memory_mode) = patch.memory_mode { state_db .set_thread_memory_mode(thread_id, memory_mode_as_str(memory_mode)) diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index b2953d7ef9..a595ba0919 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -154,17 +154,11 @@ impl ThreadMetadataSync { let affects_metadata = items .iter() .any(codex_state::rollout_item_affects_thread_metadata); - let advances_recency = items - .iter() - .any(|item| matches!(item, RolloutItem::EventMsg(EventMsg::TurnStarted(_)))); - let mut update = if affects_metadata { + let update = if affects_metadata { self.observe_items(items)? } else { thread_updated_at_touch() }; - if advances_recency { - update.advance_recency_at = Some(Utc::now()); - } self.merge_pending_update(Some(update)); if !affects_metadata && !self @@ -353,7 +347,6 @@ fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool { || update.model.is_some() || update.reasoning_effort.is_some() || update.created_at.is_some() - || update.advance_recency_at.is_some() || update.source.is_some() || update.thread_source.is_some() || update.agent_nickname.is_some() @@ -386,7 +379,6 @@ mod tests { use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadGoalUpdatedEvent; - use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use pretty_assertions::assert_eq; @@ -503,27 +495,6 @@ mod tests { ); } - #[test] - fn turn_start_advances_recency_at_without_changing_updated_at_behavior() { - let thread_id = ThreadId::new(); - let mut sync = ThreadMetadataSync::for_resume(&resume_params(thread_id, Vec::new())); - - let update = sync - .observe_appended_items(&[RolloutItem::EventMsg(EventMsg::TurnStarted( - TurnStartedEvent { - turn_id: "turn-1".to_string(), - trace_id: None, - started_at: None, - model_context_window: None, - collaboration_mode_kind: Default::default(), - }, - ))]) - .expect("turn start metadata update"); - - assert!(update.patch.updated_at.is_some()); - assert!(update.patch.advance_recency_at.is_some()); - } - #[test] fn resume_history_waits_for_append_before_flushing_metadata() { let thread_id = ThreadId::new(); diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index b969c6b957..bb8bf2f6dc 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -160,8 +160,6 @@ pub enum ThreadSortKey { CreatedAt, /// Sort by the thread last-update timestamp. UpdatedAt, - /// Sort by the thread's product recency timestamp. - RecencyAt, } /// The direction to use when listing stored threads. @@ -399,8 +397,6 @@ pub struct StoredThread { pub created_at: DateTime, /// Thread last-update timestamp. pub updated_at: DateTime, - /// Thread product-recency timestamp. - pub recency_at: DateTime, /// Thread archive timestamp, if archived. pub archived_at: Option>, /// Working directory captured for the thread. @@ -508,8 +504,6 @@ pub struct ThreadMetadataPatch { pub created_at: Option>, /// Last update timestamp for this metadata observation. pub updated_at: Option>, - /// Advance product recency to at least this timestamp. - pub advance_recency_at: Option>, /// Session source. pub source: Option, /// Optional analytics source classification. @@ -592,9 +586,6 @@ impl ThreadMetadataPatch { if next.updated_at.is_some() { self.updated_at = next.updated_at; } - if next.advance_recency_at.is_some() { - self.advance_recency_at = next.advance_recency_at; - } if next.source.is_some() { self.source = next.source; } @@ -648,7 +639,6 @@ impl ThreadMetadataPatch { && self.reasoning_effort.is_none() && self.created_at.is_none() && self.updated_at.is_none() - && self.advance_recency_at.is_none() && self.source.is_none() && self.thread_source.is_none() && self.agent_nickname.is_none() diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index 6bd260c0b6..aba08f08d9 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -136,7 +136,6 @@ mod tests { model_provider: "openai".to_string(), created_at: 0, updated_at: 0, - recency_at: Some(0), status: ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index d8a29d4eed..87538251aa 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2881,7 +2881,6 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re model_provider: "agent-provider".to_string(), created_at: 1, updated_at: 2, - recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: Some(rollout_path.clone()), cwd: test_path_buf("/tmp/agent").abs(), @@ -2974,7 +2973,6 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ model_provider: "agent-provider".to_string(), created_at: 1, updated_at: 2, - recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/agent").abs(), @@ -3034,7 +3032,6 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { model_provider: "read-provider".to_string(), created_at: 1, updated_at: 2, - recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/read").abs(), @@ -5646,7 +5643,6 @@ async fn thread_rollback_response_discards_queued_active_thread_events() { model_provider: "openai".to_string(), created_at: 0, updated_at: 0, - recency_at: Some(0), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/project").abs(), diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index ceafb71db9..bcc172ef93 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -417,7 +417,6 @@ mod tests { model_provider: "read-provider".to_string(), created_at: 1, updated_at: 2, - recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/read").abs(), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 4049ca14a6..5e36fb1c9d 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -2319,7 +2319,6 @@ mod tests { model_provider: "openai".to_string(), created_at: 1, updated_at: 2, - recency_at: Some(2), status: ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/project").abs(), diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 08191eaf2b..18313bcca6 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -608,7 +608,7 @@ fn spawn_app_server_page_loader( fn sort_key_label(sort_key: ThreadSortKey) -> &'static str { match sort_key { ThreadSortKey::CreatedAt => "Created", - ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => "Updated", + ThreadSortKey::UpdatedAt => "Updated", } } @@ -1614,7 +1614,7 @@ impl PickerState { fn toggle_sort_key(&mut self) { self.sort_key = match self.sort_key { ThreadSortKey::CreatedAt => ThreadSortKey::UpdatedAt, - ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => ThreadSortKey::CreatedAt, + ThreadSortKey::UpdatedAt => ThreadSortKey::CreatedAt, }; self.start_initial_load(); } @@ -2613,7 +2613,7 @@ fn render_dense_session_lines( let updated = format_relative_time(reference, row.updated_at.or(row.created_at)); let date = match state.sort_key { ThreadSortKey::CreatedAt => created, - ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => updated, + ThreadSortKey::UpdatedAt => updated, }; let mut lines = vec![dense_summary_line(DenseSummaryInput { marker, @@ -2742,7 +2742,7 @@ fn render_footer_lines( ) -> Vec> { let date = match sort_key { ThreadSortKey::CreatedAt => created, - ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => updated, + ThreadSortKey::UpdatedAt => updated, }; let mut parts = vec![FooterPart::Date(date.to_string())]; if show_cwd { @@ -5728,7 +5728,6 @@ session_picker_view = "dense" model_provider: String::from("openai"), created_at: 1, updated_at: 2, - recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), @@ -5764,7 +5763,6 @@ session_picker_view = "dense" model_provider: String::from("openai"), created_at: 1, updated_at: 2, - recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), @@ -5834,7 +5832,6 @@ session_picker_view = "dense" model_provider: String::from("openai"), created_at: 1, updated_at: 2, - recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), @@ -5893,7 +5890,6 @@ session_picker_view = "dense" model_provider: String::from("openai"), created_at: 1, updated_at: 2, - recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), From 172b2218a51ca0a0320385aaa57dc636c12a3384 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 16 Jun 2026 22:17:24 -0700 Subject: [PATCH 23/70] core: remove redundant TurnContext and Prompt fields (#28638) ## Why `TurnContext` had accumulated dead fields and cached projections of values already owned by its per-turn `Config` or `ModelInfo`. Keeping both copies made ownership unclear and allowed artificial split-brain states, such as a compatibility hash differing from the model metadata it came from. `Prompt` similarly carried a write-only personality after personality selection had already been materialized into its base instructions. This makes the canonical owner explicit: configuration-backed values come from `config`, model-derived values come from `model_info`, and prompts contain only data consumed by request construction. ## What changed - Remove the unused `ghost_snapshot`, `codex_self_exe`, and `thread_source` fields. - Remove duplicate `comp_hash`, `truncation_policy`, `features`, `shell_environment_policy`, `codex_linux_sandbox_exe`, `compact_prompt`, and `tool_mode` fields. - Read those values directly from `TurnContext::config` or `TurnContext::model_info` at their consumers. - Remove the write-only `Prompt::personality` field and its constructor assignments. - Preserve review-turn inheritance of the parent turn's shell policy, Linux sandbox executable, and compact prompt through the review config. ## Testing - `cargo check -p codex-core --tests` --- codex-rs/core/src/client_common.rs | 5 -- codex-rs/core/src/compact.rs | 10 ++- codex-rs/core/src/compact_remote.rs | 1 - codex-rs/core/src/compact_remote_v2.rs | 1 - codex-rs/core/src/context_manager/updates.rs | 8 ++- codex-rs/core/src/mcp_tool_call.rs | 10 ++- codex-rs/core/src/mcp_tool_call_tests.rs | 18 ++--- codex-rs/core/src/session/mod.rs | 34 ++++++--- codex-rs/core/src/session/review.rs | 28 +++----- .../src/session/rollout_reconstruction.rs | 4 +- codex-rs/core/src/session/tests.rs | 32 +++------ .../core/src/session/tests/guardian_tests.rs | 20 +++--- codex-rs/core/src/session/token_budget.rs | 2 +- codex-rs/core/src/session/turn.rs | 11 +-- codex-rs/core/src/session/turn_context.rs | 71 ++----------------- codex-rs/core/src/tasks/compact.rs | 8 ++- codex-rs/core/src/tasks/mod.rs | 2 +- codex-rs/core/src/tasks/user_shell.rs | 12 ++-- codex-rs/core/src/tools/code_mode/mod.rs | 4 +- codex-rs/core/src/tools/events.rs | 7 +- .../core/src/tools/handlers/apply_patch.rs | 6 +- .../src/tools/handlers/extension_tools.rs | 4 +- codex-rs/core/src/tools/handlers/mcp.rs | 2 +- .../list_mcp_resource_templates.rs | 3 +- .../mcp_resource/list_mcp_resources.rs | 3 +- .../mcp_resource/read_mcp_resource.rs | 3 +- .../src/tools/handlers/multi_agents_common.rs | 3 - .../src/tools/handlers/multi_agents_tests.rs | 14 ++-- codex-rs/core/src/tools/handlers/shell.rs | 11 ++- .../src/tools/handlers/shell/shell_command.rs | 5 +- .../core/src/tools/handlers/shell_tests.rs | 2 +- .../handlers/unified_exec/exec_command.rs | 4 +- .../handlers/unified_exec/write_stdin.rs | 2 +- .../core/src/tools/handlers/view_image.rs | 2 +- codex-rs/core/src/tools/mod.rs | 15 ++++ codex-rs/core/src/tools/orchestrator.rs | 6 +- .../tools/runtimes/shell/unix_escalation.rs | 8 +-- codex-rs/core/src/tools/spec_plan.rs | 46 +++++++----- codex-rs/core/src/tools/spec_plan_tests.rs | 20 ------ codex-rs/core/src/unified_exec/mod_tests.rs | 2 +- .../core/src/unified_exec/process_manager.rs | 16 +++-- codex-rs/core/src/user_shell_command.rs | 5 +- 42 files changed, 225 insertions(+), 245 deletions(-) diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 624501db46..e0c2ff03c3 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -1,5 +1,4 @@ pub use codex_api::ResponseEvent; -use codex_config::types::Personality; use codex_protocol::error::Result; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; @@ -29,9 +28,6 @@ pub struct Prompt { pub base_instructions: BaseInstructions, - /// Optionally specify the personality of the model. - pub personality: Option, - /// Optional the output schema for the model's response. pub output_schema: Option, @@ -46,7 +42,6 @@ impl Default for Prompt { tools: Vec::new(), parallel_tool_calls: false, base_instructions: BaseInstructions::default(), - personality: None, output_schema: None, output_schema_strict: true, } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 52b2ef4ef5..9d79138e87 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -77,7 +77,12 @@ pub(crate) async fn run_inline_auto_compact_task( reason: CompactionReason, phase: CompactionPhase, ) -> CodexResult<()> { - let prompt = turn_context.compact_prompt().to_string(); + let prompt = turn_context + .config + .compact_prompt + .as_deref() + .unwrap_or(SUMMARIZATION_PROMPT) + .to_string(); let input = vec![UserInput::Text { text: prompt, // Compaction prompt is synthesized; no UI element ranges to preserve. @@ -209,7 +214,7 @@ async fn run_compact_task_inner_impl( let mut history = sess.clone_history().await; history.record_items( &[initial_input_for_turn.into()], - turn_context.truncation_policy, + turn_context.model_info.truncation_policy.into(), ); let max_retries = turn_context.provider.info().stream_max_retries(); @@ -234,7 +239,6 @@ async fn run_compact_task_inner_impl( let prompt = Prompt { input: turn_input, base_instructions: sess.get_base_instructions().await, - personality: turn_context.personality, ..Default::default() }; let attempt_result = drain_to_completed( diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index eca14af80e..08ca7efcdf 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -228,7 +228,6 @@ async fn run_remote_compact_task_inner_impl( tools: tool_router.model_visible_specs(), parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, base_instructions, - personality: turn_context.personality, output_schema: None, output_schema_strict: true, }; diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index d13aa9406e..4b4071a0d9 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -237,7 +237,6 @@ async fn run_remote_compact_task_inner_impl( tools: tool_router.model_visible_specs(), parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, base_instructions, - personality: turn_context.personality, output_schema: None, output_schema_strict: true, }; diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 0ce02240b0..4c69aae95b 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -63,8 +63,12 @@ fn build_permissions_update_item( exec_policy, #[allow(deprecated)] &next.cwd, - next.features.enabled(Feature::ExecPermissionApprovals), - next.features.enabled(Feature::RequestPermissionsTool), + next.config + .features + .enabled(Feature::ExecPermissionApprovals), + next.config + .features + .enabled(Feature::RequestPermissionsTool), ) .render(), ) diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 708d266546..e8cbb56c55 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -626,7 +626,11 @@ async fn maybe_request_codex_apps_auth_elicitation( return result; } - if !turn_context.features.enabled(Feature::AuthElicitation) { + if !turn_context + .config + .features + .enabled(Feature::AuthElicitation) + { return result; } @@ -723,10 +727,10 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state( let sandbox_state = serde_json::to_value(SandboxState { permission_profile: Some(turn_context.permission_profile()), sandbox_policy: turn_context.sandbox_policy(), - codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(), + codex_linux_sandbox_exe: turn_context.config.codex_linux_sandbox_exe.clone(), #[allow(deprecated)] sandbox_cwd: turn_context.cwd.to_path_buf(), - use_legacy_landlock: turn_context.features.use_legacy_landlock(), + use_legacy_landlock: turn_context.config.features.use_legacy_landlock(), })?; match meta.as_mut() { diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 9a8a9174a1..56d1ff9b6c 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1316,15 +1316,14 @@ async fn codex_apps_auth_elicitation_non_host_owned_server_returns_original_resu let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await; let mut features = Features::with_defaults(); features.enable(Feature::AuthElicitation); - Arc::get_mut(&mut turn_context) - .expect("single turn context ref") - .features = ManagedFeatures::from(features); + let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref"); + Arc::make_mut(&mut turn_context.config).features = ManagedFeatures::from(features); let result = codex_apps_auth_failure_result(); let metadata = codex_apps_auth_failure_metadata(); let returned = maybe_request_codex_apps_auth_elicitation( &session, - &turn_context, + turn_context, "call_123", CODEX_APPS_MCP_SERVER_NAME, Some(&metadata), @@ -1343,7 +1342,7 @@ async fn codex_apps_auth_elicitation_disallowed_by_policy_returns_original_resul let mut features = Features::with_defaults(); features.enable(Feature::AuthElicitation); let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref"); - turn_context.features = ManagedFeatures::from(features); + Arc::make_mut(&mut turn_context.config).features = ManagedFeatures::from(features); turn_context .approval_policy .set(AskForApproval::Never) @@ -1372,7 +1371,7 @@ async fn codex_apps_auth_elicitation_granular_mcp_disabled_returns_original_resu let mut features = Features::with_defaults(); features.enable(Feature::AuthElicitation); let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref"); - turn_context.features = ManagedFeatures::from(features); + Arc::make_mut(&mut turn_context.config).features = ManagedFeatures::from(features); turn_context .approval_policy .set(AskForApproval::Granular(GranularApprovalConfig { @@ -1407,9 +1406,10 @@ async fn codex_apps_auth_elicitation_feature_enabled_requests_elicitation() { *session.active_turn.lock().await = Some(ActiveTurn::default()); let mut features = Features::with_defaults(); features.enable(Feature::AuthElicitation); - Arc::get_mut(&mut turn_context) - .expect("single turn context ref") - .features = ManagedFeatures::from(features); + { + let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref"); + Arc::make_mut(&mut turn_context.config).features = ManagedFeatures::from(features); + } let result = codex_apps_auth_failure_result(); let metadata = codex_apps_auth_failure_metadata(); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index d632609c02..d61de71bc5 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -149,7 +149,6 @@ use codex_thread_store::ReadThreadParams; use codex_thread_store::ResumeThreadParams; use codex_thread_store::ThreadPersistenceMetadata; use codex_thread_store::ThreadStore; -use codex_utils_output_truncation::TruncationPolicy; use codex_utils_path_uri::PathUri; use futures::future::BoxFuture; use futures::future::Shared; @@ -198,7 +197,6 @@ use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStackOrdering; use codex_config::types::McpServerConfig; use codex_model_provider_info::ModelProviderInfo; -use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; #[cfg(test)] @@ -1330,7 +1328,11 @@ impl Session { } = self .reconstruct_history_from_rollout(turn_context, rollout_items) .await; - if turn_context.features.enabled(Feature::ResizeAllImages) { + if turn_context + .config + .features + .enabled(Feature::ResizeAllImages) + { // Keep the recorded rollout unchanged. Prepare its reconstructed history before // installing it, so legacy images are processed once for this resume or fork and // will be processed again if the rollout is reconstructed in a future session. @@ -2630,7 +2632,11 @@ impl Session { turn_context: &TurnContext, items: &'a [ResponseItem], ) -> Cow<'a, [ResponseItem]> { - if !turn_context.features.enabled(Feature::ResizeAllImages) { + if !turn_context + .config + .features + .enabled(Feature::ResizeAllImages) + { return Cow::Borrowed(items); } @@ -2644,7 +2650,11 @@ impl Session { turn_context: &TurnContext, input: Vec, ) -> ResponseItem { - let local_image_preparation = if turn_context.features.enabled(Feature::ResizeAllImages) { + let local_image_preparation = if turn_context + .config + .features + .enabled(Feature::ResizeAllImages) + { LocalImagePreparation::Defer } else { LocalImagePreparation::Process @@ -2664,7 +2674,10 @@ impl Session { let items = items.as_ref(); { let mut state = self.state.lock().await; - state.record_items(items.iter(), turn_context.truncation_policy); + state.record_items( + items.iter(), + turn_context.model_info.truncation_policy.into(), + ); } self.persist_rollout_response_items(items).await; self.send_raw_response_items(turn_context, items).await; @@ -2683,7 +2696,10 @@ impl Session { let items = items.as_ref(); { let mut state = self.state.lock().await; - state.record_items(items.iter(), turn_context.truncation_policy); + state.record_items( + items.iter(), + turn_context.model_info.truncation_policy.into(), + ); } self.persist_rollout_items(&[RolloutItem::InterAgentCommunication(communication)]) .await; @@ -2885,9 +2901,11 @@ impl Session { #[allow(deprecated)] &turn_context.cwd, turn_context + .config .features .enabled(Feature::ExecPermissionApprovals), turn_context + .config .features .enabled(Feature::RequestPermissionsTool), ) @@ -3031,7 +3049,7 @@ impl Session { contextual_user_sections.push(user_instructions.to_string()); } // This is full-context metadata. Steady-state context diffs should not re-emit it. - if turn_context.features.enabled(Feature::TokenBudget) + if turn_context.config.features.enabled(Feature::TokenBudget) && let Some(model_context_window) = turn_context.model_context_window() { developer_sections.push( diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 746265971e..0296d9e44a 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -1,6 +1,5 @@ use super::*; use codex_core_skills::HostLoadedSkills; -use codex_protocol::openai_models::ToolMode; use std::sync::atomic::AtomicBool; /// Spawn a review thread using the given prompt. @@ -47,15 +46,14 @@ pub(super) async fn spawn_review_thread( let mut per_turn_config = (*config).clone(); per_turn_config.model = Some(model.clone()); per_turn_config.features = review_features.clone(); - let tool_mode = model_info.tool_mode.unwrap_or_else(|| { - if per_turn_config.features.enabled(Feature::CodeModeOnly) { - ToolMode::CodeModeOnly - } else if per_turn_config.features.enabled(Feature::CodeMode) { - ToolMode::CodeMode - } else { - ToolMode::Direct - } - }); + per_turn_config.permissions.shell_environment_policy = parent_turn_context + .config + .permissions + .shell_environment_policy + .clone(); + per_turn_config.codex_linux_sandbox_exe = + parent_turn_context.config.codex_linux_sandbox_exe.clone(); + per_turn_config.compact_prompt = parent_turn_context.config.compact_prompt.clone(); if let Err(err) = per_turn_config.web_search_mode.set(review_web_search_mode) { let fallback_value = per_turn_config.web_search_mode.value(); tracing::warn!( @@ -113,26 +111,20 @@ pub(super) async fn spawn_review_thread( config: per_turn_config, auth_manager: auth_manager_for_context, model_info: model_info.clone(), - comp_hash: model_info.comp_hash.clone(), - tool_mode, session_telemetry: session_telemetry_for_context, provider: provider_for_context, reasoning_effort, reasoning_summary, session_source, parent_thread_id: parent_turn_context.parent_thread_id, - thread_source: parent_turn_context.thread_source.clone(), environments: parent_turn_context.environments.clone(), available_models, unified_exec_shell_mode, - features: review_features, - ghost_snapshot: parent_turn_context.ghost_snapshot.clone(), current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), developer_instructions: None, user_instructions: None, - compact_prompt: parent_turn_context.compact_prompt.clone(), collaboration_mode: parent_turn_context.collaboration_mode.clone(), multi_agent_version: MultiAgentVersion::Disabled, personality: parent_turn_context.personality, @@ -140,14 +132,10 @@ pub(super) async fn spawn_review_thread( permission_profile: parent_turn_context.permission_profile(), network: parent_turn_context.network.clone(), windows_sandbox_level: parent_turn_context.windows_sandbox_level, - shell_environment_policy: parent_turn_context.shell_environment_policy.clone(), #[allow(deprecated)] cwd: parent_turn_context.cwd.clone(), final_output_json_schema: None, - codex_self_exe: parent_turn_context.codex_self_exe.clone(), - codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(), dynamic_tools: parent_turn_context.dynamic_tools.clone(), - truncation_policy: model_info.truncation_policy.into(), turn_metadata_state, extension_data, turn_skills: TurnSkillsContext::new(parent_turn_context.turn_skills.outcome.clone()), diff --git a/codex-rs/core/src/session/rollout_reconstruction.rs b/codex-rs/core/src/session/rollout_reconstruction.rs index 34e7807a9a..ab4838b2ff 100644 --- a/codex-rs/core/src/session/rollout_reconstruction.rs +++ b/codex-rs/core/src/session/rollout_reconstruction.rs @@ -271,14 +271,14 @@ impl Session { RolloutItem::ResponseItem(response_item) => { history.record_items( std::iter::once(response_item), - turn_context.truncation_policy, + turn_context.model_info.truncation_policy.into(), ); } RolloutItem::InterAgentCommunication(communication) => { let response_item = communication.to_model_input_item(); history.record_items( std::iter::once(&response_item), - turn_context.truncation_policy, + turn_context.model_info.truncation_policy.into(), ); } RolloutItem::Compacted(compacted) => { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b7ddb90b1c..473adaa835 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3530,7 +3530,7 @@ async fn includes_timed_out_message() { }; let (_, turn_context) = make_session_and_context().await; - let out = format_exec_output_str(&exec, turn_context.truncation_policy); + let out = format_exec_output_str(&exec, turn_context.model_info.truncation_policy.into()); assert_eq!( out, @@ -3569,10 +3569,6 @@ async fn turn_context_with_model_updates_model_fields() { updated.config.model_reasoning_effort, Some(ReasoningEffortConfig::Medium) ); - assert_eq!( - updated.truncation_policy, - expected_model_info.truncation_policy.into() - ); } #[test] @@ -8118,18 +8114,6 @@ fn file_system_policy_with_unreadable_glob(turn_context: &TurnContext) -> FileSy policy } -#[tokio::test] -async fn turn_context_item_uses_turn_context_comp_hash_snapshot() { - let (_session, mut turn_context) = make_session_and_context().await; - turn_context.comp_hash = Some("turn-context-hash".to_string()); - turn_context.model_info.comp_hash = Some("model-info-hash".to_string()); - - assert_eq!( - turn_context.to_turn_context_item().comp_hash.as_deref(), - Some("turn-context-hash") - ); -} - #[tokio::test] async fn turn_context_item_stores_local_cwd() { let (_session, mut turn_context) = make_session_and_context().await; @@ -9706,7 +9690,7 @@ async fn sample_rollout( } live_history.record_items( initial_context.iter(), - reconstruction_turn.truncation_policy, + reconstruction_turn.model_info.truncation_policy.into(), ); let user1 = ResponseItem::Message { @@ -9720,7 +9704,7 @@ async fn sample_rollout( }; live_history.record_items( std::iter::once(&user1), - reconstruction_turn.truncation_policy, + reconstruction_turn.model_info.truncation_policy.into(), ); rollout_items.push(RolloutItem::ResponseItem(user1.clone())); @@ -9735,7 +9719,7 @@ async fn sample_rollout( }; live_history.record_items( std::iter::once(&assistant1), - reconstruction_turn.truncation_policy, + reconstruction_turn.model_info.truncation_policy.into(), ); rollout_items.push(RolloutItem::ResponseItem(assistant1.clone())); @@ -9763,7 +9747,7 @@ async fn sample_rollout( }; live_history.record_items( std::iter::once(&user2), - reconstruction_turn.truncation_policy, + reconstruction_turn.model_info.truncation_policy.into(), ); rollout_items.push(RolloutItem::ResponseItem(user2.clone())); @@ -9778,7 +9762,7 @@ async fn sample_rollout( }; live_history.record_items( std::iter::once(&assistant2), - reconstruction_turn.truncation_policy, + reconstruction_turn.model_info.truncation_policy.into(), ); rollout_items.push(RolloutItem::ResponseItem(assistant2.clone())); @@ -9806,7 +9790,7 @@ async fn sample_rollout( }; live_history.record_items( std::iter::once(&user3), - reconstruction_turn.truncation_policy, + reconstruction_turn.model_info.truncation_policy.into(), ); rollout_items.push(RolloutItem::ResponseItem(user3)); @@ -9821,7 +9805,7 @@ async fn sample_rollout( }; live_history.record_items( std::iter::once(&assistant3), - reconstruction_turn.truncation_policy, + reconstruction_turn.model_info.truncation_policy.into(), ); rollout_items.push(RolloutItem::ResponseItem(assistant3)); diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index e958b39e1f..43cd91a99d 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -95,11 +95,11 @@ async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() { .approval_policy .set(AskForApproval::OnRequest) .expect("test setup should allow updating approval policy"); - turn_context_raw + let mut config = (*turn_context_raw.config).clone(); + config .features .enable(Feature::GuardianApproval) .expect("test setup should allow enabling guardian approvals"); - let mut config = (*turn_context_raw.config).clone(); config.approvals_reviewer = ApprovalsReviewer::AutoReview; config.model_provider.base_url = Some(format!("{}/v1", server.uri())); let config = Arc::new(config); @@ -183,11 +183,11 @@ async fn request_permissions_guardian_review_stops_when_cancelled() { .approval_policy .set(AskForApproval::OnRequest) .expect("test setup should allow updating approval policy"); - turn_context_raw + let mut config = (*turn_context_raw.config).clone(); + config .features .enable(Feature::GuardianApproval) .expect("test setup should allow enabling guardian approvals"); - let mut config = (*turn_context_raw.config).clone(); config.approvals_reviewer = ApprovalsReviewer::AutoReview; config.model_provider.base_url = Some(format!("{}/v1", server.uri())); let config = Arc::new(config); @@ -292,21 +292,21 @@ async fn guardian_allows_shell_command_additional_permissions_requests_past_poli .await; let (mut session, mut turn_context_raw) = make_session_and_context().await; - turn_context_raw.codex_linux_sandbox_exe = codex_linux_sandbox_exe_or_skip!(); turn_context_raw .approval_policy .set(AskForApproval::OnRequest) .expect("test setup should allow updating approval policy"); - turn_context_raw - .features - .enable(Feature::GuardianApproval) - .expect("test setup should allow enabling guardian approvals"); session .features .enable(Feature::ExecPermissionApprovals) .expect("test setup should allow enabling request permissions"); turn_context_raw.permission_profile = codex_protocol::models::PermissionProfile::Disabled; let mut config = (*turn_context_raw.config).clone(); + config.codex_linux_sandbox_exe = codex_linux_sandbox_exe_or_skip!(); + config + .features + .enable(Feature::GuardianApproval) + .expect("test setup should allow enabling guardian approvals"); config.model_provider.base_url = Some(format!("{}/v1", server.uri())); let config = Arc::new(config); let models_manager = models_manager_with_provider( @@ -467,7 +467,7 @@ async fn guardian_allows_unified_exec_additional_permissions_requests_past_polic .approval_policy .set(AskForApproval::OnRequest) .expect("test setup should allow updating approval policy"); - turn_context_raw + Arc::make_mut(&mut turn_context_raw.config) .features .enable(Feature::GuardianApproval) .expect("test setup should allow enabling guardian approvals"); diff --git a/codex-rs/core/src/session/token_budget.rs b/codex-rs/core/src/session/token_budget.rs index b8f9f82fbc..4548ed3198 100644 --- a/codex-rs/core/src/session/token_budget.rs +++ b/codex-rs/core/src/session/token_budget.rs @@ -11,7 +11,7 @@ pub(super) async fn maybe_record_token_budget_remaining_context( tokens_before_sampling: i64, tokens_after_sampling: i64, ) { - if !turn_context.features.enabled(Feature::TokenBudget) { + if !turn_context.config.features.enabled(Feature::TokenBudget) { return; } let Some(model_context_window) = turn_context.model_context_window() else { diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 4b052cb659..7da157cf7b 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -177,7 +177,7 @@ pub(crate) async fn run_turn( .await; sess.set_previous_turn_settings(Some(PreviousTurnSettings { model: turn_context.model_info.slug.clone(), - comp_hash: turn_context.comp_hash.clone(), + comp_hash: turn_context.model_info.comp_hash.clone(), realtime_active: Some(turn_context.realtime_active), })) .await; @@ -840,7 +840,7 @@ async fn maybe_run_previous_model_inline_compact( }; let should_compact_for_comp_hash_change = comp_hash_changed( previous_turn_settings.comp_hash.as_deref(), - turn_context.comp_hash.as_deref(), + turn_context.model_info.comp_hash.as_deref(), ); let previous_model_turn_context = Arc::new( turn_context @@ -913,7 +913,11 @@ async fn run_auto_compact( phase: CompactionPhase, ) -> CodexResult<()> { if should_use_remote_compact_task(turn_context.provider.info()) { - if turn_context.features.enabled(Feature::RemoteCompactionV2) { + if turn_context + .config + .features + .enabled(Feature::RemoteCompactionV2) + { emit_compact_metric( &sess.services.session_telemetry, "remote_v2", @@ -1025,7 +1029,6 @@ pub(crate) fn build_prompt( tools: router.model_visible_specs(), parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, base_instructions, - personality: turn_context.personality, output_schema: turn_context.final_output_json_schema.clone(), output_schema_strict: !crate::guardian::is_guardian_reviewer_source( &turn_context.session_source, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 526a16bacc..b76c9384f7 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -1,7 +1,6 @@ use super::*; use crate::SkillLoadOutcome; use crate::agents_md::LoadedAgentsMd; -use crate::config::GhostSnapshotConfig; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::shell_snapshot::ShellSnapshotFile; use codex_core_skills::HostLoadedSkills; @@ -12,9 +11,7 @@ use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::openai_models::ModelInfo; -use codex_protocol::openai_models::ToolMode; use codex_protocol::protocol::MultiAgentVersion; -use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile; use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy; @@ -111,15 +108,12 @@ pub struct TurnContext { pub config: Arc, pub(crate) auth_manager: Option>, pub(crate) model_info: ModelInfo, - pub(crate) comp_hash: Option, - pub(crate) tool_mode: ToolMode, pub(crate) session_telemetry: SessionTelemetry, pub(crate) provider: SharedModelProvider, pub(crate) reasoning_effort: Option, pub(crate) reasoning_summary: ReasoningSummaryConfig, pub(crate) session_source: SessionSource, pub(crate) parent_thread_id: Option, - pub(crate) thread_source: Option, pub(crate) environments: TurnEnvironmentSnapshot, /// The session's absolute working directory. All relative paths provided /// by the model as well as sandbox policies are resolved against this path @@ -130,7 +124,6 @@ pub struct TurnContext { pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, pub(crate) developer_instructions: Option, - pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, pub(crate) collaboration_mode: CollaborationMode, pub(crate) multi_agent_version: MultiAgentVersion, @@ -139,15 +132,9 @@ pub struct TurnContext { pub(crate) permission_profile: PermissionProfile, pub(crate) network: Option, pub(crate) windows_sandbox_level: WindowsSandboxLevel, - pub(crate) shell_environment_policy: ShellEnvironmentPolicy, pub(crate) available_models: Vec, pub(crate) unified_exec_shell_mode: UnifiedExecShellMode, - pub features: ManagedFeatures, - pub(crate) ghost_snapshot: GhostSnapshotConfig, pub(crate) final_output_json_schema: Option, - pub(crate) codex_self_exe: Option, - pub(crate) codex_linux_sandbox_exe: Option, - pub(crate) truncation_policy: TruncationPolicy, pub(crate) dynamic_tools: Vec, pub(crate) turn_metadata_state: Arc, pub(crate) extension_data: Arc, @@ -214,7 +201,9 @@ impl TurnContext { .auth_manager .as_deref() .is_some_and(AuthManager::current_auth_uses_codex_backend); - self.features.apps_enabled_for_auth(uses_codex_backend) + self.config + .features + .apps_enabled_for_auth(uses_codex_backend) } pub(crate) fn tool_environment_mode(&self) -> ToolEnvironmentMode { @@ -231,16 +220,6 @@ impl TurnContext { let model_info = models_manager .get_model_info(model.as_str(), &config.to_models_manager_config()) .await; - let tool_mode = model_info.tool_mode.unwrap_or_else(|| { - if config.features.enabled(Feature::CodeModeOnly) { - ToolMode::CodeModeOnly - } else if config.features.enabled(Feature::CodeMode) { - ToolMode::CodeMode - } else { - ToolMode::Direct - } - }); - let truncation_policy = model_info.truncation_policy.into(); let supported_reasoning_levels = model_info .supported_reasoning_levels .iter() @@ -269,7 +248,6 @@ impl TurnContext { Some(reasoning_effort.clone()), /*developer_instructions*/ None, ); - let features = self.features.clone(); let available_models = models_manager .list_models(RefreshStrategy::OnlineIfUncached) .await; @@ -281,8 +259,6 @@ impl TurnContext { config: Arc::new(config), auth_manager: self.auth_manager.clone(), model_info: model_info.clone(), - comp_hash: model_info.comp_hash.clone(), - tool_mode, session_telemetry: self .session_telemetry .clone() @@ -292,7 +268,6 @@ impl TurnContext { reasoning_summary: self.reasoning_summary, session_source: self.session_source.clone(), parent_thread_id: self.parent_thread_id, - thread_source: self.thread_source.clone(), environments: self.environments.clone(), #[allow(deprecated)] cwd: self.cwd.clone(), @@ -300,7 +275,6 @@ impl TurnContext { timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), developer_instructions: self.developer_instructions.clone(), - compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), collaboration_mode, multi_agent_version: self.multi_agent_version, @@ -309,15 +283,9 @@ impl TurnContext { permission_profile: self.permission_profile.clone(), network: self.network.clone(), windows_sandbox_level: self.windows_sandbox_level, - shell_environment_policy: self.shell_environment_policy.clone(), available_models, unified_exec_shell_mode: self.unified_exec_shell_mode.clone(), - features, - ghost_snapshot: self.ghost_snapshot.clone(), final_output_json_schema: self.final_output_json_schema.clone(), - codex_self_exe: self.codex_self_exe.clone(), - codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), - truncation_policy, dynamic_tools: self.dynamic_tools.clone(), turn_metadata_state: self.turn_metadata_state.clone(), extension_data: Arc::clone(&self.extension_data), @@ -368,7 +336,7 @@ impl TurnContext { .config .permissions .windows_sandbox_private_desktop, - use_legacy_landlock: self.features.use_legacy_landlock(), + use_legacy_landlock: self.config.features.use_legacy_landlock(), } } @@ -388,12 +356,6 @@ impl TurnContext { .then_some(file_system_sandbox_policy) } - pub(crate) fn compact_prompt(&self) -> &str { - self.compact_prompt - .as_deref() - .unwrap_or(compact::SUMMARIZATION_PROMPT) - } - pub(crate) fn to_turn_context_item(&self) -> TurnContextItem { let workspace_roots = self.config.effective_workspace_roots(); #[allow(deprecated)] @@ -410,7 +372,7 @@ impl TurnContext { network: self.turn_context_network_item(), file_system_sandbox_policy: self.non_legacy_file_system_sandbox_policy(), model: self.model_info.slug.clone(), - comp_hash: self.comp_hash.clone(), + comp_hash: self.model_info.comp_hash.clone(), personality: self.personality, collaboration_mode: Some(self.collaboration_mode.clone()), multi_agent_version: Some(self.multi_agent_version), @@ -549,15 +511,6 @@ impl Session { ); let mut per_turn_config = per_turn_config; - let tool_mode = model_info.tool_mode.unwrap_or_else(|| { - if per_turn_config.features.enabled(Feature::CodeModeOnly) { - ToolMode::CodeModeOnly - } else if per_turn_config.features.enabled(Feature::CodeMode) { - ToolMode::CodeMode - } else { - ToolMode::Direct - } - }); per_turn_config.service_tier = get_service_tier( per_turn_config.service_tier, per_turn_config.features.enabled(Feature::FastMode), @@ -583,18 +536,15 @@ impl Session { sub_id, trace_id: current_span_trace_id(), realtime_active: false, - config: per_turn_config.clone(), + config: per_turn_config, auth_manager: auth_manager_for_context, - model_info: model_info.clone(), - comp_hash: model_info.comp_hash.clone(), - tool_mode, + model_info, session_telemetry: session_telemetry_for_context, provider: provider_for_context, reasoning_effort, reasoning_summary, session_source, parent_thread_id: session_configuration.parent_thread_id, - thread_source: session_configuration.thread_source.clone(), environments, #[allow(deprecated)] cwd, @@ -602,7 +552,6 @@ impl Session { timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), developer_instructions: session_configuration.developer_instructions.clone(), - compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration .loaded_agents_md .as_ref() @@ -614,15 +563,9 @@ impl Session { permission_profile: session_configuration.permission_profile(), network, windows_sandbox_level: session_configuration.windows_sandbox_level, - shell_environment_policy: per_turn_config.permissions.shell_environment_policy.clone(), available_models, unified_exec_shell_mode, - features: per_turn_config.features.clone(), - ghost_snapshot: per_turn_config.ghost_snapshot.clone(), final_output_json_schema: None, - codex_self_exe: per_turn_config.codex_self_exe.clone(), - codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(), - truncation_policy: model_info.truncation_policy.into(), dynamic_tools: session_configuration.dynamic_tools.clone(), turn_metadata_state, extension_data, diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index adc98e413a..dd9d6cb079 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -31,6 +31,7 @@ impl SessionTask for CompactTask { let session = session.clone_session(); let _ = if crate::compact::should_use_remote_compact_task(ctx.provider.info()) { if ctx + .config .features .enabled(codex_features::Feature::RemoteCompactionV2) { @@ -55,7 +56,12 @@ impl SessionTask for CompactTask { /*manual*/ true, ); let input = vec![UserInput::Text { - text: ctx.compact_prompt().to_string(), + text: ctx + .config + .compact_prompt + .as_deref() + .unwrap_or(crate::compact::SUMMARIZATION_PROMPT) + .to_string(), // Compaction prompt is synthesized; no UI element ranges to preserve. text_elements: Vec::new(), }]; diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b2f337ec42..3ff0e2d5f7 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -722,7 +722,7 @@ impl Session { } emit_turn_memory_metric( &self.services.session_telemetry, - turn_context.features.enabled(Feature::MemoryTool), + turn_context.config.features.enabled(Feature::MemoryTool), turn_context.config.memories.use_memories, turn_had_memory_citation, ); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 4c30493517..37835c36a1 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -157,7 +157,7 @@ pub(crate) async fn execute_user_shell_command( }; let shell_snapshot_location = turn_environment.shell_snapshot(&cwd); let mut exec_env_map = create_env( - &turn_context.shell_environment_policy, + &turn_context.config.permissions.shell_environment_policy, Some(session.thread_id), ); if exec_env_map.contains_key(PROXY_ACTIVE_ENV_KEY) { @@ -167,7 +167,11 @@ pub(crate) async fn execute_user_shell_command( &display_command, environment_shell, shell_snapshot_location.as_ref(), - &turn_context.shell_environment_policy.r#set, + &turn_context + .config + .permissions + .shell_environment_policy + .r#set, &mut exec_env_map, ); @@ -294,7 +298,7 @@ pub(crate) async fn execute_user_shell_command( duration: output.duration, formatted_output: format_exec_output_str( &output, - turn_context.truncation_policy, + turn_context.model_info.truncation_policy.into(), ), status: if output.exit_code == 0 { ExecCommandStatus::Completed @@ -339,7 +343,7 @@ pub(crate) async fn execute_user_shell_command( duration: exec_output.duration, formatted_output: format_exec_output_str( &exec_output, - turn_context.truncation_policy, + turn_context.model_info.truncation_policy.into(), ), status: ExecCommandStatus::Failed, }), diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index 92bb5a8ad6..09918033e6 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -26,6 +26,7 @@ use crate::tools::ToolRouter; use crate::tools::context::FunctionToolOutput; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolPayload; +use crate::tools::effective_tool_mode; use crate::tools::parallel::ToolCallRuntime; use crate::tools::router::ToolCall; use crate::tools::router::ToolCallSource; @@ -116,7 +117,8 @@ impl CodeModeService { router: Arc, tracker: SharedTurnDiffTracker, ) -> Option { - if !matches!(turn.tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly) + let tool_mode = effective_tool_mode(turn); + if !matches!(tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly) || self.session.is_none() { return None; diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 2751780d58..c30cee84ca 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -346,7 +346,7 @@ impl ToolEmitter { output: &ExecToolCallOutput, ctx: ToolEventCtx<'_>, ) -> String { - super::format_exec_output_for_model(output, ctx.turn.truncation_policy) + super::format_exec_output_for_model(output, ctx.turn.model_info.truncation_policy.into()) } pub async fn finish( @@ -495,7 +495,10 @@ async fn emit_exec_stage( aggregated_output: output.aggregated_output.text.clone(), exit_code: output.exit_code, duration: output.duration, - formatted_output: format_exec_output_str(&output, ctx.turn.truncation_policy), + formatted_output: format_exec_output_str( + &output, + ctx.turn.model_info.truncation_policy.into(), + ), status: if output.exit_code == 0 { ExecCommandStatus::Completed } else { diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 2ce9119e35..9004eb9922 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -82,7 +82,11 @@ impl ToolArgumentDiffConsumer for ApplyPatchArgumentDiffConsumer { call_id: String, diff: &str, ) -> Option { - if !turn.features.enabled(Feature::ApplyPatchStreamingEvents) { + if !turn + .config + .features + .enabled(Feature::ApplyPatchStreamingEvents) + { return None; } diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index b59bfaec02..ae79ba405d 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -143,7 +143,7 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { call_id: invocation.call_id.clone(), tool_name: invocation.tool_name.clone(), model: invocation.turn.model_info.slug.clone(), - truncation_policy: invocation.turn.truncation_policy, + truncation_policy: invocation.turn.model_info.truncation_policy.into(), conversation_history, turn_item_emitter: Arc::new(CoreTurnItemEmitter { session: Arc::downgrade(&invocation.session), @@ -315,7 +315,7 @@ mod tests { let weak_turn = Arc::downgrade(&turn); let turn_id = turn.sub_id.clone(); let model = turn.model_info.slug.clone(); - let truncation_policy = turn.truncation_policy; + let truncation_policy = turn.model_info.truncation_policy.into(); let expected_sandbox_cwds = turn .environments .turn_environments diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index efb32aab94..016a0344cd 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -156,7 +156,7 @@ impl McpHandler { tool_input: result.tool_input, wall_time: started.elapsed(), original_image_detail_supported: can_request_original_image_detail(&turn.model_info), - truncation_policy: turn.truncation_policy, + truncation_policy: turn.model_info.truncation_policy.into(), })) } } diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs index 80b183daba..2af409cf20 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs @@ -115,9 +115,10 @@ impl ListMcpResourceTemplatesHandler { } } .await; + let truncation_policy = turn.model_info.truncation_policy.into(); match payload_result { - Ok(payload) => match serialize_function_output(payload, turn.truncation_policy) { + Ok(payload) => match serialize_function_output(payload, truncation_policy) { Ok(output) => { let content = function_call_output_content_items_to_text(&output.body) .unwrap_or_default(); diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs index a77d617b94..1a6a6e2014 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs @@ -113,9 +113,10 @@ impl ListMcpResourcesHandler { } } .await; + let truncation_policy = turn.model_info.truncation_policy.into(); match payload_result { - Ok(payload) => match serialize_function_output(payload, turn.truncation_policy) { + Ok(payload) => match serialize_function_output(payload, truncation_policy) { Ok(output) => { let content = function_call_output_content_items_to_text(&output.body) .unwrap_or_default(); diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs index 9ca15718a0..f1ee6b462c 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs @@ -96,9 +96,10 @@ impl ReadMcpResourceHandler { }) } .await; + let truncation_policy = turn.model_info.truncation_policy.into(); match payload_result { - Ok(payload) => match serialize_function_output(payload, turn.truncation_policy) { + Ok(payload) => match serialize_function_output(payload, truncation_policy) { Ok(output) => { let content = function_call_output_content_items_to_text(&output.body) .unwrap_or_default(); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index 5958892152..8f91ce5b8e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -228,7 +228,6 @@ fn build_agent_shared_config(turn: &TurnContext) -> Result Result Result ToolMode { + turn_context.model_info.tool_mode.unwrap_or_else(|| { + if turn_context.config.features.enabled(Feature::CodeModeOnly) { + ToolMode::CodeModeOnly + } else if turn_context.config.features.enabled(Feature::CodeMode) { + ToolMode::CodeMode + } else { + ToolMode::Direct + } + }) +} + /// Format the combined exec output for sending back to the model. /// Includes exit code and duration metadata; truncates large bodies safely. pub fn format_exec_output_for_model( diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 7e97e4ad51..14c6ad74dc 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -237,7 +237,7 @@ impl ToolOrchestrator { }; // Platform-specific flag gating is handled by SandboxManager::select_initial. - let use_legacy_landlock = turn_ctx.features.use_legacy_landlock(); + let use_legacy_landlock = turn_ctx.config.features.use_legacy_landlock(); #[allow(deprecated)] let sandbox_cwd = tool.sandbox_cwd(req).unwrap_or(&turn_ctx.cwd); let sandbox_policy_cwd = PathUri::from_abs_path(sandbox_cwd); @@ -249,7 +249,7 @@ impl ToolOrchestrator { manager: &self.sandbox, sandbox_cwd: &sandbox_policy_cwd, workspace_roots: workspace_roots.as_slice(), - codex_linux_sandbox_exe: turn_ctx.codex_linux_sandbox_exe.as_ref(), + codex_linux_sandbox_exe: turn_ctx.config.codex_linux_sandbox_exe.as_ref(), use_legacy_landlock, windows_sandbox_level: turn_ctx.windows_sandbox_level, windows_sandbox_private_desktop: turn_ctx @@ -413,7 +413,7 @@ impl ToolOrchestrator { let retry_codex_linux_sandbox_exe = if unsandboxed_allowed { None } else { - turn_ctx.codex_linux_sandbox_exe.as_ref() + turn_ctx.config.codex_linux_sandbox_exe.as_ref() }; let retry_attempt = SandboxAttempt { sandbox: retry_sandbox, diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index ea53e9a55c..f2df497089 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -185,8 +185,8 @@ pub(super) async fn try_run_zsh_fork( arg0, sandbox_policy_cwd, windows_sandbox_workspace_roots, - codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(), - use_legacy_landlock: ctx.turn.features.use_legacy_landlock(), + codex_linux_sandbox_exe: ctx.turn.config.codex_linux_sandbox_exe.clone(), + use_legacy_landlock: ctx.turn.config.features.use_legacy_landlock(), }; let main_execve_wrapper_exe = ctx .session @@ -286,8 +286,8 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( arg0: exec_request.arg0.clone(), sandbox_policy_cwd: exec_request.windows_sandbox_policy_cwd.clone(), windows_sandbox_workspace_roots: exec_request.windows_sandbox_workspace_roots.clone(), - codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(), - use_legacy_landlock: ctx.turn.features.use_legacy_landlock(), + codex_linux_sandbox_exe: ctx.turn.config.codex_linux_sandbox_exe.clone(), + use_legacy_landlock: ctx.turn.config.features.use_legacy_landlock(), }; let escalation_policy = CoreShellActionProvider { policy: Arc::clone(&exec_policy), diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 9269aa379b..7680e5d4ff 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -3,6 +3,7 @@ use crate::agent::next_thread_spawn_depth; use crate::session::turn_context::TurnContext; use crate::tools::code_mode::execute_spec::create_code_mode_tool; use crate::tools::context::ToolInvocation; +use crate::tools::effective_tool_mode; use crate::tools::handlers::ApplyPatchHandler; use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; @@ -243,10 +244,9 @@ fn spec_for_model_request( tool_name: &ToolName, spec: ToolSpec, ) -> ToolSpec { - if matches!( - turn_context.tool_mode, - ToolMode::CodeMode | ToolMode::CodeModeOnly - ) && exposure != ToolExposure::DirectModelOnly + let tool_mode = effective_tool_mode(turn_context); + if matches!(tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly) + && exposure != ToolExposure::DirectModelOnly && !is_excluded_from_code_mode(turn_context, tool_name) && codex_code_mode::is_code_mode_nested_tool(spec.name()) { @@ -298,7 +298,7 @@ pub(crate) fn search_tool_enabled(turn_context: &TurnContext) -> bool { } pub(crate) fn tool_suggest_enabled(turn_context: &TurnContext) -> bool { - let features = turn_context.features.get(); + let features = turn_context.config.features.get(); features.enabled(Feature::ToolSuggest) && features.enabled(Feature::Apps) && features.enabled(Feature::Plugins) @@ -324,7 +324,12 @@ fn collab_tools_enabled(turn_context: &TurnContext) -> bool { } fn agent_jobs_tools_enabled(turn_context: &TurnContext) -> bool { - turn_context.features.get().enabled(Feature::SpawnCsv) && collab_tools_enabled(turn_context) + turn_context + .config + .features + .get() + .enabled(Feature::SpawnCsv) + && collab_tools_enabled(turn_context) } fn agent_jobs_worker_tools_enabled(turn_context: &TurnContext) -> bool { @@ -339,6 +344,7 @@ fn agent_jobs_worker_tools_enabled(turn_context: &TurnContext) -> bool { fn image_generation_tool_enabled(turn_context: &TurnContext) -> bool { image_generation_runtime_enabled(turn_context) && turn_context + .config .features .get() .enabled(Feature::ImageGeneration) @@ -365,7 +371,11 @@ fn standalone_image_generation_model_visible(turn_context: &TurnContext) -> bool return true; } - turn_context.features.get().enabled(Feature::ImageGenExt) + turn_context + .config + .features + .get() + .enabled(Feature::ImageGenExt) } fn standalone_image_generation_available( @@ -412,7 +422,8 @@ fn is_hidden_by_code_mode_only( tool_name: &ToolName, exposure: ToolExposure, ) -> bool { - turn_context.tool_mode == ToolMode::CodeModeOnly + let tool_mode = effective_tool_mode(turn_context); + tool_mode == ToolMode::CodeModeOnly && exposure != ToolExposure::DirectModelOnly && codex_code_mode::is_code_mode_nested_tool(&codex_tools::code_mode_name_for_tool_name( tool_name, @@ -433,10 +444,8 @@ fn build_code_mode_executors( turn_context: &TurnContext, executors: &[Arc], ) -> Vec> { - if !matches!( - turn_context.tool_mode, - ToolMode::CodeMode | ToolMode::CodeModeOnly - ) { + let tool_mode = effective_tool_mode(turn_context); + if !matches!(tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly) { return vec![]; } @@ -482,7 +491,7 @@ fn build_code_mode_executors( create_code_mode_tool( &enabled_tools, &namespace_descriptions, - turn_context.tool_mode == ToolMode::CodeModeOnly, + tool_mode == ToolMode::CodeModeOnly, deferred_tools_available, ), code_mode_nested_tool_specs, @@ -577,6 +586,7 @@ fn standalone_web_search_enabled(turn_context: &TurnContext) -> bool { namespace_tools_enabled(turn_context) && (turn_context.model_info.use_responses_lite || turn_context + .config .features .get() .enabled(Feature::StandaloneWebSearch)) @@ -584,7 +594,7 @@ fn standalone_web_search_enabled(turn_context: &TurnContext) -> bool { fn add_shell_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { let turn_context = context.turn_context; - let features = turn_context.features.get(); + let features = turn_context.config.features.get(); let environment_mode = turn_context.tool_environment_mode(); if !environment_mode.has_environment() { return; @@ -643,7 +653,7 @@ fn add_mcp_resource_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { let turn_context = context.turn_context; - let features = turn_context.features.get(); + let features = turn_context.config.features.get(); let environment_mode = turn_context.tool_environment_mode(); planned_tools.add(PlanHandler); @@ -915,10 +925,8 @@ fn append_extension_tool_executors( .iter() .map(|executor| executor.tool_name()) .collect::>(); - if matches!( - turn_context.tool_mode, - ToolMode::CodeMode | ToolMode::CodeModeOnly - ) { + let tool_mode = effective_tool_mode(turn_context); + if matches!(tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly) { reserved_tool_names.insert(ToolName::plain(codex_code_mode::PUBLIC_TOOL_NAME)); reserved_tool_names.insert(ToolName::plain(codex_code_mode::WAIT_TOOL_NAME)); } diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 8a324dcc8a..43bcf03a6c 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -197,16 +197,6 @@ async fn probe(configure_turn: impl FnOnce(&mut TurnContext)) -> ToolPlanProbe { } fn set_feature(turn: &mut TurnContext, feature: Feature, enabled: bool) { - if enabled { - turn.features - .enable(feature) - .expect("test feature should be enableable"); - } else { - turn.features - .disable(feature) - .expect("test feature should be disableable"); - } - let mut config = (*turn.config).clone(); if enabled { config @@ -221,15 +211,6 @@ fn set_feature(turn: &mut TurnContext, feature: Feature, enabled: bool) { } turn.multi_agent_version = config.multi_agent_version_from_features(); turn.config = Arc::new(config); - turn.tool_mode = turn.model_info.tool_mode.unwrap_or_else(|| { - if turn.config.features.enabled(Feature::CodeModeOnly) { - ToolMode::CodeModeOnly - } else if turn.config.features.enabled(Feature::CodeMode) { - ToolMode::CodeMode - } else { - ToolMode::Direct - } - }); } fn set_features(turn: &mut TurnContext, features: &[Feature]) { @@ -1196,7 +1177,6 @@ async fn tool_mode_selector_overrides_feature_flags() { let direct = probe(|turn| { set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]); turn.model_info.tool_mode = Some(ToolMode::Direct); - turn.tool_mode = ToolMode::Direct; }) .await; direct.assert_visible_lacks(&[ diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index ad39d62b96..dce521f6f4 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -190,7 +190,7 @@ async fn exec_command_with_tty( chunk_id: generate_chunk_id(), wall_time, raw_output: collected, - truncation_policy: turn.truncation_policy, + truncation_policy: turn.model_info.truncation_policy.into(), max_output_tokens: None, process_id: response_process_id, exit_code, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 702a339e2e..e683df963c 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -602,7 +602,7 @@ impl UnifiedExecProcessManager { chunk_id, wall_time, raw_output: collected, - truncation_policy: context.turn.truncation_policy, + truncation_policy: context.turn.model_info.truncation_policy.into(), max_output_tokens: request.max_output_tokens, process_id: response_process_id, exit_code, @@ -1030,7 +1030,7 @@ impl UnifiedExecProcessManager { context: &UnifiedExecContext, ) -> Result<(UnifiedExecProcess, Option), UnifiedExecError> { let local_policy_env = create_env( - &context.turn.shell_environment_policy, + &context.turn.config.permissions.shell_environment_policy, /*thread_id*/ None, ); let mut env = local_policy_env.clone(); @@ -1040,7 +1040,9 @@ impl UnifiedExecProcessManager { ); let env = apply_unified_exec_env(env); let exec_server_env_config = ExecServerEnvConfig { - policy: exec_env_policy_from_shell_policy(&context.turn.shell_environment_policy), + policy: exec_env_policy_from_shell_policy( + &context.turn.config.permissions.shell_environment_policy, + ), local_policy_env, }; let mut orchestrator = ToolOrchestrator::new(); @@ -1072,7 +1074,13 @@ impl UnifiedExecProcessManager { turn_environment: request.turn_environment.clone(), env, exec_server_env_config: Some(exec_server_env_config), - explicit_env_overrides: context.turn.shell_environment_policy.r#set.clone(), + explicit_env_overrides: context + .turn + .config + .permissions + .shell_environment_policy + .r#set + .clone(), network: request.network.clone(), tty: request.tty, sandbox_permissions: request.sandbox_permissions, diff --git a/codex-rs/core/src/user_shell_command.rs b/codex-rs/core/src/user_shell_command.rs index cf034faca0..e64cf64cdf 100644 --- a/codex-rs/core/src/user_shell_command.rs +++ b/codex-rs/core/src/user_shell_command.rs @@ -11,7 +11,10 @@ fn user_shell_command_fragment( exec_output: &ExecToolCallOutput, turn_context: &TurnContext, ) -> UserShellCommand { - let output = format_exec_output_str(exec_output, turn_context.truncation_policy); + let output = format_exec_output_str( + exec_output, + turn_context.model_info.truncation_policy.into(), + ); UserShellCommand::new(command, exec_output.exit_code, exec_output.duration, output) } From 13151988537de2cb930183576710f9b59326aabc Mon Sep 17 00:00:00 2001 From: Won Park Date: Tue, 16 Jun 2026 23:03:00 -0700 Subject: [PATCH 24/70] [codex] Persist built-in image results reported as generating (#28656) ## Why #27920 stopped persisting image-generation items unless their status was `completed`, preventing failed standalone extension items with empty results from being saved. Built-in image generation can instead emit a terminal `response.output_item.done` containing a complete base64 PNG while the item status remains `generating`. In that case, app-server emits no `savedPath`, so Codex Apps can render the inline image but cannot expose a file artifact. ## What changed - Persist image-generation items whenever `result` contains image data. Failed terminal items still have empty results and remain unpersisted. - Update the existing built-in image-generation integration test to cover a terminal `generating` item and verify both `saved_path` and the written PNG bytes. ## Validation - Confirmed with a raw built-in websocket trace: the image progressed through `in_progress`, `generating`, and `partial_image`, then emitted one `response.output_item.done` with `status: "generating"` and a complete PNG result. - `just test -p codex-core builtin_image_generation_call_persisted` is currently blocked before test execution by a pre-existing compile error in `thread-store/src/thread_metadata_sync.rs:171`. --- codex-rs/core/src/stream_events_utils.rs | 2 +- codex-rs/core/tests/suite/items.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 265d7b450f..f3e3bbc046 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -579,7 +579,7 @@ pub(crate) async fn finalize_turn_item( } } if let TurnItem::ImageGeneration(image_item) = &mut *turn_item - && image_item.status == "completed" + && !image_item.result.is_empty() { persist_image_generation_item(sess, turn_context, image_item).await; } diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index a39a2b5e05..515d496b8b 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -348,7 +348,7 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { +async fn builtin_image_generation_call_persisted() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -369,7 +369,7 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { let first_response = sse(vec![ ev_response_created("resp-1"), - ev_image_generation_call(call_id, "completed", "A tiny blue square", "Zm9v"), + ev_image_generation_call(call_id, "generating", "A tiny blue square", "Zm9v"), ev_completed("resp-1"), ]); mount_sse_once(&server, first_response).await; @@ -422,7 +422,7 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { assert_eq!(completed.0.id, call_id); assert!(completed.1 > 0); assert_eq!(end.call_id, call_id); - assert_eq!(end.status, "completed"); + assert_eq!(end.status, "generating"); assert_eq!(end.revised_prompt, Some("A tiny blue square".to_string())); assert_eq!(end.result, "Zm9v"); assert_eq!( From cf17e1bc209813140e69193bcda2172ba4fdbc58 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 17 Jun 2026 09:20:39 +0100 Subject: [PATCH 25/70] Resume exec-server sessions after disconnect (#28512) Supersedes #28288 (closed). ## Why A short WebSocket interruption currently ends every client-side process handle, even though exec-server keeps the server session and its processes alive for a short time. This is especially visible for executor-backed stdio MCP servers: a temporary connection loss becomes a permanent `Transport closed` error. The server already has the information needed to resume the session, but the client opens a fresh session instead of using it. This change reconnects below the process and MCP layers. Existing process handles stay valid, missed output is recovered, and the same server-side processes continue running. ## State machine One logical `ExecServerClient` stays alive while its underlying RPC connection changes generations. ```text transport closes +------------------------------------------------+ | v +-------------+ +-------------+ | Connected | | Recovering | +-------------+ +-------------+ ^ | | session resumed, processes caught up | retryable error +------------------------------------------------+ loops until deadline | | deadline or permanent error v +-------------+ | Failed | +-------------+ ``` ### `Connected` - New RPC calls use the current connection. - Process notifications are published in sequence order. - A disconnect only starts recovery if it came from the current connection generation. Late events from older generations cannot replace the active connection. ### `Recovering` - New calls wait instead of choosing a half-connected RPC client. - Existing process handles, wake subscriptions, and event subscriptions stay open. - Streaming HTTP response bodies fail immediately because their byte streams cannot be resumed safely. - Recovery first waits for process starts that were already in flight. A start whose result became ambiguous is cleaned up after reconnection instead of being silently adopted. - The client reconnects with the learned `session_id`. The server may briefly report that the old connection is still attached, so that error is retried until the detach finishes. - The notification consumer starts before the resume handshake completes. This prevents a busy process from filling the notification queue and blocking the initialize response. - Before installing the new connection, the client catches up every recoverable process with `process/read`. ### `Failed` - Recovery stops after 25 seconds or after a permanent error. - Waiting calls are released with one stable disconnect error. - Existing process sessions receive a terminal failure instead of waiting forever. ## Recovering process events Output, exit, and close events share one sequence. During normal operation, the client buffers early events until every lower sequence has been published. After reconnection, the client reads each process starting after its last published sequence: 1. Retained output chunks are inserted by sequence number. 2. Exit and close state are reconstructed in their sequence positions. 3. Events already received as live notifications are ignored as duplicates. 4. Newly contiguous events are published in order. 5. If the server no longer retains enough output to fill a sequence gap, only that process is terminated and failed. The recovered connection remains usable for other processes. The server reports its full next event sequence for unbounded reads, including exit and close events. Closed processes remain readable for the same 30-second window used to retain detached sessions. ## Other details - Detached server sessions are retained for 30 seconds, leaving margin around the client's 25-second recovery deadline. - Session attach and detach update the active notification sender under the same attachment lock, so an old connection cannot clear a newly attached sender. - A dedicated error code distinguishes the temporary "session is still attached" race from permanent initialization errors. - Process starts are identity-checked on both client and server. Cleanup from an older start cannot remove a newer process that reused the same ID. - Mutating requests that were already in flight when the transport closed are not replayed, because the client cannot know whether the server applied them. Requests started after recovery is known wait for the replacement connection. - We assume the server/client version stays in sync (on the before/after this PR) ## User impact Long-running commands and stdio MCP servers can survive a temporary exec-server WebSocket interruption without changing process IDs or losing output produced during the outage. --- codex-rs/exec-server/src/client.rs | 666 +++++++++++------- .../exec-server/src/client/rpc_http_client.rs | 6 +- codex-rs/exec-server/src/client_recovery.rs | 516 ++++++++++++++ codex-rs/exec-server/src/client_transport.rs | 117 ++- codex-rs/exec-server/src/local_process.rs | 51 +- codex-rs/exec-server/src/remote_process.rs | 7 +- codex-rs/exec-server/src/rpc.rs | 45 +- .../exec-server/src/server/handler/tests.rs | 2 +- .../src/server/session_registry.rs | 10 +- .../exec-server/tests/common/exec_server.rs | 127 ++++ codex-rs/exec-server/tests/exec_process.rs | 183 +++-- 11 files changed, 1366 insertions(+), 364 deletions(-) create mode 100644 codex-rs/exec-server/src/client_recovery.rs diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index f6c531845c..ae77dc7c4f 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -3,7 +3,9 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex as StdMutex; use std::sync::OnceLock; +use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; use std::time::Duration; use arc_swap::ArcSwap; @@ -25,6 +27,7 @@ use crate::client_api::ExecServerTransportParams; use crate::client_api::HttpClient; use crate::client_api::RemoteExecServerConnectArgs; use crate::client_api::StdioExecServerConnectArgs; +use crate::client_transport::ExecServerReconnectStrategy; use crate::connection::JsonRpcConnection; use crate::process::ExecProcessEvent; use crate::process::ExecProcessEventLog; @@ -95,9 +98,10 @@ use crate::protocol::WriteParams; use crate::protocol::WriteResponse; use crate::rpc::RpcCallError; use crate::rpc::RpcClient; -use crate::rpc::RpcClientEvent; pub(crate) mod http_client; +#[path = "client_recovery.rs"] +mod recovery; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10); @@ -150,16 +154,19 @@ pub(crate) struct SessionState { wake_tx: watch::Sender, events: ExecProcessEventLog, ordered_events: StdMutex, - failure: Mutex>, + recoverable: AtomicBool, } #[derive(Default)] struct OrderedSessionEvents { last_published_seq: u64, + exit_published: bool, + closed_published: bool, // Server-side output, exit, and closed notifications are emitted by // different tasks and can reach the client out of order. Keep future events // here until all lower sequence numbers have been published. pending: BTreeMap, + failure: Option, } #[derive(Clone)] @@ -170,7 +177,8 @@ pub(crate) struct Session { } struct Inner { - client: RpcClient, + connection: StdMutex, + connection_changed: watch::Sender<()>, // The remote transport delivers one shared notification stream for every // process on the connection. Keep a local process_id -> session registry so // we can turn those connection-global notifications into process wakeups @@ -179,11 +187,7 @@ struct Inner { // ArcSwap makes reads cheap on the hot notification path, but writes still // need serialization so concurrent register/remove operations do not // overwrite each other's copy-on-write updates. - sessions_write_lock: Mutex<()>, - // Once the transport closes, every environment operation should fail quickly - // with the same canonical message. This client never reconnects, so the - // latch only moves from unset to set once. - disconnected: OnceLock, + sessions_write_lock: StdMutex<()>, // Streaming HTTP responses are keyed by a client-generated request id // because they share the same connection-global notification channel as // process output. Keep the routing table local to the client so higher @@ -192,14 +196,19 @@ struct Inner { http_body_stream_failures: ArcSwap>, http_body_streams_write_lock: Mutex<()>, http_body_stream_next_id: AtomicU64, - session_id: std::sync::RwLock>, - reader_task: tokio::task::JoinHandle<()>, + session_id: OnceLock, + reconnect_strategy: Option, } -impl Drop for Inner { - fn drop(&mut self) { - self.reader_task.abort(); - } +struct ConnectionState { + status: ConnectionStatus, + active_process_starts: usize, +} + +enum ConnectionStatus { + Connected(Arc), + Recovering, + Failed(String), } #[derive(Clone)] @@ -207,6 +216,16 @@ pub struct ExecServerClient { inner: Arc, } +struct ActiveProcessStart { + inner: Arc, +} + +impl Drop for ActiveProcessStart { + fn drop(&mut self) { + self.inner.finish_process_start(); + } +} + #[derive(Clone)] pub(crate) struct LazyRemoteExecServerClient { transport_params: ExecServerTransportParams, @@ -339,6 +358,15 @@ impl ExecServerClient { pub async fn initialize( &self, options: ExecServerClientConnectOptions, + ) -> Result { + let rpc_client = self.inner.rpc_client().await?; + self.initialize_rpc(&rpc_client, options).await + } + + async fn initialize_rpc( + &self, + rpc_client: &RpcClient, + options: ExecServerClientConnectOptions, ) -> Result { let ExecServerClientConnectOptions { client_name, @@ -347,9 +375,7 @@ impl ExecServerClient { } = options; timeout(initialize_timeout, async { - let response: InitializeResponse = self - .inner - .client + let response: InitializeResponse = rpc_client .call( INITIALIZE_METHOD, &InitializeParams { @@ -358,15 +384,19 @@ impl ExecServerClient { }, ) .await?; - { - let mut session_id = self - .inner - .session_id - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner); - *session_id = Some(response.session_id.clone()); + let session_id = self + .inner + .session_id + .get_or_init(|| response.session_id.clone()); + if session_id != &response.session_id { + return Err(ExecServerError::Protocol(format!( + "exec-server initialized an unexpected session {}", + response.session_id + ))); } - self.notify_initialized().await?; + rpc_client + .notify(INITIALIZED_METHOD, &serde_json::json!({})) + .await?; Ok(response) }) .await @@ -503,14 +533,72 @@ impl ExecServerClient { self.call(FS_COPY_METHOD, ¶ms).await } + pub(crate) async fn start_process( + &self, + params: ExecParams, + ) -> Result { + loop { + let rpc_client = self.inner.rpc_client().await?; + if !self.inner.begin_process_start(&rpc_client) { + continue; + } + + let process_id = params.process_id.clone(); + let state = Arc::new(SessionState::new(/*recoverable*/ false)); + if let Err(error) = self.inner.insert_session(&process_id, Arc::clone(&state)) { + self.inner.finish_process_start(); + return Err(error); + } + let active_start = ActiveProcessStart { + inner: Arc::clone(&self.inner), + }; + let client = self.clone(); + let (result_tx, result_rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + let _active_start = active_start; + match client + .call_rpc::<_, ExecResponse>(&rpc_client, EXEC_METHOD, ¶ms) + .await + { + Ok(_) => { + state.recoverable.store(true, Ordering::Release); + let session = Session { + client: client.clone(), + process_id: process_id.clone(), + state: Arc::clone(&state), + }; + if result_tx.send(Ok(session)).is_err() { + state.recoverable.store(false, Ordering::Release); + tokio::spawn(async move { + cleanup_process_start(&client, &process_id, &state).await; + }); + } + } + Err(error) => { + if is_transport_closed_error(&error) { + tokio::spawn(async move { + cleanup_process_start(&client, &process_id, &state).await; + }); + } else { + client.inner.remove_session_if(&process_id, &state); + } + let _ = result_tx.send(Err(error)); + } + } + }); + return result_rx.await.map_err(|_| { + ExecServerError::Protocol("process start task stopped unexpectedly".to_string()) + })?; + } + } + + #[cfg(test)] pub(crate) async fn register_session( &self, process_id: &ProcessId, ) -> Result { - let state = Arc::new(SessionState::new()); - self.inner - .insert_session(process_id, Arc::clone(&state)) - .await?; + let state = Arc::new(SessionState::new(/*recoverable*/ true)); + self.inner.insert_session(process_id, Arc::clone(&state))?; Ok(Session { client: self.clone(), process_id: process_id.clone(), @@ -518,84 +606,52 @@ impl ExecServerClient { }) } - pub(crate) async fn unregister_session(&self, process_id: &ProcessId) { - self.inner.remove_session(process_id).await; - } - pub fn session_id(&self) -> Option { - self.inner - .session_id - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() + self.inner.session_id.get().cloned() } fn is_disconnected(&self) -> bool { - self.inner.disconnected.get().is_some() || self.inner.client.is_disconnected() + self.inner.is_failed() } pub(crate) async fn connect( connection: JsonRpcConnection, options: ExecServerClientConnectOptions, ) -> Result { - let (rpc_client, mut events_rx) = RpcClient::new(connection); - let inner = Arc::new_cyclic(|weak| { - let weak = weak.clone(); - let reader_task = tokio::spawn(async move { - while let Some(event) = events_rx.recv().await { - match event { - RpcClientEvent::Notification(notification) => { - if let Some(inner) = weak.upgrade() - && let Err(err) = - handle_server_notification(&inner, notification).await - { - let message = record_disconnected( - &inner, - format!("exec-server notification handling failed: {err}"), - ); - fail_all_in_flight_work(&inner, message).await; - return; - } - } - RpcClientEvent::Disconnected { reason } => { - if let Some(inner) = weak.upgrade() { - let message = record_disconnected( - &inner, - disconnected_message(reason.as_deref()), - ); - fail_all_in_flight_work(&inner, message).await; - } - return; - } - } - } - }); - - Inner { - client: rpc_client, - sessions: ArcSwap::from_pointee(HashMap::new()), - sessions_write_lock: Mutex::new(()), - disconnected: OnceLock::new(), - http_body_streams: ArcSwap::from_pointee(HashMap::new()), - http_body_stream_failures: ArcSwap::from_pointee(HashMap::new()), - http_body_streams_write_lock: Mutex::new(()), - http_body_stream_next_id: AtomicU64::new(1), - session_id: std::sync::RwLock::new(None), - reader_task, - } - }); - - let client = Self { inner }; - client.initialize(options).await?; - Ok(client) + Self::connect_with_recovery(connection, options, /*reconnect_strategy*/ None).await } - async fn notify_initialized(&self) -> Result<(), ExecServerError> { - self.inner - .client - .notify(INITIALIZED_METHOD, &serde_json::json!({})) - .await - .map_err(ExecServerError::Json) + pub(crate) async fn connect_with_recovery( + connection: JsonRpcConnection, + options: ExecServerClientConnectOptions, + reconnect_strategy: Option, + ) -> Result { + let (rpc_client, events_rx) = RpcClient::new(connection); + let rpc_client = Arc::new(rpc_client); + let session_id = OnceLock::new(); + let (connection_changed, _connection_changed_rx) = watch::channel(()); + let inner = Arc::new(Inner { + connection: StdMutex::new(ConnectionState { + status: ConnectionStatus::Connected(Arc::clone(&rpc_client)), + active_process_starts: 0, + }), + connection_changed, + sessions: ArcSwap::from_pointee(HashMap::new()), + sessions_write_lock: StdMutex::new(()), + http_body_streams: ArcSwap::from_pointee(HashMap::new()), + http_body_stream_failures: ArcSwap::from_pointee(HashMap::new()), + http_body_streams_write_lock: Mutex::new(()), + http_body_stream_next_id: AtomicU64::new(1), + session_id, + reconnect_strategy, + }); + let client = Self { inner }; + // An explicit resume can redirect notifications from running processes + // before initialize returns. Drain them immediately so a burst cannot + // fill the bounded event channel and block the initialize response. + client.spawn_rpc_reader(&rpc_client, events_rx); + client.initialize_rpc(&rpc_client, options).await?; + Ok(client) } async fn call(&self, method: &str, params: &P) -> Result @@ -603,24 +659,28 @@ impl ExecServerClient { P: serde::Serialize, T: serde::de::DeserializeOwned, { - // Reject new work before allocating a JSON-RPC request id. MCP tool - // calls, process writes, and fs operations all pass through here, so - // this is the shared low-level failure path after environment disconnect. - if let Some(error) = self.inner.disconnected_error() { - return Err(error); - } + let rpc_client = self.inner.rpc_client().await?; + self.call_rpc(&rpc_client, method, params).await + } - match self.inner.client.call(method, params).await { + async fn call_rpc( + &self, + rpc_client: &Arc, + method: &str, + params: &P, + ) -> Result + where + P: serde::Serialize, + T: serde::de::DeserializeOwned, + { + match rpc_client.call(method, params).await { Ok(response) => Ok(response), Err(error) => { let error = ExecServerError::from(error); if is_transport_closed_error(&error) { - // A call can race with disconnect after the preflight - // check. Only the reader task drains sessions so queued - // process notifications stay ordered before disconnect. - let message = disconnected_message(/*reason*/ None); - let message = record_disconnected(&self.inner, message); - Err(ExecServerError::Disconnected(message)) + Err(ExecServerError::Disconnected(disconnected_message( + /*reason*/ None, + ))) } else { Err(error) } @@ -629,6 +689,23 @@ impl ExecServerClient { } } +async fn cleanup_process_start( + client: &ExecServerClient, + process_id: &ProcessId, + state: &Arc, +) { + loop { + match client.terminate(process_id).await { + Ok(_) => break, + Err(error) if is_transport_closed_error(&error) && !client.inner.is_failed() => { + continue; + } + Err(_) => break, + } + } + client.inner.remove_session_if(process_id, state); +} + impl From for ExecServerError { fn from(value: RpcCallError) -> Self { match value { @@ -643,7 +720,7 @@ impl From for ExecServerError { } impl SessionState { - fn new() -> Self { + fn new(recoverable: bool) -> Self { let (wake_tx, _wake_rx) = watch::channel(0); Self { wake_tx, @@ -652,7 +729,7 @@ impl SessionState { PROCESS_EVENT_RETAINED_BYTES, ), ordered_events: StdMutex::new(OrderedSessionEvents::default()), - failure: Mutex::new(None), + recoverable: AtomicBool::new(recoverable), } } @@ -665,8 +742,8 @@ impl SessionState { } fn note_change(&self, seq: u64) { - let next = (*self.wake_tx.borrow()).max(seq); - let _ = self.wake_tx.send(next); + self.wake_tx + .send_modify(|current| *current = (*current).max(seq)); } /// Publishes a process event only when all earlier sequenced events have @@ -682,55 +759,61 @@ impl SessionState { return false; }; - let mut ready = Vec::new(); + let mut ordered_events = self + .ordered_events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // We have already delivered this sequence number or moved past it, + // so accepting it again would duplicate output or lifecycle events. + if ordered_events.failure.is_some() + || ordered_events.closed_published + || seq <= ordered_events.last_published_seq { - let mut ordered_events = self - .ordered_events - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - // We have already delivered this sequence number or moved past it, - // so accepting it again would duplicate output or lifecycle events. - if seq <= ordered_events.last_published_seq { - return false; - } - - ordered_events.pending.entry(seq).or_insert(event); - loop { - let next_seq = ordered_events.last_published_seq + 1; - let Some(event) = ordered_events.pending.remove(&next_seq) else { - break; - }; - ordered_events.last_published_seq += 1; - ready.push(event); - } + return false; } + ordered_events.pending.entry(seq).or_insert(event); + self.publish_ready(&mut ordered_events) + } + + fn publish_ready(&self, ordered_events: &mut OrderedSessionEvents) -> bool { let mut published_closed = false; - for event in ready { - published_closed |= matches!(&event, ExecProcessEvent::Closed { .. }); + loop { + let next_seq = ordered_events.last_published_seq.saturating_add(1); + let Some(event) = ordered_events.pending.remove(&next_seq) else { + break; + }; + ordered_events.last_published_seq = next_seq; + ordered_events.exit_published |= matches!(&event, ExecProcessEvent::Exited { .. }); + let is_closed = matches!(&event, ExecProcessEvent::Closed { .. }); + ordered_events.closed_published |= is_closed; + published_closed |= is_closed; self.events.publish(event); } published_closed } - async fn set_failure(&self, message: String) { - let mut failure = self.failure.lock().await; - let should_publish = failure.is_none(); - if should_publish { - *failure = Some(message.clone()); - } - drop(failure); - let next = (*self.wake_tx.borrow()).saturating_add(1); - let _ = self.wake_tx.send(next); - if should_publish { - let _ = self.publish_ordered_event(ExecProcessEvent::Failed(message)); + fn set_failure(&self, message: String) { + let mut ordered_events = self + .ordered_events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if ordered_events.failure.is_some() || ordered_events.closed_published { + return; } + ordered_events.failure = Some(message.clone()); + ordered_events.pending.clear(); + self.events.publish(ExecProcessEvent::Failed(message)); + drop(ordered_events); + self.wake_tx + .send_modify(|current| *current = current.saturating_add(1)); } - async fn failed_response(&self) -> Option { - self.failure + fn failed_response(&self) -> Option { + self.ordered_events .lock() - .await + .unwrap_or_else(std::sync::PoisonError::into_inner) + .failure .clone() .map(|message| self.synthesized_failure(message)) } @@ -767,27 +850,37 @@ impl Session { max_bytes: Option, wait_ms: Option, ) -> Result { - if let Some(response) = self.state.failed_response().await { - return Ok(response); - } - - match self - .client - .read(ReadParams { - process_id: self.process_id.clone(), - after_seq, - max_bytes, - wait_ms, - }) - .await - { - Ok(response) => Ok(response), - Err(err) if is_transport_closed_error(&err) => { - let message = disconnected_message(/*reason*/ None); - self.state.set_failure(message.clone()).await; - Ok(self.state.synthesized_failure(message)) + loop { + if let Some(response) = self.state.failed_response() { + return Ok(response); + } + + match self + .client + .read(ReadParams { + process_id: self.process_id.clone(), + after_seq, + max_bytes, + wait_ms, + }) + .await + { + Ok(response) => return Ok(response), + Err(error) + if is_transport_closed_error(&error) && !self.client.inner.is_failed() => + { + continue; + } + Err(error) if is_transport_closed_error(&error) => { + if let Some(response) = self.state.failed_response() { + return Ok(response); + } + let message = error.to_string(); + self.state.set_failure(message.clone()); + return Ok(self.state.synthesized_failure(message)); + } + Err(error) => return Err(error), } - Err(err) => Err(err), } } @@ -805,40 +898,31 @@ impl Session { } pub(crate) async fn unregister(&self) { - self.client.unregister_session(&self.process_id).await; + self.client + .inner + .remove_session_if(&self.process_id, &self.state); } } impl Inner { - fn disconnected_error(&self) -> Option { - self.disconnected - .get() - .cloned() - .map(ExecServerError::Disconnected) - } - - fn set_disconnected(&self, message: String) -> Option { - match self.disconnected.set(message.clone()) { - Ok(()) => Some(message), - Err(_) => None, - } - } - fn get_session(&self, process_id: &ProcessId) -> Option> { self.sessions.load().get(process_id).cloned() } - async fn insert_session( + fn insert_session( &self, process_id: &ProcessId, session: Arc, ) -> Result<(), ExecServerError> { - let _sessions_write_guard = self.sessions_write_lock.lock().await; + let _sessions_write_guard = self + .sessions_write_lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); // Do not register a process session that can never receive environment // notifications. Without this check, remote MCP startup could create a // dead session and wait for process output that will never arrive. - if let Some(error) = self.disconnected_error() { - return Err(error); + if let Some(message) = self.failure_message() { + return Err(ExecServerError::Disconnected(message)); } let sessions = self.sessions.load(); if sessions.contains_key(process_id) { @@ -852,19 +936,28 @@ impl Inner { Ok(()) } - async fn remove_session(&self, process_id: &ProcessId) -> Option> { - let _sessions_write_guard = self.sessions_write_lock.lock().await; + fn remove_session_if(&self, process_id: &ProcessId, expected: &Arc) { + let _sessions_write_guard = self + .sessions_write_lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let sessions = self.sessions.load(); - let session = sessions.get(process_id).cloned(); - session.as_ref()?; + if !sessions + .get(process_id) + .is_some_and(|session| Arc::ptr_eq(session, expected)) + { + return; + } let mut next_sessions = sessions.as_ref().clone(); next_sessions.remove(process_id); self.sessions.store(Arc::new(next_sessions)); - session } - async fn take_all_sessions(&self) -> HashMap> { - let _sessions_write_guard = self.sessions_write_lock.lock().await; + fn take_all_sessions(&self) -> HashMap> { + let _sessions_write_guard = self + .sessions_write_lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let sessions = self.sessions.load(); let drained_sessions = sessions.as_ref().clone(); self.sessions.store(Arc::new(HashMap::new())); @@ -892,31 +985,20 @@ fn is_transport_closed_error(error: &ExecServerError) -> bool { ) } -fn record_disconnected(inner: &Arc, message: String) -> String { - // The first observer records the canonical disconnect reason. Session - // draining stays with the reader task so it can preserve notification - // ordering before publishing the terminal failure. - if let Some(message) = inner.set_disconnected(message.clone()) { - message - } else { - inner.disconnected.get().cloned().unwrap_or(message) - } -} - -async fn fail_all_sessions(inner: &Arc, message: String) { - let sessions = inner.take_all_sessions().await; +fn fail_all_sessions(inner: &Arc, message: String) { + let sessions = inner.take_all_sessions(); for (_, session) in sessions { // Sessions synthesize a closed read response and emit a pushed Failed // event. That covers both polling consumers and streaming consumers // such as environment-backed MCP stdio. - session.set_failure(message.clone()).await; + session.set_failure(message.clone()); } } /// Fails all in-flight work that depends on the shared JSON-RPC transport. async fn fail_all_in_flight_work(inner: &Arc, message: String) { - fail_all_sessions(inner, message.clone()).await; + fail_all_sessions(inner, message.clone()); inner.fail_all_http_body_streams(message).await; } @@ -937,7 +1019,7 @@ async fn handle_server_notification( chunk: params.chunk, })); if published_closed { - inner.remove_session(¶ms.process_id).await; + inner.remove_session_if(¶ms.process_id, &session); } } } @@ -951,7 +1033,7 @@ async fn handle_server_notification( exit_code: params.exit_code, }); if published_closed { - inner.remove_session(¶ms.process_id).await; + inner.remove_session_if(¶ms.process_id, &session); } } } @@ -966,7 +1048,7 @@ async fn handle_server_notification( let published_closed = session.publish_ordered_event(ExecProcessEvent::Closed { seq: params.seq }); if published_closed { - inner.remove_session(¶ms.process_id).await; + inner.remove_session_if(¶ms.process_id, &session); } } } @@ -1020,6 +1102,7 @@ mod tests { #[cfg(not(windows))] use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT; use crate::client_api::ExecServerTransportParams; + use crate::client_api::RemoteExecServerConnectArgs; use crate::client_api::StdioExecServerCommand; use crate::client_api::StdioExecServerConnectArgs; use crate::connection::JsonRpcConnection; @@ -1136,19 +1219,6 @@ mod tests { } } - async fn wait_for_disconnect(client: &ExecServerClient) { - timeout(Duration::from_secs(1), async { - loop { - if client.is_disconnected() { - return; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("client should observe disconnect"); - } - #[cfg(not(windows))] #[tokio::test] async fn connect_stdio_command_initializes_json_rpc_client() { @@ -1567,7 +1637,7 @@ mod tests { } #[tokio::test] - async fn remote_websocket_client_replaces_disconnected_client_with_fresh_session() { + async fn remote_websocket_client_resumes_session() { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("listener should bind"); @@ -1575,28 +1645,27 @@ mod tests { "ws://{}", listener.local_addr().expect("listener should have address") ); - let server = tokio::spawn({ - async move { - let mut first = accept_websocket(&listener).await; - complete_websocket_initialize( - &mut first, - "session-1", - /*expected_resume_session_id*/ None, - ) - .await; - first - .close(None) - .await - .expect("first websocket should close"); + let (resumed_tx, resumed_rx) = oneshot::channel(); + let (finish_tx, finish_rx) = oneshot::channel(); + let server = tokio::spawn(async move { + let mut first = accept_websocket(&listener).await; + complete_websocket_initialize( + &mut first, + "session-1", + /*expected_resume_session_id*/ None, + ) + .await; + first.close(None).await.expect("websocket should close"); - let mut second = accept_websocket(&listener).await; - complete_websocket_initialize( - &mut second, - "session-2", - /*expected_resume_session_id*/ None, - ) - .await; - } + let mut resumed = accept_websocket(&listener).await; + complete_websocket_initialize( + &mut resumed, + "session-1", + /*expected_resume_session_id*/ Some("session-1"), + ) + .await; + resumed_tx.send(()).expect("resume should signal"); + finish_rx.await.expect("test should finish"); }); let client = LazyRemoteExecServerClient::new(ExecServerTransportParams::WebSocketUrl { @@ -1604,16 +1673,103 @@ mod tests { connect_timeout: Duration::from_secs(1), initialize_timeout: Duration::from_secs(1), }); - let first = client.get().await.expect("first client should connect"); - wait_for_disconnect(&first).await; + let stable_client = client.get().await.expect("client should connect"); + timeout(Duration::from_secs(1), resumed_rx) + .await + .expect("session resume should not time out") + .expect("session resume should signal"); + let reused_client = client.get().await.expect("client should stay connected"); + assert_eq!(stable_client.session_id().as_deref(), Some("session-1")); + assert!(Arc::ptr_eq(&stable_client.inner, &reused_client.inner)); + finish_tx.send(()).expect("test should finish"); + server.await.expect("server task should finish"); + } - let (replacement_a, replacement_b) = tokio::join!(client.get(), client.get()); - let replacement_a = replacement_a.expect("first replacement should connect"); - let replacement_b = replacement_b.expect("second replacement should reuse client"); - assert_eq!(replacement_a.session_id().as_deref(), Some("session-2")); - assert_eq!(replacement_b.session_id().as_deref(), Some("session-2")); - assert!(Arc::ptr_eq(&replacement_a.inner, &replacement_b.inner)); + #[tokio::test] + async fn explicit_resume_drains_notifications_before_initialize_response() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let websocket_url = format!( + "ws://{}", + listener.local_addr().expect("listener should have address") + ); + let (initialized_tx, initialized_rx) = oneshot::channel(); + let (finish_tx, finish_rx) = oneshot::channel(); + let server = tokio::spawn(async move { + let mut websocket = accept_websocket(&listener).await; + let initialize = read_jsonrpc_websocket(&mut websocket).await; + let request = match initialize { + JSONRPCMessage::Request(request) if request.method == INITIALIZE_METHOD => request, + other => panic!("expected initialize request, got {other:?}"), + }; + let params: crate::protocol::InitializeParams = + serde_json::from_value(request.params.expect("initialize params should exist")) + .expect("initialize params should deserialize"); + assert_eq!(params.resume_session_id.as_deref(), Some("session-1")); + for seq in 1..=256 { + write_jsonrpc_websocket( + &mut websocket, + JSONRPCMessage::Notification(JSONRPCNotification { + method: EXEC_OUTPUT_DELTA_METHOD.to_string(), + params: Some( + serde_json::to_value(ExecOutputDeltaNotification { + process_id: ProcessId::from("busy-process"), + seq, + stream: ExecOutputStream::Stdout, + chunk: b"output".to_vec().into(), + }) + .expect("output notification should serialize"), + ), + }), + ) + .await; + } + write_jsonrpc_websocket( + &mut websocket, + JSONRPCMessage::Response(JSONRPCResponse { + id: request.id, + result: serde_json::to_value(InitializeResponse { + session_id: "session-1".to_string(), + }) + .expect("initialize response should serialize"), + }), + ) + .await; + + let initialized = read_jsonrpc_websocket(&mut websocket).await; + match initialized { + JSONRPCMessage::Notification(notification) + if notification.method == INITIALIZED_METHOD => {} + other => panic!("expected initialized notification, got {other:?}"), + } + initialized_tx + .send(()) + .expect("initialized notification should signal"); + finish_rx.await.expect("test should finish"); + }); + + let client = timeout( + Duration::from_secs(1), + ExecServerClient::connect_websocket(RemoteExecServerConnectArgs { + websocket_url, + client_name: "test-client".to_string(), + connect_timeout: Duration::from_secs(1), + initialize_timeout: Duration::from_secs(1), + resume_session_id: Some("session-1".to_string()), + }), + ) + .await + .expect("explicit resume should not time out") + .expect("explicit resume should connect"); + assert_eq!(client.session_id().as_deref(), Some("session-1")); + + timeout(Duration::from_secs(1), initialized_rx) + .await + .expect("initialized notification should not time out") + .expect("initialized notification should signal"); + finish_tx.send(()).expect("test should finish"); server.await.expect("server task should finish"); } diff --git a/codex-rs/exec-server/src/client/rpc_http_client.rs b/codex-rs/exec-server/src/client/rpc_http_client.rs index d2ce842ca9..d243a8d42f 100644 --- a/codex-rs/exec-server/src/client/rpc_http_client.rs +++ b/codex-rs/exec-server/src/client/rpc_http_client.rs @@ -42,6 +42,7 @@ impl ExecServerClient { &self, mut params: HttpRequestParams, ) -> Result<(HttpRequestResponse, HttpResponseBodyStream), ExecServerError> { + let rpc_client = self.inner.rpc_client().await?; params.stream_response = true; let request_id = self.inner.next_http_body_stream_request_id(); params.request_id = request_id.clone(); @@ -51,7 +52,10 @@ impl ExecServerClient { .await?; let mut registration = HttpBodyStreamRegistration::new(Arc::clone(&self.inner), request_id.clone()); - let response = match self.call(HTTP_REQUEST_METHOD, ¶ms).await { + let response = match self + .call_rpc(&rpc_client, HTTP_REQUEST_METHOD, ¶ms) + .await + { Ok(response) => response, Err(error) => { self.inner.remove_http_body_stream(&request_id).await; diff --git a/codex-rs/exec-server/src/client_recovery.rs b/codex-rs/exec-server/src/client_recovery.rs new file mode 100644 index 0000000000..9a2f3c29a7 --- /dev/null +++ b/codex-rs/exec-server/src/client_recovery.rs @@ -0,0 +1,516 @@ +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use tokio::sync::mpsc; +use tokio::time::Instant; +use tokio::time::sleep; +use tokio::time::timeout_at; + +use super::ConnectionStatus; +use super::ExecServerClient; +use super::ExecServerError; +use super::Inner; +use super::OrderedSessionEvents; +use super::SessionState; +use super::disconnected_message; +use super::fail_all_in_flight_work; +use super::handle_server_notification; +use super::is_transport_closed_error; +use crate::process::ExecProcessEvent; +use crate::protocol::EXEC_READ_METHOD; +use crate::protocol::EXEC_TERMINATE_METHOD; +use crate::protocol::ReadParams; +use crate::protocol::ReadResponse; +use crate::protocol::TerminateParams; +use crate::protocol::TerminateResponse; +use crate::rpc::RpcClient; +use crate::rpc::RpcClientEvent; +use crate::rpc::SESSION_ALREADY_ATTACHED_ERROR_CODE; + +#[cfg(test)] +const SESSION_RECOVERY_TIMEOUT: Duration = Duration::from_millis(500); +#[cfg(not(test))] +// Leave margin inside the server's 30-second retention windows because the +// client and server start their disconnect clocks independently. +const SESSION_RECOVERY_TIMEOUT: Duration = Duration::from_secs(25); +const SESSION_RECOVERY_RETRY_INTERVAL: Duration = Duration::from_millis(100); + +impl SessionState { + fn last_published_seq(&self) -> u64 { + self.ordered_events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .last_published_seq + } + + fn recover_events(&self, response: ReadResponse) -> Result { + let ReadResponse { + chunks, + next_seq, + exited, + exit_code, + closed, + failure, + } = response; + if let Some(message) = failure { + return Err(ExecServerError::Protocol(format!( + "process failed while recovering: {message}" + ))); + } + + let target_seq = next_seq.saturating_sub(1); + let published_closed = { + let mut ordered_events = self + .ordered_events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if ordered_events.failure.is_some() + || ordered_events.closed_published + || target_seq <= ordered_events.last_published_seq + { + return Ok(false); + } + for chunk in chunks { + if chunk.seq > ordered_events.last_published_seq { + ordered_events + .pending + .entry(chunk.seq) + .or_insert(ExecProcessEvent::Output(chunk)); + } + } + if closed { + match ordered_events.pending.get(&target_seq) { + Some(ExecProcessEvent::Closed { .. }) => {} + Some(_) => { + return Err(ExecServerError::Protocol(format!( + "process close sequence {target_seq} conflicts with recovered output" + ))); + } + None => { + ordered_events + .pending + .insert(target_seq, ExecProcessEvent::Closed { seq: target_seq }); + } + } + } + + let exit_known = ordered_events.exit_published + || ordered_events + .pending + .range(..=target_seq) + .any(|(_, event)| matches!(event, ExecProcessEvent::Exited { .. })); + let event_count = target_seq - ordered_events.last_published_seq; + let retained_count = ordered_events + .pending + .range(ordered_events.last_published_seq.saturating_add(1)..=target_seq) + .count() as u64; + let missing_count = event_count.saturating_sub(retained_count); + if exited && !exit_known { + if missing_count != 1 { + return Err(recovery_gap_error(target_seq)); + } + let seq = first_missing_seq(&ordered_events, target_seq); + let exit_code = exit_code.ok_or_else(|| { + ExecServerError::Protocol( + "recovering exited process did not include its exit code".to_string(), + ) + })?; + ordered_events + .pending + .insert(seq, ExecProcessEvent::Exited { seq, exit_code }); + } else if missing_count != 0 { + return Err(recovery_gap_error(target_seq)); + } + self.publish_ready(&mut ordered_events) + }; + + self.note_change(target_seq); + Ok(published_closed) + } +} + +fn first_missing_seq(events: &OrderedSessionEvents, target_seq: u64) -> u64 { + let mut expected = events.last_published_seq.saturating_add(1); + for seq in events + .pending + .range(expected..=target_seq) + .map(|(seq, _)| *seq) + { + if seq != expected { + break; + } + expected = expected.saturating_add(1); + } + expected +} + +fn recovery_gap_error(target_seq: u64) -> ExecServerError { + ExecServerError::Protocol(format!( + "process events are no longer retained while recovering through sequence {target_seq}" + )) +} + +impl Inner { + pub(super) async fn rpc_client(self: &Arc) -> Result, ExecServerError> { + let mut connection_changed = self.connection_changed.subscribe(); + loop { + if let Some(message) = self.failure_message() { + return Err(ExecServerError::Disconnected(message)); + } + + let rpc_client = { + let connection = self + .connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match &connection.status { + ConnectionStatus::Connected(rpc_client) => Some(Arc::clone(rpc_client)), + ConnectionStatus::Recovering | ConnectionStatus::Failed(_) => None, + } + }; + let Some(rpc_client) = rpc_client else { + let _ = connection_changed.changed().await; + continue; + }; + if !rpc_client.is_disconnected() { + return Ok(rpc_client); + } + + let _ = connection_changed.changed().await; + } + } + + pub(super) fn begin_process_start(&self, expected: &Arc) -> bool { + let mut connection = self + .connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let ConnectionStatus::Connected(current) = &connection.status else { + return false; + }; + if !Arc::ptr_eq(current, expected) || expected.is_disconnected() { + return false; + } + connection.active_process_starts += 1; + true + } + + pub(super) fn finish_process_start(&self) { + { + let mut connection = self + .connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if connection.active_process_starts == 0 { + tracing::error!("finished an exec-server process start that was not active"); + return; + } + connection.active_process_starts -= 1; + } + self.notify_connection_changed(); + } + + pub(super) fn is_failed(&self) -> bool { + self.failure_message().is_some() + } + + pub(super) fn failure_message(&self) -> Option { + let connection = self + .connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match &connection.status { + ConnectionStatus::Failed(message) => Some(message.clone()), + ConnectionStatus::Connected(_) | ConnectionStatus::Recovering => None, + } + } + + fn request_recovery( + self: &Arc, + failed_rpc_client: Arc, + disconnect_message: String, + ) { + let should_recover = { + let mut connection = self + .connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match &connection.status { + ConnectionStatus::Connected(current) + if Arc::ptr_eq(current, &failed_rpc_client) => + { + connection.status = ConnectionStatus::Recovering; + true + } + ConnectionStatus::Connected(_) + | ConnectionStatus::Recovering + | ConnectionStatus::Failed(_) => false, + } + }; + if !should_recover { + return; + } + + self.notify_connection_changed(); + let inner = Arc::clone(self); + tokio::spawn(async move { + inner.recover(disconnect_message).await; + }); + } + + async fn recover(self: &Arc, disconnect_message: String) { + let deadline = Instant::now() + SESSION_RECOVERY_TIMEOUT; + self.fail_all_http_body_streams(disconnect_message.clone()) + .await; + if timeout_at(deadline, self.wait_for_process_starts()) + .await + .is_err() + { + let message = format!( + "{disconnect_message}; failed to resume exec-server session: recovery timed out after {SESSION_RECOVERY_TIMEOUT:?}" + ); + self.fail(message).await; + return; + } + if self.reconnect_strategy.is_none() { + self.fail(disconnect_message).await; + return; + } + + let Some(session_id) = self.session_id.get().cloned() else { + let message = format!( + "{disconnect_message}; failed to resume exec-server session: missing session id" + ); + self.fail(message).await; + return; + }; + let last_error = loop { + match timeout_at(deadline, self.resume_once(&session_id)).await { + Ok(Ok(candidate)) if !candidate.is_disconnected() => { + if self.install_recovered_client(candidate) { + return; + } + } + Ok(Ok(_)) => {} + Ok(Err(error)) if !is_retryable_recovery_error(&error) => { + break error.to_string(); + } + Ok(Err(_)) => {} + Err(_) => { + break format!("recovery timed out after {SESSION_RECOVERY_TIMEOUT:?}"); + } + } + + let now = Instant::now(); + if now >= deadline { + break format!("recovery timed out after {SESSION_RECOVERY_TIMEOUT:?}"); + } + sleep(SESSION_RECOVERY_RETRY_INTERVAL.min(deadline - now)).await; + }; + + let message = + format!("{disconnect_message}; failed to resume exec-server session: {last_error}"); + self.fail(message).await; + } + + async fn wait_for_process_starts(&self) { + let mut connection_changed = self.connection_changed.subscribe(); + loop { + let starts_are_done = self + .connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .active_process_starts + == 0; + if starts_are_done { + return; + } + let _ = connection_changed.changed().await; + } + } + + fn install_recovered_client(&self, rpc_client: Arc) -> bool { + let installed = { + let mut connection = self + .connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !matches!(connection.status, ConnectionStatus::Recovering) + || rpc_client.is_disconnected() + { + false + } else { + connection.status = ConnectionStatus::Connected(rpc_client); + true + } + }; + if installed { + self.notify_connection_changed(); + } + installed + } + + fn notify_connection_changed(&self) { + self.connection_changed.send_replace(()); + } + + async fn resume_once( + self: &Arc, + session_id: &str, + ) -> Result, ExecServerError> { + let reconnect_strategy = self + .reconnect_strategy + .as_ref() + .ok_or_else(|| ExecServerError::Protocol("missing reconnect strategy".to_string()))?; + let (connection, options) = reconnect_strategy.resume(session_id).await?; + let (rpc_client, events_rx) = RpcClient::new(connection); + let rpc_client = Arc::new(rpc_client); + let client = ExecServerClient { + inner: Arc::clone(self), + }; + // Resuming a session redirects notifications from its running processes + // to this connection during initialize. Drain them immediately so a + // burst cannot fill the bounded event channel and block the initialize + // response behind it. + client.spawn_rpc_reader(&rpc_client, events_rx); + client.initialize_rpc(&rpc_client, options).await?; + + self.recover_processes(&rpc_client).await?; + Ok(rpc_client) + } + + async fn recover_processes( + self: &Arc, + rpc_client: &RpcClient, + ) -> Result<(), ExecServerError> { + let sessions = self.sessions.load_full(); + for (process_id, session) in sessions.iter() { + if !session.recoverable.load(Ordering::Acquire) { + continue; + } + let response = rpc_client + .call::<_, ReadResponse>( + EXEC_READ_METHOD, + &ReadParams { + process_id: process_id.clone(), + after_seq: Some(session.last_published_seq()), + max_bytes: None, + wait_ms: Some(0), + }, + ) + .await + .map_err(ExecServerError::from); + let recovered = match response { + Ok(response) => session.recover_events(response), + Err(error) if is_transport_closed_error(&error) => return Err(error), + Err(error) => Err(error), + }; + match recovered { + Ok(true) => self.remove_session_if(process_id, session), + Ok(false) => {} + Err(error) => { + let terminated: Result = rpc_client + .call( + EXEC_TERMINATE_METHOD, + &TerminateParams { + process_id: process_id.clone(), + }, + ) + .await + .map_err(ExecServerError::from); + if let Err(terminate_error) = terminated + && is_transport_closed_error(&terminate_error) + { + return Err(terminate_error); + } + self.remove_session_if(process_id, session); + session.set_failure(format!("failed to recover process {process_id}: {error}")); + } + } + } + Ok(()) + } + + async fn fail(self: &Arc, message: String) { + let (message, newly_failed) = { + let mut connection = self + .connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match &connection.status { + ConnectionStatus::Failed(existing) => (existing.clone(), false), + ConnectionStatus::Connected(_) | ConnectionStatus::Recovering => { + connection.status = ConnectionStatus::Failed(message.clone()); + (message, true) + } + } + }; + if newly_failed { + self.notify_connection_changed(); + fail_all_in_flight_work(self, message.clone()).await; + } + } +} + +impl ExecServerClient { + pub(super) fn spawn_rpc_reader( + &self, + rpc_client: &Arc, + mut events_rx: mpsc::Receiver, + ) { + let inner = Arc::downgrade(&self.inner); + let rpc_client = Arc::downgrade(rpc_client); + tokio::spawn(async move { + while let Some(event) = events_rx.recv().await { + let (Some(inner), Some(rpc_client)) = (inner.upgrade(), rpc_client.upgrade()) + else { + return; + }; + match event { + RpcClientEvent::Notification(notification) => { + if let Err(error) = handle_server_notification(&inner, notification).await { + rpc_client.close_transport().await; + inner.request_recovery( + rpc_client, + format!("exec-server notification handling failed: {error}"), + ); + return; + } + } + RpcClientEvent::Disconnected { reason } => { + inner.request_recovery(rpc_client, disconnected_message(reason.as_deref())); + return; + } + } + } + }); + } +} + +fn is_retryable_recovery_error(error: &ExecServerError) -> bool { + is_transport_closed_error(error) + || matches!( + error, + ExecServerError::WebSocketConnectTimeout { .. } + | ExecServerError::WebSocketConnect { .. } + | ExecServerError::InitializeTimedOut { .. } + ) + || matches!( + error, + ExecServerError::EnvironmentRegistryRequest(error) + if error.is_connect() || error.is_timeout() + ) + || matches!( + error, + ExecServerError::EnvironmentRegistryHttp { status, .. } + if status.is_server_error() + || *status == reqwest::StatusCode::REQUEST_TIMEOUT + || *status == reqwest::StatusCode::TOO_MANY_REQUESTS + ) + || matches!( + error, + ExecServerError::Server { code, .. } + if *code == SESSION_ALREADY_ATTACHED_ERROR_CODE + ) +} diff --git a/codex-rs/exec-server/src/client_transport.rs b/codex-rs/exec-server/src/client_transport.rs index c31a8fcdf6..b079d32725 100644 --- a/codex-rs/exec-server/src/client_transport.rs +++ b/codex-rs/exec-server/src/client_transport.rs @@ -1,4 +1,7 @@ use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; + use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; use tokio::process::Command; @@ -14,12 +17,15 @@ use crate::ExecServerClient; use crate::ExecServerError; use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT; use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT; +use crate::client_api::ExecServerClientConnectOptions; use crate::client_api::NoiseRendezvousConnectArgs; use crate::client_api::NoiseRendezvousConnectBundle; +use crate::client_api::NoiseRendezvousConnectProvider; use crate::client_api::RemoteExecServerConnectArgs; use crate::client_api::StdioExecServerCommand; use crate::client_api::StdioExecServerConnectArgs; use crate::connection::JsonRpcConnection; +use crate::noise_channel::NoiseChannelIdentity; use crate::noise_relay::NoiseHarnessConnectionArgs; use crate::noise_relay::noise_harness_connection_from_websocket; use crate::noise_relay::noise_relay_websocket_config; @@ -27,6 +33,57 @@ use crate::relay::harness_connection_from_websocket; const ENVIRONMENT_CLIENT_NAME: &str = "codex-environment"; +/// Reopens the transport for one logical exec-server client session. +/// +/// URL connections reuse their configured endpoint. Noise connections retain +/// the harness identity but fetch a fresh single-use authorization bundle for +/// every physical connection attempt. +#[derive(Clone)] +pub(crate) enum ExecServerReconnectStrategy { + WebSocket(RemoteExecServerConnectArgs), + NoiseRendezvous { + provider: Arc, + identity: NoiseChannelIdentity, + client_name: String, + connect_timeout: Duration, + initialize_timeout: Duration, + }, +} + +impl ExecServerReconnectStrategy { + pub(crate) async fn resume( + &self, + session_id: &str, + ) -> Result<(JsonRpcConnection, ExecServerClientConnectOptions), ExecServerError> { + match self { + Self::WebSocket(args) => { + let mut args = args.clone(); + args.resume_session_id = Some(session_id.to_string()); + let connection = ExecServerClient::open_websocket_connection(&args).await?; + Ok((connection, args.into())) + } + Self::NoiseRendezvous { + provider, + identity, + client_name, + connect_timeout, + initialize_timeout, + } => { + let bundle = provider.connect_bundle(identity.public_key()).await?; + ExecServerClient::open_noise_rendezvous_connection(NoiseRendezvousConnectArgs { + bundle, + harness_identity: identity.clone(), + client_name: client_name.clone(), + connect_timeout: *connect_timeout, + initialize_timeout: *initialize_timeout, + resume_session_id: Some(session_id.to_string()), + }) + .await + } + } + } +} + impl ExecServerClient { /// Open the selected transport and run the common JSON-RPC initialization. /// Noise connection details are fetched here so reconnects get a fresh URL @@ -53,16 +110,25 @@ impl ExecServerClient { provider, identity, } => { - let bundle = provider.connect_bundle(identity.public_key()).await?; - Self::connect_noise_rendezvous(NoiseRendezvousConnectArgs { - bundle, - harness_identity: identity, + let reconnect_strategy = ExecServerReconnectStrategy::NoiseRendezvous { + provider: Arc::clone(&provider), + identity: identity.clone(), client_name: ENVIRONMENT_CLIENT_NAME.to_string(), connect_timeout: DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, initialize_timeout: DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT, - resume_session_id: None, - }) - .await + }; + let bundle = provider.connect_bundle(identity.public_key()).await?; + let (connection, options) = + Self::open_noise_rendezvous_connection(NoiseRendezvousConnectArgs { + bundle, + harness_identity: identity, + client_name: ENVIRONMENT_CLIENT_NAME.to_string(), + connect_timeout: DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, + initialize_timeout: DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT, + resume_session_id: None, + }) + .await?; + Self::connect_with_recovery(connection, options, Some(reconnect_strategy)).await } crate::client_api::ExecServerTransportParams::StdioCommand { command, @@ -82,6 +148,19 @@ impl ExecServerClient { pub async fn connect_websocket( args: RemoteExecServerConnectArgs, ) -> Result { + let connection = Self::open_websocket_connection(&args).await?; + let options = args.clone().into(); + Self::connect_with_recovery( + connection, + options, + Some(ExecServerReconnectStrategy::WebSocket(args)), + ) + .await + } + + pub(crate) async fn open_websocket_connection( + args: &RemoteExecServerConnectArgs, + ) -> Result { ensure_rustls_crypto_provider(); let websocket_url = args.websocket_url.clone(); let connect_timeout = args.connect_timeout; @@ -102,15 +181,26 @@ impl ExecServerClient { } else { JsonRpcConnection::from_websocket(stream, connection_label) }; - Self::connect(connection, args.into()).await + Ok(connection) } - /// Connect to one exec-server through an authenticated rendezvous stream. + /// Connect to one exec-server through an authenticated rendezvous stream + /// using a caller-supplied single-use authorization bundle. + /// /// The executor key is pinned before JSON-RPC starts; the websocket carries - /// only ciphertext after that. + /// only ciphertext after that. Environment-managed connections use a + /// retained [`NoiseRendezvousConnectProvider`] so recovery can fetch a fresh + /// bundle for each reconnect. pub async fn connect_noise_rendezvous( args: NoiseRendezvousConnectArgs, ) -> Result { + let (connection, options) = Self::open_noise_rendezvous_connection(args).await?; + Self::connect(connection, options).await + } + + pub(crate) async fn open_noise_rendezvous_connection( + args: NoiseRendezvousConnectArgs, + ) -> Result<(JsonRpcConnection, ExecServerClientConnectOptions), ExecServerError> { ensure_rustls_crypto_provider(); // Keep the registry-issued URL, key, and authorization together for this // connection attempt. @@ -164,15 +254,14 @@ impl ExecServerClient { harness_key_authorization, }, ); - Self::connect( + Ok(( connection, - crate::client_api::ExecServerClientConnectOptions { + ExecServerClientConnectOptions { client_name, initialize_timeout, resume_session_id, }, - ) - .await + )) } pub(crate) async fn connect_stdio_command( diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 78a53897ee..d8ee65160a 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -81,8 +81,10 @@ struct RunningProcess { closed: bool, } +struct ProcessStart; + enum ProcessEntry { - Starting, + Starting(Arc), Running(Box), } @@ -128,7 +130,7 @@ impl LocalProcess { processes .drain() .filter_map(|(_, process)| match process { - ProcessEntry::Starting => None, + ProcessEntry::Starting(_) => None, ProcessEntry::Running(process) => Some(process), }) .collect::>() @@ -163,6 +165,7 @@ impl LocalProcess { )) })?; + let start = Arc::new(ProcessStart); { let mut process_map = self.inner.processes.lock().await; if process_map.contains_key(&process_id) { @@ -170,7 +173,10 @@ impl LocalProcess { "process {process_id} already exists" ))); } - process_map.insert(process_id.clone(), ProcessEntry::Starting); + process_map.insert( + process_id.clone(), + ProcessEntry::Starting(Arc::clone(&start)), + ); } let env = child_env(¶ms); @@ -207,7 +213,10 @@ impl LocalProcess { Ok(spawned) => spawned, Err(err) => { let mut process_map = self.inner.processes.lock().await; - if matches!(process_map.get(&process_id), Some(ProcessEntry::Starting)) { + if matches!( + process_map.get(&process_id), + Some(ProcessEntry::Starting(current)) if Arc::ptr_eq(current, &start) + ) { process_map.remove(&process_id); } return Err(internal_error(err.to_string())); @@ -222,6 +231,16 @@ impl LocalProcess { ); { let mut process_map = self.inner.processes.lock().await; + if !matches!( + process_map.get(&process_id), + Some(ProcessEntry::Starting(current)) if Arc::ptr_eq(current, &start) + ) { + drop(process_map); + spawned.session.terminate(); + return Err(invalid_request(format!( + "process {process_id} start was cancelled" + ))); + } process_map.insert( process_id.clone(), ProcessEntry::Running(Box::new(RunningProcess { @@ -320,7 +339,9 @@ impl LocalProcess { break; } } - + if params.max_bytes.is_none() { + next_seq = process.next_seq; + } ( ReadResponse { chunks, @@ -408,7 +429,7 @@ impl LocalProcess { .signal(pty_process_signal(params.signal)) .map_err(|err| internal_error(format!("failed to signal process: {err}")))? } - Some(ProcessEntry::Starting) | None => {} + Some(ProcessEntry::Starting(_)) | None => {} } } @@ -420,7 +441,7 @@ impl LocalProcess { params: TerminateParams, ) -> Result { let running = { - let process_map = self.inner.processes.lock().await; + let mut process_map = self.inner.processes.lock().await; match process_map.get(¶ms.process_id) { Some(ProcessEntry::Running(process)) => { if process.exit_code.is_some() { @@ -429,7 +450,11 @@ impl LocalProcess { process.session.terminate(); true } - Some(ProcessEntry::Starting) | None => false, + Some(ProcessEntry::Starting(_)) => { + process_map.remove(¶ms.process_id); + true + } + None => false, } }; @@ -915,6 +940,16 @@ mod tests { ) .await .expect("process should close"); + let replay_after_exit = backend + .exec_read(ReadParams { + process_id: process.process_id.clone(), + after_seq: Some(1), + max_bytes: None, + wait_ms: Some(0), + }) + .await + .expect("closed process should remain readable"); + assert_eq!(replay_after_exit.next_seq, 4); backend.shutdown().await; } diff --git a/codex-rs/exec-server/src/remote_process.rs b/codex-rs/exec-server/src/remote_process.rs index e130114cc3..72f41ae70e 100644 --- a/codex-rs/exec-server/src/remote_process.rs +++ b/codex-rs/exec-server/src/remote_process.rs @@ -35,13 +35,8 @@ impl RemoteProcess { &self, params: ExecParams, ) -> Result { - let process_id = params.process_id.clone(); let client = self.client.get().await?; - let session = client.register_session(&process_id).await?; - if let Err(err) = client.exec(params).await { - session.unregister().await; - return Err(err); - } + let session = client.start_process(params).await?; Ok(StartedExecProcess { process: Arc::new(RemoteExecProcess { session }), diff --git a/codex-rs/exec-server/src/rpc.rs b/codex-rs/exec-server/src/rpc.rs index 981a1c1a80..82cf2b200a 100644 --- a/codex-rs/exec-server/src/rpc.rs +++ b/codex-rs/exec-server/src/rpc.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::future::Future; use std::pin::Pin; use std::sync::Arc; +use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; @@ -25,6 +26,8 @@ use crate::connection::JsonRpcConnection; use crate::connection::JsonRpcConnectionEvent; use crate::connection::JsonRpcTransport; +pub(crate) const SESSION_ALREADY_ATTACHED_ERROR_CODE: i64 = -32010; + #[derive(Debug)] pub(crate) enum RpcCallError { /// The underlying JSON-RPC transport closed before this call completed. @@ -225,6 +228,7 @@ pub(crate) struct RpcClient { // immediately when the socket closes, even if no JSON-RPC error response // can be delivered for their request id. disconnected_rx: watch::Receiver, + closed: Arc, next_request_id: AtomicI64, transport_tasks: Vec>, transport: JsonRpcTransport, @@ -241,9 +245,11 @@ impl RpcClient { transport, } = connection; let pending = Arc::new(Mutex::new(HashMap::::new())); + let closed = Arc::new(AtomicBool::new(false)); let (event_tx, event_rx) = mpsc::channel(128); let pending_for_reader = Arc::clone(&pending); + let closed_for_reader = Arc::clone(&closed); let transport_for_reader = transport.clone(); let reader_task = tokio::spawn(async move { let disconnect_reason = loop { @@ -269,12 +275,13 @@ impl RpcClient { } }; + closed_for_reader.store(true, Ordering::Release); + drain_pending(&pending_for_reader).await; let _ = event_tx .send(RpcClientEvent::Disconnected { reason: disconnect_reason, }) .await; - drain_pending(&pending_for_reader).await; transport_for_reader.terminate(); }); @@ -283,6 +290,7 @@ impl RpcClient { write_tx, pending, disconnected_rx, + closed, next_request_id: AtomicI64::new(1), transport_tasks, transport, @@ -296,24 +304,31 @@ impl RpcClient { &self, method: &str, params: &P, - ) -> Result<(), serde_json::Error> { - let params = serde_json::to_value(params)?; + ) -> Result<(), RpcCallError> { + let params = serde_json::to_value(params).map_err(RpcCallError::Json)?; + if self.closed.load(Ordering::Acquire) || *self.disconnected_rx.borrow() { + return Err(RpcCallError::Closed); + } self.write_tx .send(JSONRPCMessage::Notification(JSONRPCNotification { method: method.to_string(), params: Some(params), })) .await - .map_err(|_| { - serde_json::Error::io(std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "JSON-RPC transport closed", - )) - }) + .map_err(|_| RpcCallError::Closed) } pub(crate) fn is_disconnected(&self) -> bool { - *self.disconnected_rx.borrow() + self.closed.load(Ordering::Acquire) || *self.disconnected_rx.borrow() + } + + pub(crate) async fn close_transport(&self) { + self.closed.store(true, Ordering::Release); + self.transport.terminate(); + for task in &self.transport_tasks { + task.abort(); + } + drain_pending(&self.pending).await; } pub(crate) async fn call(&self, method: &str, params: &P) -> Result @@ -328,7 +343,7 @@ impl RpcClient { // Registering the pending request and checking disconnect must be // atomic with the reader's drain_pending path. Otherwise a call // can sneak in after the drain and wait forever. - if *self.disconnected_rx.borrow() { + if self.closed.load(Ordering::Acquire) || *self.disconnected_rx.borrow() { return Err(RpcCallError::Closed); } pending.insert(request_id.clone(), response_tx); @@ -417,6 +432,14 @@ pub(crate) fn invalid_request(message: String) -> JSONRPCErrorError { } } +pub(crate) fn session_already_attached(message: String) -> JSONRPCErrorError { + JSONRPCErrorError { + code: SESSION_ALREADY_ATTACHED_ERROR_CODE, + data: None, + message, + } +} + pub(crate) fn method_not_found(message: String) -> JSONRPCErrorError { JSONRPCErrorError { code: -32601, diff --git a/codex-rs/exec-server/src/server/handler/tests.rs b/codex-rs/exec-server/src/server/handler/tests.rs index 97bfba5344..0bd682bd96 100644 --- a/codex-rs/exec-server/src/server/handler/tests.rs +++ b/codex-rs/exec-server/src/server/handler/tests.rs @@ -257,7 +257,7 @@ async fn active_session_resume_is_rejected() { .await .expect_err("active session resume should fail"); - assert_eq!(err.code, -32600); + assert_eq!(err.code, crate::rpc::SESSION_ALREADY_ATTACHED_ERROR_CODE); assert_eq!( err.message, format!( diff --git a/codex-rs/exec-server/src/server/session_registry.rs b/codex-rs/exec-server/src/server/session_registry.rs index 82c779c6bb..59da3b50a7 100644 --- a/codex-rs/exec-server/src/server/session_registry.rs +++ b/codex-rs/exec-server/src/server/session_registry.rs @@ -9,12 +9,13 @@ use uuid::Uuid; use crate::rpc::RpcNotificationSender; use crate::rpc::invalid_request; +use crate::rpc::session_already_attached; use crate::server::process_handler::ProcessHandler; #[cfg(test)] const DETACHED_SESSION_TTL: Duration = Duration::from_millis(200); #[cfg(not(test))] -const DETACHED_SESSION_TTL: Duration = Duration::from_secs(10); +const DETACHED_SESSION_TTL: Duration = Duration::from_secs(30); pub(crate) struct SessionRegistry { sessions: Mutex>>, @@ -82,7 +83,7 @@ impl SessionRegistry { })?; Ok(AttachOutcome::Expired { session_id, entry }) } else if entry.has_active_connection() { - Err(invalid_request(format!( + Err(session_already_attached(format!( "session {session_id} is already attached to another connection" ))) } else { @@ -176,6 +177,7 @@ impl SessionEntry { return false; } + self.process.set_notification_sender(/*notifications*/ None); attachment.current_connection_id = None; attachment.detached_connection_id = Some(connection_id); attachment.detached_expires_at = Some(tokio::time::Instant::now() + DETACHED_SESSION_TTL); @@ -245,10 +247,6 @@ impl SessionHandle { return; } - self.entry - .process - .set_notification_sender(/*notifications*/ None); - let registry = Arc::clone(&self.registry); let session_id = self.entry.session_id.clone(); let connection_id = self.connection_id; diff --git a/codex-rs/exec-server/tests/common/exec_server.rs b/codex-rs/exec-server/tests/common/exec_server.rs index f1bf03d25b..ad089d5cda 100644 --- a/codex-rs/exec-server/tests/common/exec_server.rs +++ b/codex-rs/exec-server/tests/common/exec_server.rs @@ -14,8 +14,13 @@ use futures::StreamExt; use tempfile::TempDir; use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; +use tokio::io::copy_bidirectional; +use tokio::net::TcpListener; +use tokio::net::TcpStream; use tokio::process::Child; use tokio::process::Command; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; use tokio::time::Instant; use tokio::time::sleep; use tokio::time::timeout; @@ -48,6 +53,20 @@ pub(crate) struct TestCodexHelperPaths { pub(crate) codex_linux_sandbox_exe: Option, } +pub(crate) struct DisconnectableWebSocketProxy { + websocket_url: String, + pause_tx: Option>, + blocked_connection_rx: Option>, + resume_tx: Option>, + task: JoinHandle<()>, +} + +impl Drop for DisconnectableWebSocketProxy { + fn drop(&mut self) { + self.task.abort(); + } +} + pub(crate) fn test_codex_helper_paths() -> anyhow::Result { let (helper_binary, codex_linux_sandbox_exe) = super::current_test_binary_helper_paths()?; Ok(TestCodexHelperPaths { @@ -106,6 +125,35 @@ impl ExecServerHarness { Ok(()) } + pub(crate) async fn disconnectable_websocket_proxy( + &self, + ) -> anyhow::Result { + let upstream = self + .websocket_url + .strip_prefix("ws://") + .ok_or_else(|| anyhow!("exec-server websocket URL must use ws://"))? + .to_string(); + let listener = TcpListener::bind("127.0.0.1:0").await?; + let websocket_url = format!("ws://{}", listener.local_addr()?); + let (pause_tx, pause_rx) = oneshot::channel(); + let (blocked_connection_tx, blocked_connection_rx) = oneshot::channel(); + let (resume_tx, resume_rx) = oneshot::channel(); + let task = tokio::spawn(run_disconnectable_proxy( + listener, + upstream, + pause_rx, + blocked_connection_tx, + resume_rx, + )); + Ok(DisconnectableWebSocketProxy { + websocket_url, + pause_tx: Some(pause_tx), + blocked_connection_rx: Some(blocked_connection_rx), + resume_tx: Some(resume_tx), + task, + }) + } + pub(crate) async fn send_request( &mut self, method: &str, @@ -213,6 +261,85 @@ impl ExecServerHarness { } } +impl DisconnectableWebSocketProxy { + pub(crate) fn websocket_url(&self) -> &str { + &self.websocket_url + } + + pub(crate) async fn pause_and_disconnect(&mut self) -> anyhow::Result<()> { + self.pause_tx + .take() + .ok_or_else(|| anyhow!("disconnectable websocket proxy is already paused"))? + .send(()) + .map_err(|_| anyhow!("disconnectable websocket proxy stopped"))?; + let blocked_connection_rx = self + .blocked_connection_rx + .take() + .ok_or_else(|| anyhow!("disconnectable websocket proxy is already paused"))?; + timeout(CONNECT_TIMEOUT, blocked_connection_rx) + .await + .map_err(|_| anyhow!("timed out waiting for client reconnect attempt"))? + .map_err(|_| anyhow!("disconnectable websocket proxy stopped"))?; + Ok(()) + } + + pub(crate) fn resume(&mut self) -> anyhow::Result<()> { + self.resume_tx + .take() + .ok_or_else(|| anyhow!("disconnectable websocket proxy is already resumed"))? + .send(()) + .map_err(|_| anyhow!("disconnectable websocket proxy stopped"))?; + Ok(()) + } +} + +async fn run_disconnectable_proxy( + listener: TcpListener, + upstream: String, + pause_rx: oneshot::Receiver<()>, + blocked_connection_tx: oneshot::Sender<()>, + mut resume_rx: oneshot::Receiver<()>, +) { + let Ok((mut downstream, _)) = listener.accept().await else { + return; + }; + let Ok(mut upstream_stream) = TcpStream::connect(&upstream).await else { + return; + }; + tokio::select! { + _ = copy_bidirectional(&mut downstream, &mut upstream_stream) => return, + _ = pause_rx => {} + } + drop(downstream); + drop(upstream_stream); + + let mut blocked_connection_tx = Some(blocked_connection_tx); + loop { + tokio::select! { + _ = &mut resume_rx => break, + accepted = listener.accept() => { + let Ok((blocked, _)) = accepted else { + break; + }; + drop(blocked); + if let Some(blocked_connection_tx) = blocked_connection_tx.take() { + let _ = blocked_connection_tx.send(()); + } + } + } + } + + loop { + let Ok((mut downstream, _)) = listener.accept().await else { + return; + }; + let Ok(mut upstream_stream) = TcpStream::connect(&upstream).await else { + continue; + }; + let _ = copy_bidirectional(&mut downstream, &mut upstream_stream).await; + } +} + async fn connect_websocket_when_ready( websocket_url: &str, ) -> anyhow::Result<( diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index e5522f4073..11a3f3ece7 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -1,5 +1,6 @@ mod common; +use std::collections::HashMap; use std::sync::Arc; use anyhow::Context; @@ -21,6 +22,7 @@ use tempfile::TempDir; use test_case::test_case; use tokio::sync::watch; use tokio::time::Duration; +use tokio::time::sleep; use tokio::time::timeout; use common::DELAYED_OUTPUT_AFTER_EXIT_PARENT_ARG; @@ -30,7 +32,7 @@ use common::exec_server::exec_server; struct ProcessContext { backend: Arc, - server: Option, + _server: Option, } #[derive(Debug, PartialEq, Eq)] @@ -55,13 +57,13 @@ async fn create_process_context(use_remote: bool) -> Result { let environment = Environment::create_for_tests(Some(server.websocket_url().to_string()))?; Ok(ProcessContext { backend: environment.get_exec_backend(), - server: Some(server), + _server: Some(server), }) } else { let environment = Environment::create_for_tests(/*exec_server_url*/ None)?; Ok(ProcessContext { backend: environment.get_exec_backend(), - server: None, + _server: None, }) } } @@ -634,88 +636,145 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe( #[cfg_attr(not(unix), ignore = "Unix-only exec-server process test")] // Serialize tests that launch a real exec-server process through the full CLI. #[serial_test::serial(remote_exec_server)] -async fn remote_exec_process_reports_transport_disconnect() -> Result<()> { - let mut context = create_process_context(/*use_remote*/ true).await?; - let session = context - .backend +async fn remote_exec_process_recovers_after_transport_disconnect() -> Result<()> { + let server = exec_server().await?; + let mut proxy = server.disconnectable_websocket_proxy().await?; + let environment = Environment::create_for_tests(Some(proxy.websocket_url().to_string()))?; + let backend = environment.get_exec_backend(); + let temp_dir = TempDir::new()?; + let gate_path = temp_dir.path().join("release-output"); + let emitted_path = temp_dir.path().join("output-emitted"); + let session = backend .start(ExecParams { - process_id: ProcessId::from("proc-disconnect"), + process_id: ProcessId::from("proc-recover"), argv: vec![ "/bin/sh".to_string(), "-c".to_string(), - "sleep 10".to_string(), + concat!( + "printf 'ready:%s\\n' \"$$\"; ", + "while [ ! -f \"$GATE\" ]; do /bin/sleep 0.01; done; ", + "printf 'during:%s\\n' \"$$\"; ", + ": > \"$EMITTED\"; ", + "IFS= read -r line; ", + "printf 'after:%s:%s\\n' \"$$\" \"$line\"; ", + "exit 7", + ) + .to_string(), ], cwd: PathUri::from_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, - env: Default::default(), + env: HashMap::from([ + ( + "GATE".to_string(), + gate_path.to_string_lossy().into_owned(), + ), + ( + "EMITTED".to_string(), + emitted_path.to_string_lossy().into_owned(), + ), + ]), tty: false, - pipe_stdin: false, + pipe_stdin: true, arg0: None, }) .await?; let process = Arc::clone(&session.process); let mut events = process.subscribe_events(); - let process_for_pending_read = Arc::clone(&process); - let pending_read = tokio::spawn(async move { - process_for_pending_read + let mut output = Vec::new(); + let mut last_seq = 0; + while !output.ends_with(b"\n") { + match timeout(Duration::from_secs(5), events.recv()).await?? { + ExecProcessEvent::Output(chunk) => { + assert_eq!(chunk.seq, last_seq + 1); + last_seq = chunk.seq; + output.extend_from_slice(&chunk.chunk.into_inner()); + } + event => anyhow::bail!("expected ready output before disconnect, got {event:?}"), + } + } + let ready = String::from_utf8(output.clone())?; + let pid = ready + .strip_prefix("ready:") + .and_then(|line| line.strip_suffix('\n')) + .context("ready output should contain the process id")? + .to_string(); + + proxy.pause_and_disconnect().await?; + tokio::fs::write(&gate_path, b"").await?; + timeout(Duration::from_secs(5), async { + while tokio::fs::metadata(&emitted_path).await.is_err() { + sleep(Duration::from_millis(10)).await; + } + }) + .await + .context("process did not emit output while disconnected")?; + + let process_for_read = Arc::clone(&process); + let mut pending_read = tokio::spawn(async move { + process_for_read .read( - /*after_seq*/ None, + /*after_seq*/ Some(last_seq), /*max_bytes*/ None, - /*wait_ms*/ Some(60_000), + /*wait_ms*/ Some(0), ) .await }); - let server = context - .server - .as_mut() - .expect("remote context should include exec-server harness"); - server.shutdown().await?; - - let event = timeout(Duration::from_secs(2), events.recv()).await??; - let ExecProcessEvent::Failed(event_message) = event else { - anyhow::bail!("expected process failure event, got {event:?}"); - }; assert!( - event_message.starts_with("exec-server transport disconnected"), - "unexpected failure event: {event_message}" + timeout(Duration::from_millis(200), &mut pending_read) + .await + .is_err(), + "process reads should wait while recovery is in progress" + ); + proxy.resume()?; + + let recovered_read = timeout(Duration::from_secs(5), pending_read) + .await + .context("timed out waiting for a read after recovery")??; + let recovered_read = recovered_read?; + assert_eq!(recovered_read.failure, None); + let recovered_output = recovered_read + .chunks + .into_iter() + .flat_map(|chunk| chunk.chunk.into_inner()) + .collect::>(); + assert_eq!( + String::from_utf8(recovered_output)?, + format!("during:{pid}\n") ); - let pending_response = timeout(Duration::from_secs(2), pending_read).await???; - let pending_message = pending_response - .failure - .expect("pending read should surface disconnect as a failure"); - assert!( - pending_message.starts_with("exec-server transport disconnected"), - "unexpected pending failure message: {pending_message}" - ); + let write = timeout(Duration::from_secs(5), process.write(b"hello\n".to_vec())) + .await + .context("timed out waiting for a write after recovery")??; + assert_eq!(write.status, WriteStatus::Accepted); - let mut wake_rx = process.subscribe_wake(); - let response = read_process_until_change(process, &mut wake_rx, /*after_seq*/ None).await?; - let message = response - .failure - .expect("disconnect should surface as a failure"); - assert!( - message.starts_with("exec-server transport disconnected"), - "unexpected failure message: {message}" - ); - assert!( - response.closed, - "disconnect should close the process session" - ); - - let write_result = timeout( - Duration::from_secs(2), - session.process.write(b"hello".to_vec()), - ) - .await - .context("timed out waiting for write after disconnect")?; - let write_error = write_result.expect_err("write after disconnect should fail"); - assert!( - write_error - .to_string() - .starts_with("exec-server transport disconnected"), - "unexpected write error: {write_error}" + let mut saw_exit = false; + loop { + match timeout(Duration::from_secs(5), events.recv()).await?? { + ExecProcessEvent::Output(chunk) => { + assert_eq!(chunk.seq, last_seq + 1); + last_seq = chunk.seq; + output.extend_from_slice(&chunk.chunk.into_inner()); + } + ExecProcessEvent::Exited { seq, exit_code } => { + assert_eq!(seq, last_seq + 1); + assert_eq!(exit_code, 7); + last_seq = seq; + saw_exit = true; + } + ExecProcessEvent::Closed { seq } => { + assert!(saw_exit, "closed must be delivered after exit"); + assert_eq!(seq, last_seq + 1); + break; + } + ExecProcessEvent::Failed(message) => { + anyhow::bail!("process recovery failed: {message}"); + } + } + } + assert_eq!( + String::from_utf8(output)?, + format!("ready:{pid}\nduring:{pid}\nafter:{pid}:hello\n") ); Ok(()) From a5229e06867b83b2cf331c852cebfcc74828bd24 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 17 Jun 2026 10:52:23 +0100 Subject: [PATCH 26/70] Back off registry retries during exec recovery (#28546) ## Why PR #28512 retries a failed session recovery every 100 ms. Every Noise recovery attempt first asks the environment registry for a fresh connection bundle, even when the eventual failure comes from the WebSocket or initialize handshake. During an outage, that could make each disconnected client call the registry about 250 times during the 25-second recovery window. ## What changes All retryable Noise recovery failures now use a separate backoff schedule: ```text base: 500 ms -> 1 s -> 2 s -> 4 s -> 5 s maximum actual: 500-750 ms, 1-1.5 s, 2-3 s, 4-6 s, 5-7.5 s ``` The extra 0-50% is deterministic per-session jitter so disconnected clients do not retry together. Direct WebSocket recovery keeps the existing 100 ms retry because it does not re-enter the registry. --- codex-rs/exec-server/src/client_recovery.rs | 70 ++++++++++++++----- .../exec-server/src/client_recovery_tests.rs | 40 +++++++++++ 2 files changed, 94 insertions(+), 16 deletions(-) create mode 100644 codex-rs/exec-server/src/client_recovery_tests.rs diff --git a/codex-rs/exec-server/src/client_recovery.rs b/codex-rs/exec-server/src/client_recovery.rs index 9a2f3c29a7..77feb0a4ba 100644 --- a/codex-rs/exec-server/src/client_recovery.rs +++ b/codex-rs/exec-server/src/client_recovery.rs @@ -1,3 +1,6 @@ +use std::collections::hash_map::DefaultHasher; +use std::hash::Hash; +use std::hash::Hasher; use std::sync::Arc; use std::sync::atomic::Ordering; use std::time::Duration; @@ -17,6 +20,7 @@ use super::disconnected_message; use super::fail_all_in_flight_work; use super::handle_server_notification; use super::is_transport_closed_error; +use crate::client_transport::ExecServerReconnectStrategy; use crate::process::ExecProcessEvent; use crate::protocol::EXEC_READ_METHOD; use crate::protocol::EXEC_TERMINATE_METHOD; @@ -35,6 +39,8 @@ const SESSION_RECOVERY_TIMEOUT: Duration = Duration::from_millis(500); // client and server start their disconnect clocks independently. const SESSION_RECOVERY_TIMEOUT: Duration = Duration::from_secs(25); const SESSION_RECOVERY_RETRY_INTERVAL: Duration = Duration::from_millis(100); +const REGISTRY_RECOVERY_INITIAL_RETRY_INTERVAL: Duration = Duration::from_millis(500); +const REGISTRY_RECOVERY_MAX_RETRY_INTERVAL: Duration = Duration::from_secs(5); impl SessionState { fn last_published_seq(&self) -> u64 { @@ -285,14 +291,18 @@ impl Inner { self.fail(message).await; return; }; + let uses_registry_backoff = matches!( + self.reconnect_strategy.as_ref(), + Some(ExecServerReconnectStrategy::NoiseRendezvous { .. }) + ); + let mut registry_retry_attempt = 0; let last_error = loop { match timeout_at(deadline, self.resume_once(&session_id)).await { - Ok(Ok(candidate)) if !candidate.is_disconnected() => { - if self.install_recovered_client(candidate) { + Ok(Ok(candidate)) => { + if !candidate.is_disconnected() && self.install_recovered_client(candidate) { return; } } - Ok(Ok(_)) => {} Ok(Err(error)) if !is_retryable_recovery_error(&error) => { break error.to_string(); } @@ -302,11 +312,19 @@ impl Inner { } } + let retry_delay = if uses_registry_backoff { + let delay = registry_recovery_retry_delay(&session_id, registry_retry_attempt); + registry_retry_attempt = registry_retry_attempt.saturating_add(1); + delay + } else { + SESSION_RECOVERY_RETRY_INTERVAL + }; + let now = Instant::now(); if now >= deadline { break format!("recovery timed out after {SESSION_RECOVERY_TIMEOUT:?}"); } - sleep(SESSION_RECOVERY_RETRY_INTERVAL.min(deadline - now)).await; + sleep(retry_delay.min(deadline - now)).await; }; let message = @@ -496,21 +514,41 @@ fn is_retryable_recovery_error(error: &ExecServerError) -> bool { | ExecServerError::WebSocketConnect { .. } | ExecServerError::InitializeTimedOut { .. } ) - || matches!( - error, - ExecServerError::EnvironmentRegistryRequest(error) - if error.is_connect() || error.is_timeout() - ) - || matches!( - error, - ExecServerError::EnvironmentRegistryHttp { status, .. } - if status.is_server_error() - || *status == reqwest::StatusCode::REQUEST_TIMEOUT - || *status == reqwest::StatusCode::TOO_MANY_REQUESTS - ) + || is_retryable_registry_error(error) || matches!( error, ExecServerError::Server { code, .. } if *code == SESSION_ALREADY_ATTACHED_ERROR_CODE ) } + +fn is_retryable_registry_error(error: &ExecServerError) -> bool { + matches!( + error, + ExecServerError::EnvironmentRegistryRequest(error) + if error.is_connect() || error.is_timeout() + ) || matches!( + error, + ExecServerError::EnvironmentRegistryHttp { status, .. } + if status.is_server_error() + || *status == reqwest::StatusCode::REQUEST_TIMEOUT + || *status == reqwest::StatusCode::TOO_MANY_REQUESTS + ) +} + +fn registry_recovery_retry_delay(session_id: &str, attempt: u32) -> Duration { + let multiplier = 1_u32.checked_shl(attempt.min(4)).unwrap_or(u32::MAX); + let base_delay = REGISTRY_RECOVERY_INITIAL_RETRY_INTERVAL + .saturating_mul(multiplier) + .min(REGISTRY_RECOVERY_MAX_RETRY_INTERVAL); + let base_millis = base_delay.as_millis() as u64; + let mut hasher = DefaultHasher::new(); + session_id.hash(&mut hasher); + attempt.hash(&mut hasher); + + Duration::from_millis(base_millis + hasher.finish() % (base_millis / 2 + 1)) +} + +#[cfg(test)] +#[path = "client_recovery_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/client_recovery_tests.rs b/codex-rs/exec-server/src/client_recovery_tests.rs new file mode 100644 index 0000000000..a6ff91fc9c --- /dev/null +++ b/codex-rs/exec-server/src/client_recovery_tests.rs @@ -0,0 +1,40 @@ +use std::time::Duration; + +use super::*; + +fn registry_error() -> ExecServerError { + ExecServerError::EnvironmentRegistryHttp { + status: reqwest::StatusCode::TOO_MANY_REQUESTS, + code: None, + message: "registry unavailable".to_string(), + } +} + +#[test] +fn registry_recovery_retry_delay_exponentially_backs_off_and_caps() { + let cases = [ + (0, Duration::from_millis(500)), + (1, Duration::from_secs(1)), + (2, Duration::from_secs(2)), + (3, Duration::from_secs(4)), + (4, Duration::from_secs(5)), + (20, Duration::from_secs(5)), + ]; + + for (attempt, base) in cases { + let delay = registry_recovery_retry_delay("session-1", attempt); + assert!(delay >= base, "delay {delay:?} for attempt {attempt}"); + assert!( + delay <= base + base / 2, + "delay {delay:?} for attempt {attempt}" + ); + } +} + +#[test] +fn recovery_retries_transient_registry_errors() { + let error = registry_error(); + + assert!(is_retryable_registry_error(&error)); + assert!(is_retryable_recovery_error(&error)); +} From 45f603302c45269737db97443612bb4876365798 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 17 Jun 2026 13:48:56 +0100 Subject: [PATCH 27/70] Add join key for MAv2 inter-agent messages (#28561) ## Summary This keeps inter-agent communication on the existing raw response item path and adds a join key for MAv2 tool calls. MAv2 `spawn_agent`, `send_message`, and `followup_task` now stamp the originating tool call id into `ResponseItemMetadata.source_call_id` on the raw `ResponseItem::AgentMessage`. App-server clients can join that raw item back to the existing tool/activity event by call id, while using the raw agent message's existing sender, receiver, and content fields. No new app-server `ThreadItem` or notification type is added. ## Tests - `just fmt` - `just write-app-server-schema` - `just test -p codex-protocol` - `just test -p codex-app-server-protocol` - `just test -p codex-core multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_path` - `just test -p codex-core multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn` - `just fix -p codex-protocol` - `just fix -p codex-app-server-protocol` - `just fix -p codex-core` --- .../schema/json/ClientRequest.json | 6 +++++ .../codex_app_server_protocol.schemas.json | 6 +++++ .../codex_app_server_protocol.v2.schemas.json | 6 +++++ .../RawResponseItemCompletedNotification.json | 6 +++++ .../schema/json/v2/ThreadResumeParams.json | 6 +++++ .../schema/typescript/ResponseItemMetadata.ts | 2 +- codex-rs/core/src/compact_tests.rs | 1 + .../core/src/context_manager/history_tests.rs | 1 + codex-rs/core/src/session/tests.rs | 1 + .../src/tools/handlers/multi_agents_tests.rs | 27 +++++++++++++++++++ .../handlers/multi_agents_v2/message_tool.rs | 7 ++++- .../tools/handlers/multi_agents_v2/spawn.rs | 7 ++++- .../tests/suite/subagent_notifications.rs | 3 +++ codex-rs/protocol/src/models.rs | 4 +++ 14 files changed, 80 insertions(+), 3 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 3358ea9d63..cb1d594ff6 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2940,6 +2940,12 @@ }, "ResponseItemMetadata": { "properties": { + "source_call_id": { + "type": [ + "string", + "null" + ] + }, "turn_id": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 1e11c76ae4..bd894898d1 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -15424,6 +15424,12 @@ }, "ResponseItemMetadata": { "properties": { + "source_call_id": { + "type": [ + "string", + "null" + ] + }, "turn_id": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 326bfdfc9a..fdc3a342cf 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -11877,6 +11877,12 @@ }, "ResponseItemMetadata": { "properties": { + "source_call_id": { + "type": [ + "string", + "null" + ] + }, "turn_id": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index fdd40dae61..3efca03224 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -1035,6 +1035,12 @@ }, "ResponseItemMetadata": { "properties": { + "source_call_id": { + "type": [ + "string", + "null" + ] + }, "turn_id": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 7ba2a0a646..093c27044d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1106,6 +1106,12 @@ }, "ResponseItemMetadata": { "properties": { + "source_call_id": { + "type": [ + "string", + "null" + ] + }, "turn_id": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts index f7b69a6dfe..365c561fd1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ResponseItemMetadata = { turn_id?: string, }; +export type ResponseItemMetadata = { turn_id?: string, source_call_id?: string, }; diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs index a52e70a7ba..c1004004ad 100644 --- a/codex-rs/core/src/compact_tests.rs +++ b/codex-rs/core/src/compact_tests.rs @@ -238,6 +238,7 @@ fn build_compacted_history_preserves_user_message_metadata() { message: "first user message".to_string(), metadata: Some(ResponseItemMetadata { turn_id: Some("turn-1".to_string()), + ..Default::default() }), }], "summary text", diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 908cb286f0..bf5bfc3b7a 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -1054,6 +1054,7 @@ fn record_items_truncates_function_call_output_content() { }, metadata: Some(ResponseItemMetadata { turn_id: Some("turn-1".to_string()), + ..Default::default() }), }; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 473adaa835..a26fb6599f 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1606,6 +1606,7 @@ async fn reconstruct_history_uses_replacement_history_verbatim() { phase: None, metadata: Some(ResponseItemMetadata { turn_id: Some("compact-turn".to_string()), + ..Default::default() }), }; let replacement_history = vec![ diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 4608862ca6..557164f4de 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1195,6 +1195,11 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat && communication.other_recipients.is_empty() && communication.content.is_empty() && communication.encrypted_content.as_deref() == Some("encrypted-spawn-message") + && communication + .metadata + .as_ref() + .and_then(|metadata| metadata.source_call_id.as_deref()) + == Some("call-1") && communication.trigger_turn ) })); @@ -1222,6 +1227,11 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat && communication.other_recipients.is_empty() && communication.content.is_empty() && communication.encrypted_content.as_deref() == Some("encrypted-send-message") + && communication + .metadata + .as_ref() + .and_then(|metadata| metadata.source_call_id.as_deref()) + == Some("call-1") && !communication.trigger_turn ) })); @@ -2027,6 +2037,23 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() .await .expect("followup_task should succeed"); + assert!(manager.captured_ops().iter().any(|(id, op)| { + *id == agent_id + && matches!( + op, + Op::InterAgentCommunication { communication } + if communication.author == AgentPath::root() + && communication.recipient == worker_path + && communication.encrypted_content.as_deref() == Some("continue") + && communication + .metadata + .as_ref() + .and_then(|metadata| metadata.source_call_id.as_deref()) + == Some("call-1") + && communication.trigger_turn + ) + })); + let second_turn = thread.codex.session.new_default_turn().await; thread .codex diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index 7fdb80e8bc..81eebed4a3 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -6,6 +6,7 @@ use super::*; use crate::tools::context::FunctionToolOutput; use crate::turn_timing::now_unix_timestamp_ms; +use codex_protocol::models::ResponseItemMetadata; use codex_protocol::protocol::InterAgentCommunication; #[derive(Clone, Copy, PartialEq, Eq)] @@ -99,8 +100,12 @@ pub(crate) async fn handle_message_string_tool( .session_source .get_agent_path() .unwrap_or_else(AgentPath::root); - let communication = + let mut communication = communication_from_tool_message(author, receiver_agent_path.clone(), message); + communication + .metadata + .get_or_insert_with(ResponseItemMetadata::default) + .source_call_id = Some(call_id.clone()); let result = session .services .agent_control diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 2186a4a6d6..51813c5c19 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -8,6 +8,7 @@ use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions; use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2; use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::AgentPath; +use codex_protocol::models::ResponseItemMetadata; use codex_protocol::protocol::Op; use codex_tools::ToolSpec; @@ -117,8 +118,12 @@ async fn handle_spawn_agent( .session_source .get_agent_path() .unwrap_or_else(AgentPath::root); - let communication = + let mut communication = communication_from_tool_message(author, new_agent_path.clone(), message); + communication + .metadata + .get_or_insert_with(ResponseItemMetadata::default) + .source_call_id = Some(call_id.clone()); Op::InterAgentCommunication { communication } } initial_operation => initial_operation, diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 764380be71..2501aa0dde 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -1088,6 +1088,9 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result "type": "agent_message", "author": "/root", "recipient": "/root/worker", + "metadata": { + "source_call_id": SPAWN_CALL_ID, + }, "content": [ { "type": "input_text", diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 0e7e9bb446..9443866c6f 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -909,6 +909,9 @@ pub struct ResponseItemMetadata { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub turn_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub source_call_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] @@ -2072,6 +2075,7 @@ mod tests { fn response_item_metadata(turn_id: &str) -> ResponseItemMetadata { ResponseItemMetadata { turn_id: Some(turn_id.to_string()), + ..Default::default() } } From 5935a906194a74b6c6d0bc6f92b70d15f7cdeffa Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 17 Jun 2026 15:18:39 +0100 Subject: [PATCH 28/70] app-server: keep the model cache warm (#28699) ## Why The app server is long-lived, but its shared model cache otherwise refreshes only when a caller needs it. Once the five-minute cache expires, starting a thread or calling `model/list` can wait for `/models` on the request path. Refresh the cache in the background before it expires so foreground callers normally use fresh local state. ## What changed - Start an app-server worker that refreshes models immediately and then every three minutes using the existing models-manager API. - Hold only a weak reference to the models manager between refreshes, so the worker does not extend its lifetime. - Stop scheduling refreshes when the app-server lifecycle handle is shut down or dropped. A refresh already in progress is allowed to finish. - Adjust affected app-server test fixtures to distinguish the background `/models` probe from the connection they are testing. The existing models-manager cache, refresh strategies, auth handling, ETag behavior, and concurrency semantics are unchanged. ## Testing - `models_refresh_worker::tests::refreshes_immediately_periodically_and_stops_when_dropped` - `suite::v2::remote_control::listen_off_honors_persisted_remote_control_enable` - `suite::v2::attestation::attestation_generate_round_trip_adds_header_to_responses_websocket_handshake` --- codex-rs/app-server/src/lib.rs | 1 + codex-rs/app-server/src/message_processor.rs | 8 ++ .../app-server/src/models_refresh_worker.rs | 65 ++++++++++++++ .../src/models_refresh_worker_tests.rs | 90 +++++++++++++++++++ .../app-server/tests/suite/v2/attestation.rs | 43 ++++----- .../tests/suite/v2/remote_control.rs | 82 ++++++++++------- codex-rs/core/tests/common/responses.rs | 7 +- 7 files changed, 238 insertions(+), 58 deletions(-) create mode 100644 codex-rs/app-server/src/models_refresh_worker.rs create mode 100644 codex-rs/app-server/src/models_refresh_worker_tests.rs diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 34bb39da9f..cac2db54eb 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -101,6 +101,7 @@ pub mod in_process; mod mcp_refresh; mod message_processor; mod models; +mod models_refresh_worker; mod outgoing_message; mod request_processors; mod request_serialization; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index c32b92c4ea..3c3600008a 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -92,6 +92,8 @@ use tokio::time::timeout; use tokio_util::sync::CancellationToken; use tracing::Instrument; +use crate::models_refresh_worker::ModelsRefreshWorker; + const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); const CONNECTION_RPC_DRAIN_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30); @@ -182,6 +184,7 @@ impl ExternalAuth for ExternalAuthRefreshBridge { pub(crate) struct MessageProcessor { outgoing: Arc, + models_refresh_worker: ModelsRefreshWorker, skills_watcher: Arc, account_processor: AccountRequestProcessor, apps_processor: AppsRequestProcessor, @@ -371,6 +374,8 @@ impl MessageProcessor { )), ) }); + let models_manager = thread_manager.get_models_manager(); + let models_refresh_worker = crate::models_refresh_worker::spawn(&models_manager); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -537,6 +542,7 @@ impl MessageProcessor { Self { outgoing, + models_refresh_worker, skills_watcher, account_processor, apps_processor, @@ -566,6 +572,7 @@ impl MessageProcessor { pub(crate) fn clear_runtime_references(&self) { self.account_processor.clear_external_auth(); self.apps_processor.shutdown(); + self.models_refresh_worker.shutdown(); self.skills_watcher.shutdown(); } @@ -742,6 +749,7 @@ impl MessageProcessor { } pub(crate) async fn drain_background_tasks(&self) { + self.models_refresh_worker.shutdown(); self.thread_processor.drain_background_tasks().await; } diff --git a/codex-rs/app-server/src/models_refresh_worker.rs b/codex-rs/app-server/src/models_refresh_worker.rs new file mode 100644 index 0000000000..475c8acabf --- /dev/null +++ b/codex-rs/app-server/src/models_refresh_worker.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; +use std::time::Duration; + +use codex_models_manager::manager::RefreshStrategy; +use codex_models_manager::manager::SharedModelsManager; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +const MODELS_REFRESH_INTERVAL: Duration = Duration::from_secs(3 * 60); + +#[derive(Debug)] +pub(crate) struct ModelsRefreshWorker { + shutdown: CancellationToken, + _task: JoinHandle<()>, +} + +impl ModelsRefreshWorker { + pub(crate) fn shutdown(&self) { + self.shutdown.cancel(); + } +} + +impl Drop for ModelsRefreshWorker { + fn drop(&mut self) { + self.shutdown(); + } +} + +pub(crate) fn spawn(models_manager: &SharedModelsManager) -> ModelsRefreshWorker { + spawn_with_interval(models_manager, MODELS_REFRESH_INTERVAL) +} + +fn spawn_with_interval( + models_manager: &SharedModelsManager, + refresh_interval: Duration, +) -> ModelsRefreshWorker { + let models_manager = Arc::downgrade(models_manager); + let shutdown = CancellationToken::new(); + let worker_shutdown = shutdown.clone(); + let task = tokio::spawn(async move { + loop { + if worker_shutdown.is_cancelled() { + break; + } + let Some(models_manager) = models_manager.upgrade() else { + break; + }; + models_manager.list_models(RefreshStrategy::Online).await; + drop(models_manager); + + tokio::select! { + _ = worker_shutdown.cancelled() => break, + _ = tokio::time::sleep(refresh_interval) => {} + } + } + }); + ModelsRefreshWorker { + shutdown, + _task: task, + } +} + +#[cfg(test)] +#[path = "models_refresh_worker_tests.rs"] +mod tests; diff --git a/codex-rs/app-server/src/models_refresh_worker_tests.rs b/codex-rs/app-server/src/models_refresh_worker_tests.rs new file mode 100644 index 0000000000..4d7ff48d31 --- /dev/null +++ b/codex-rs/app-server/src/models_refresh_worker_tests.rs @@ -0,0 +1,90 @@ +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use codex_models_manager::manager::ModelsEndpointClient; +use codex_models_manager::manager::ModelsEndpointFuture; +use codex_models_manager::manager::OpenAiModelsManager; +use codex_models_manager::manager::SharedModelsManager; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CoreResult; +use codex_protocol::openai_models::ModelInfo; +use pretty_assertions::assert_eq; +use tempfile::tempdir; +use tokio::sync::Notify; + +use super::*; + +#[derive(Debug)] +struct TestModelsEndpoint { + fetch_count: AtomicUsize, + fetched: Notify, + release_second_fetch: Notify, +} + +impl TestModelsEndpoint { + fn new() -> Arc { + Arc::new(Self { + fetch_count: AtomicUsize::new(0), + fetched: Notify::new(), + release_second_fetch: Notify::new(), + }) + } + + async fn wait_for_fetch_count(&self, expected: usize) { + tokio::time::timeout(Duration::from_secs(1), async { + while self.fetch_count.load(Ordering::SeqCst) < expected { + self.fetched.notified().await; + } + }) + .await + .unwrap_or_else(|_| panic!("expected {expected} model fetches")); + } +} + +impl ModelsEndpointClient for TestModelsEndpoint { + fn has_command_auth(&self) -> bool { + true + } + + fn uses_codex_backend(&self) -> ModelsEndpointFuture<'_, bool> { + Box::pin(async { false }) + } + + fn list_models<'a>( + &'a self, + _client_version: &'a str, + ) -> ModelsEndpointFuture<'a, CoreResult<(Vec, Option)>> { + Box::pin(async move { + let fetch_index = self.fetch_count.fetch_add(1, Ordering::SeqCst); + self.fetched.notify_one(); + if fetch_index == 0 { + return Err(CodexErr::Io(std::io::Error::other("test failure"))); + } + if fetch_index == 1 { + self.release_second_fetch.notified().await; + } + Ok((Vec::new(), None)) + }) + } +} + +#[tokio::test] +async fn refreshes_immediately_periodically_and_stops_when_dropped() { + let codex_home = tempdir().expect("temp dir"); + let endpoint = TestModelsEndpoint::new(); + let models_manager: SharedModelsManager = Arc::new(OpenAiModelsManager::new( + codex_home.path().to_path_buf(), + endpoint.clone(), + /*auth_manager*/ None, + )); + let worker = spawn_with_interval(&models_manager, Duration::from_millis(10)); + + endpoint.wait_for_fetch_count(/*expected*/ 2).await; + drop(worker); + endpoint.release_second_fetch.notify_one(); + tokio::time::sleep(Duration::from_millis(30)).await; + + assert_eq!(endpoint.fetch_count.load(Ordering::SeqCst), 2); +} diff --git a/codex-rs/app-server/tests/suite/v2/attestation.rs b/codex-rs/app-server/tests/suite/v2/attestation.rs index 567f37397a..d007614730 100644 --- a/codex-rs/app-server/tests/suite/v2/attestation.rs +++ b/codex-rs/app-server/tests/suite/v2/attestation.rs @@ -4,6 +4,7 @@ use app_test_support::ChatGptAuthFixture; use app_test_support::TestAppServer; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; +use app_test_support::write_models_cache; use codex_app_server_protocol::AttestationGenerateResponse; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::InitializeCapabilities; @@ -36,36 +37,26 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand { skip_if_no_network!(Ok(())); - let websocket_server = start_websocket_server_with_headers(vec![ - // App-server refreshes `/models` over HTTP during thread startup. It points at the same - // local test base URL, so let that non-websocket probe consume one connection before the - // websocket handshake under test arrives. - WebSocketConnectionConfig { - requests: Vec::new(), - response_headers: Vec::new(), - accept_delay: None, - close_after_requests: true, - }, - WebSocketConnectionConfig { - requests: vec![ - vec![ - responses::ev_response_created("warm-1"), - responses::ev_completed("warm-1"), - ], - vec![ - responses::ev_response_created("resp-1"), - responses::ev_assistant_message("msg-1", "Done"), - responses::ev_completed("resp-1"), - ], + let websocket_server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig { + requests: vec![ + vec![ + responses::ev_response_created("warm-1"), + responses::ev_completed("warm-1"), ], - response_headers: Vec::new(), - accept_delay: None, - close_after_requests: true, - }, - ]) + vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ], + ], + response_headers: Vec::new(), + accept_delay: None, + close_after_requests: true, + }]) .await; let codex_home = TempDir::new()?; + write_models_cache(codex_home.path())?; create_chatgpt_websocket_config( codex_home.path(), &websocket_server.uri().replacen("ws://", "http://", 1), diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs index 43076d1649..30f7f73beb 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_control.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -271,7 +271,15 @@ async fn listen_off_honors_persisted_remote_control_enable() -> Result<()> { .await?; let _app_server = TestAppServer::new_with_args(codex_home.path(), &["--listen", "off"]).await?; - timeout(STARTUP_TIMEOUT, listener.accept()).await??; + let request = timeout(STARTUP_TIMEOUT, read_http_request(&listener)).await??; + assert!( + request + .request_line + .starts_with("GET /backend-api/wham/remote/control/server ") + || request + .request_line + .starts_with("POST /backend-api/wham/remote/control/server/refresh ") + ); Ok(()) } @@ -852,9 +860,15 @@ impl BlockingRemoteControlBackend { { return; } - let Ok(_websocket) = listener.accept().await else { + let Ok(request) = read_http_request(&listener).await else { return; }; + if !request + .request_line + .starts_with("GET /backend-api/wham/remote/control/server ") + { + return; + } std::future::pending::<()>().await; } Err(err) => { @@ -1030,36 +1044,44 @@ async fn read_enroll_request(listener: &TcpListener) -> Result<(String, BufReade } async fn read_http_request(listener: &TcpListener) -> Result { - let (stream, _) = listener.accept().await?; - let mut reader = BufReader::new(stream); - - let mut request_line = String::new(); - reader.read_line(&mut request_line).await?; - let mut content_length = 0; loop { - let mut line = String::new(); - reader.read_line(&mut line).await?; - if line == "\r\n" { - break; - } - if let Some(value) = line - .trim_end() - .strip_prefix("content-length:") - .or_else(|| line.trim_end().strip_prefix("Content-Length:")) - { - content_length = value.trim().parse::()?; - } - } - let mut body = vec![0; content_length]; - if content_length > 0 { - reader.read_exact(&mut body).await?; - } + let (stream, _) = listener.accept().await?; + let mut reader = BufReader::new(stream); - Ok(HttpRequest { - request_line: request_line.trim_end().to_string(), - body: String::from_utf8(body)?, - reader, - }) + let mut request_line = String::new(); + reader.read_line(&mut request_line).await?; + let mut content_length = 0; + loop { + let mut line = String::new(); + reader.read_line(&mut line).await?; + if line == "\r\n" { + break; + } + if let Some(value) = line + .trim_end() + .strip_prefix("content-length:") + .or_else(|| line.trim_end().strip_prefix("Content-Length:")) + { + content_length = value.trim().parse::()?; + } + } + let mut body = vec![0; content_length]; + if content_length > 0 { + reader.read_exact(&mut body).await?; + } + + let request_line = request_line.trim_end().to_string(); + if request_line.starts_with("GET ") && request_line.contains("/v1/models?") { + respond_with_json(reader.into_inner(), serde_json::json!({ "models": [] })).await?; + continue; + } + + return Ok(HttpRequest { + request_line, + body: String::from_utf8(body)?, + reader, + }); + } } async fn respond_with_json(stream: TcpStream, body: serde_json::Value) -> Result<()> { diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 9eb0c6f300..b071535c52 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -1237,9 +1237,11 @@ pub async fn start_websocket_server_with_headers( Ok(value) => value, Err(_) => return, }; + // Ordinary HTTP probes can share this listener with websocket tests. Only a + // successful websocket handshake should consume a scripted connection. let connection = { - let mut pending = connections.lock().unwrap(); - pending.pop_front() + let pending = connections.lock().unwrap(); + pending.front().cloned() }; let Some(connection) = connection else { @@ -1291,6 +1293,7 @@ pub async fn start_websocket_server_with_headers( Ok(ws) => ws, Err(_) => continue, }; + connections.lock().unwrap().pop_front(); let connection_index = { let mut log = requests.lock().unwrap(); From 0318381762e336686b281d8fd9e4c11769a0dfc0 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 17 Jun 2026 16:01:06 +0100 Subject: [PATCH 29/70] Replace SkillsManager with SkillsService (#28705) ## Why Host skill discovery was still exposed as a manager even though it is a process-owned service shared by sessions, the app-server catalog, and file-watcher invalidation. The skills extension also consumed an ad hoc loaded-skills wrapper instead of a named immutable snapshot. ## What changed - replace `SkillsManager` with concrete `SkillsService` - make the service cache and return immutable `HostSkillsSnapshot` values - migrate the skills extension host provider to the snapshot boundary - migrate app-server catalog, watcher, and invalidation paths to the service This keeps the service limited to host discovery, caching, roots, and invalidation. Catalog rendering and invocation remain extension responsibilities for the next stacked change. --- codex-rs/app-server/src/message_processor.rs | 2 +- .../request_processors/account_processor.rs | 2 +- .../request_processors/catalog_processor.rs | 13 +-- .../request_processors/config_processor.rs | 2 +- .../external_agent_config_processor.rs | 2 +- .../src/request_processors/plugins.rs | 4 +- codex-rs/app-server/src/skills_watcher.rs | 12 +-- codex-rs/core-api/src/lib.rs | 2 +- codex-rs/core-skills/src/lib.rs | 8 +- codex-rs/core-skills/src/model.rs | 8 +- .../src/{manager.rs => service.rs} | 60 ++++++----- .../{manager_tests.rs => service_tests.rs} | 99 ++++++++++--------- codex-rs/core/src/agent/role_tests.rs | 11 ++- codex-rs/core/src/codex_delegate.rs | 2 +- codex-rs/core/src/config/mod.rs | 2 +- codex-rs/core/src/guardian/tests.rs | 2 +- codex-rs/core/src/lib.rs | 4 +- codex-rs/core/src/session/mod.rs | 16 +-- codex-rs/core/src/session/review.rs | 7 +- codex-rs/core/src/session/session.rs | 14 +-- codex-rs/core/src/session/tests.rs | 65 ++++++------ .../core/src/session/tests/guardian_tests.rs | 4 +- codex-rs/core/src/session/turn.rs | 2 +- codex-rs/core/src/session/turn_context.rs | 28 +++--- codex-rs/core/src/skills.rs | 6 +- codex-rs/core/src/state/service.rs | 4 +- codex-rs/core/src/thread_manager.rs | 18 ++-- codex-rs/ext/skills/src/extension.rs | 23 ++--- codex-rs/ext/skills/src/provider.rs | 6 +- codex-rs/ext/skills/src/provider/host.rs | 35 +++---- codex-rs/ext/skills/src/tools/mod.rs | 2 +- codex-rs/ext/skills/src/tools/read.rs | 2 +- .../tests/executor_file_system_authority.rs | 6 +- codex-rs/ext/skills/tests/skills_extension.rs | 6 +- 34 files changed, 244 insertions(+), 235 deletions(-) rename codex-rs/core-skills/src/{manager.rs => service.rs} (85%) rename codex-rs/core-skills/src/{manager_tests.rs => service_tests.rs} (91%) diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 3c3600008a..985160985d 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -379,7 +379,7 @@ impl MessageProcessor { thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); - let skills_watcher = SkillsWatcher::new(thread_manager.skills_manager(), outgoing.clone()); + let skills_watcher = SkillsWatcher::new(thread_manager.skills_service(), outgoing.clone()); let pending_thread_unloads = Arc::new(Mutex::new(HashSet::new())); let thread_watch_manager = diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 1fa0961ddc..5d234f7002 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -219,7 +219,7 @@ impl AccountRequestProcessor { ) { tokio::spawn(async move { thread_manager.plugins_manager().clear_cache(); - thread_manager.skills_manager().clear_cache(); + thread_manager.skills_service().clear_cache(); if thread_manager.list_thread_ids().await.is_empty() { return; } diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index 9c0cc1c8b5..f61d0ed4b6 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -511,7 +511,7 @@ impl CatalogRequestProcessor { let workspace_codex_plugins_enabled = self .workspace_codex_plugins_enabled(&config, auth.as_ref()) .await; - let skills_manager = self.thread_manager.skills_manager(); + let skills_service = self.thread_manager.skills_service(); let plugins_manager = self.thread_manager.plugins_manager(); let fs = self .thread_manager @@ -523,7 +523,7 @@ impl CatalogRequestProcessor { let config = &config; let fs = fs.clone(); let plugins_manager = &plugins_manager; - let skills_manager = &skills_manager; + let skills_service = &skills_service; async move { let (cwd_abs, config_layer_stack) = match self.resolve_cwd_config(&cwd).await { Ok(resolved) => resolved, @@ -559,9 +559,10 @@ impl CatalogRequestProcessor { config_layer_stack, config.bundled_skills_enabled(), ); - let outcome = skills_manager - .skills_for_cwd(&skills_input, force_reload, fs) + let snapshot = skills_service + .snapshot_for_cwd(&skills_input, force_reload, fs) .await; + let outcome = snapshot.outcome(); let errors = errors_to_info(&outcome.errors); let skills = skills_to_info(&outcome.skills, &outcome.disabled_paths); ( @@ -590,7 +591,7 @@ impl CatalogRequestProcessor { self.skills_watcher .register_runtime_extra_roots(&extra_roots); self.thread_manager - .skills_manager() + .skills_service() .set_extra_roots(extra_roots); self.outgoing .send_server_notification(ServerNotification::SkillsChanged( @@ -703,7 +704,7 @@ impl CatalogRequestProcessor { .await .map(|()| { self.thread_manager.plugins_manager().clear_cache(); - self.thread_manager.skills_manager().clear_cache(); + self.thread_manager.skills_service().clear_cache(); SkillsConfigWriteResponse { effective_enabled: enabled, } diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 8370181075..15c3b7c864 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -169,7 +169,7 @@ impl ConfigRequestProcessor { pub(crate) async fn handle_config_mutation(&self) { self.thread_manager.plugins_manager().clear_cache(); - self.thread_manager.skills_manager().clear_cache(); + self.thread_manager.skills_service().clear_cache(); } async fn handle_config_mutation_result( diff --git a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs index 1a9002dd18..b3cdb0e213 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs @@ -319,7 +319,7 @@ impl ExternalAgentConfigRequestProcessor { completed_item_results.extend(background_item_results); if has_plugin_imports { thread_manager.plugins_manager().clear_cache(); - thread_manager.skills_manager().clear_cache(); + thread_manager.skills_service().clear_cache(); } send_completed_import_notification( &outgoing, diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 609d89459a..d2cd08b63a 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -474,7 +474,7 @@ impl PluginRequestProcessor { ) { tokio::spawn(async move { thread_manager.plugins_manager().clear_cache(); - thread_manager.skills_manager().clear_cache(); + thread_manager.skills_service().clear_cache(); if thread_manager.list_thread_ids().await.is_empty() { return; } @@ -484,7 +484,7 @@ impl PluginRequestProcessor { fn clear_plugin_related_caches(&self) { self.thread_manager.plugins_manager().clear_cache(); - self.thread_manager.skills_manager().clear_cache(); + self.thread_manager.skills_service().clear_cache(); } async fn load_latest_config( diff --git a/codex-rs/app-server/src/skills_watcher.rs b/codex-rs/app-server/src/skills_watcher.rs index 57edb89c7f..086e076d4b 100644 --- a/codex-rs/app-server/src/skills_watcher.rs +++ b/codex-rs/app-server/src/skills_watcher.rs @@ -8,7 +8,7 @@ use codex_app_server_protocol::SkillsChangedNotification; use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::skills::SkillsLoadInput; -use codex_core::skills::SkillsManager; +use codex_core::skills::SkillsService; use codex_file_watcher::FileWatcher; use codex_file_watcher::FileWatcherSubscriber; use codex_file_watcher::Receiver; @@ -35,7 +35,7 @@ pub(crate) struct SkillsWatcher { impl SkillsWatcher { pub(crate) fn new( - skills_manager: Arc, + skills_service: Arc, outgoing: Arc, ) -> Arc { let file_watcher = match FileWatcher::new() { @@ -48,7 +48,7 @@ impl SkillsWatcher { let (subscriber, rx) = file_watcher.add_subscriber(); let shutdown_token = CancellationToken::new(); let shutdown_drop_guard = shutdown_token.clone().drop_guard(); - Self::spawn_event_loop(rx, skills_manager, outgoing, shutdown_token.child_token()); + Self::spawn_event_loop(rx, skills_service, outgoing, shutdown_token.child_token()); Arc::new(Self { subscriber, runtime_extra_roots_registration: Mutex::new(WatchRegistration::default()), @@ -110,7 +110,7 @@ impl SkillsWatcher { config.bundled_skills_enabled(), ); let roots = thread_manager - .skills_manager() + .skills_service() .skill_roots_for_config(&skills_input, Some(environment.get_filesystem())) .await .into_iter() @@ -124,7 +124,7 @@ impl SkillsWatcher { fn spawn_event_loop( rx: Receiver, - skills_manager: Arc, + skills_service: Arc, outgoing: Arc, shutdown_token: CancellationToken, ) { @@ -142,7 +142,7 @@ impl SkillsWatcher { if event.is_none() { break; } - skills_manager.clear_cache(); + skills_service.clear_cache(); outgoing .send_server_notification(ServerNotification::SkillsChanged( SkillsChangedNotification {}, diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index df4e6ea8a6..aac0e21681 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -45,7 +45,7 @@ pub use codex_core::config::ThreadStoreConfig; pub use codex_core::config::find_codex_home; pub use codex_core::init_state_db; pub use codex_core::resolve_installation_id; -pub use codex_core::skills::SkillsManager; +pub use codex_core::skills::SkillsService; pub use codex_core::thread_store_from_config; pub use codex_exec_server::EnvironmentManager; pub use codex_exec_server::ExecServerError; diff --git a/codex-rs/core-skills/src/lib.rs b/codex-rs/core-skills/src/lib.rs index 0390302afd..31e5a7c531 100644 --- a/codex-rs/core-skills/src/lib.rs +++ b/codex-rs/core-skills/src/lib.rs @@ -2,20 +2,18 @@ pub mod config_rules; pub mod injection; pub(crate) mod invocation_utils; pub mod loader; -pub mod manager; mod mention_counts; pub mod model; pub mod remote; pub mod render; +pub mod service; mod skill_instructions; pub mod system; pub(crate) use invocation_utils::build_implicit_skill_path_indexes; pub use invocation_utils::detect_implicit_skill_invocation_for_command; -pub use manager::SkillsLoadInput; -pub use manager::SkillsManager; pub use mention_counts::build_skill_name_counts; -pub use model::HostLoadedSkills; +pub use model::HostSkillsSnapshot; pub use model::SkillError; pub use model::SkillLoadOutcome; pub use model::SkillMetadata; @@ -31,4 +29,6 @@ pub use render::SkillRenderReport; pub use render::build_available_skills; pub use render::default_skill_metadata_budget; pub use render::render_available_skills_body; +pub use service::SkillsLoadInput; +pub use service::SkillsService; pub use skill_instructions::SkillInstructions; diff --git a/codex-rs/core-skills/src/model.rs b/codex-rs/core-skills/src/model.rs index 3abb30c96d..6afbde6cc9 100644 --- a/codex-rs/core-skills/src/model.rs +++ b/codex-rs/core-skills/src/model.rs @@ -132,14 +132,14 @@ impl SkillLoadOutcome { } } -/// Host-loaded skills for one turn, including the filesystem mapping needed to -/// read skill bodies through the environment that loaded them. +/// Immutable snapshot of host-owned skills and the filesystem mapping needed +/// to read each skill through the environment that discovered it. #[derive(Debug, Clone)] -pub struct HostLoadedSkills { +pub struct HostSkillsSnapshot { outcome: Arc, } -impl HostLoadedSkills { +impl HostSkillsSnapshot { pub fn new(outcome: Arc) -> Self { Self { outcome } } diff --git a/codex-rs/core-skills/src/manager.rs b/codex-rs/core-skills/src/service.rs similarity index 85% rename from codex-rs/core-skills/src/manager.rs rename to codex-rs/core-skills/src/service.rs index f8c5bfd041..29d34c3809 100644 --- a/codex-rs/core-skills/src/manager.rs +++ b/codex-rs/core-skills/src/service.rs @@ -13,6 +13,7 @@ use tracing::info; use tracing::instrument; use tracing::warn; +use crate::HostSkillsSnapshot; use crate::SkillLoadOutcome; use crate::build_implicit_skill_path_indexes; use crate::config_rules::SkillConfigRules; @@ -49,15 +50,18 @@ impl SkillsLoadInput { } } -pub struct SkillsManager { +/// Owns host skill discovery, immutable snapshots, cache invalidation, and extra roots. +/// +/// Source-specific model exposure remains the responsibility of the skills extension. +pub struct SkillsService { codex_home: AbsolutePathBuf, restriction_product: Option, extra_roots: RwLock>, - cache_by_cwd: RwLock>, - cache_by_config: RwLock>, + cache_by_cwd: RwLock>, + cache_by_config: RwLock>, } -impl SkillsManager { +impl SkillsService { pub fn new(codex_home: AbsolutePathBuf, bundled_skills_enabled: bool) -> Self { Self::new_with_restriction_product(codex_home, bundled_skills_enabled, Some(Product::Codex)) } @@ -67,7 +71,7 @@ impl SkillsManager { bundled_skills_enabled: bool, restriction_product: Option, ) -> Self { - let manager = Self { + let service = Self { codex_home, restriction_product, extra_roots: RwLock::new(Vec::new()), @@ -77,11 +81,11 @@ impl SkillsManager { if !bundled_skills_enabled { // The loader caches bundled skills under `skills/.system`. Clearing that directory is // best-effort cleanup; root selection still enforces the config even if removal fails. - uninstall_system_skills(&manager.codex_home); - } else if let Err(err) = install_system_skills(&manager.codex_home) { + uninstall_system_skills(&service.codex_home); + } else if let Err(err) = install_system_skills(&service.codex_home) { tracing::error!("failed to install system skills: {err}"); } - manager + service } pub fn set_extra_roots(&self, extra_roots: Vec) { @@ -102,25 +106,27 @@ impl SkillsManager { /// cwd so role-local and session-local skill overrides cannot bleed across sessions that happen /// to share a directory. #[instrument(level = "trace", skip_all)] - pub async fn skills_for_config( + pub async fn snapshot_for_config( &self, input: &SkillsLoadInput, fs: Option>, - ) -> SkillLoadOutcome { + ) -> HostSkillsSnapshot { let roots = self.skill_roots_for_config(input, fs).await; let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack); let cache_key = config_skills_cache_key(&roots, &skill_config_rules); - if let Some(outcome) = self.cached_outcome_for_config(&cache_key) { - return outcome; + if let Some(snapshot) = self.cached_snapshot_for_config(&cache_key) { + return snapshot; } - let outcome = self.build_skill_outcome(roots, &skill_config_rules).await; + let snapshot = HostSkillsSnapshot::new(Arc::new( + self.build_skill_outcome(roots, &skill_config_rules).await, + )); let mut cache = self .cache_by_config .write() .unwrap_or_else(std::sync::PoisonError::into_inner); - cache.insert(cache_key, outcome.clone()); - outcome + cache.insert(cache_key, snapshot.clone()); + snapshot } pub async fn skill_roots_for_config( @@ -142,18 +148,18 @@ impl SkillsManager { roots } - pub async fn skills_for_cwd( + pub async fn snapshot_for_cwd( &self, input: &SkillsLoadInput, force_reload: bool, fs: Option>, - ) -> SkillLoadOutcome { + ) -> HostSkillsSnapshot { let use_cwd_cache = fs.is_some(); if use_cwd_cache && !force_reload - && let Some(outcome) = self.cached_outcome_for_cwd(&input.cwd) + && let Some(snapshot) = self.cached_snapshot_for_cwd(&input.cwd) { - return outcome; + return snapshot; } let mut roots = skill_roots( @@ -168,15 +174,17 @@ impl SkillsManager { roots.retain(|root| root.scope != SkillScope::System); } let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack); - let outcome = self.build_skill_outcome(roots, &skill_config_rules).await; + let snapshot = HostSkillsSnapshot::new(Arc::new( + self.build_skill_outcome(roots, &skill_config_rules).await, + )); if use_cwd_cache { let mut cache = self .cache_by_cwd .write() .unwrap_or_else(std::sync::PoisonError::into_inner); - cache.insert(input.cwd.clone(), outcome.clone()); + cache.insert(input.cwd.clone(), snapshot.clone()); } - outcome + snapshot } #[instrument(level = "trace", skip_all)] @@ -216,17 +224,17 @@ impl SkillsManager { info!("skills cache cleared ({cleared} entries)"); } - fn cached_outcome_for_cwd(&self, cwd: &AbsolutePathBuf) -> Option { + fn cached_snapshot_for_cwd(&self, cwd: &AbsolutePathBuf) -> Option { match self.cache_by_cwd.read() { Ok(cache) => cache.get(cwd).cloned(), Err(err) => err.into_inner().get(cwd).cloned(), } } - fn cached_outcome_for_config( + fn cached_snapshot_for_config( &self, cache_key: &ConfigSkillsCacheKey, - ) -> Option { + ) -> Option { match self.cache_by_config.read() { Ok(cache) => cache.get(cache_key).cloned(), Err(err) => err.into_inner().get(cache_key).cloned(), @@ -303,5 +311,5 @@ fn finalize_skill_outcome( } #[cfg(test)] -#[path = "manager_tests.rs"] +#[path = "service_tests.rs"] mod tests; diff --git a/codex-rs/core-skills/src/manager_tests.rs b/codex-rs/core-skills/src/service_tests.rs similarity index 91% rename from codex-rs/core-skills/src/manager_tests.rs rename to codex-rs/core-skills/src/service_tests.rs index 713b94d7b1..bc05ebf350 100644 --- a/codex-rs/core-skills/src/manager_tests.rs +++ b/codex-rs/core-skills/src/service_tests.rs @@ -159,7 +159,7 @@ enabled = {enabled} } async fn skills_for_config_with_stack( - skills_manager: &SkillsManager, + skills_service: &SkillsService, cwd: &TempDir, config_layer_stack: &ConfigLayerStack, effective_skill_roots: &[PluginSkillRoot], @@ -170,9 +170,11 @@ async fn skills_for_config_with_stack( config_layer_stack.clone(), bundled_skills_enabled_from_stack(config_layer_stack), ); - skills_manager - .skills_for_config(&skills_input, Some(Arc::clone(&LOCAL_FS))) + skills_service + .snapshot_for_config(&skills_input, Some(Arc::clone(&LOCAL_FS))) .await + .outcome() + .clone() } #[test] @@ -183,7 +185,7 @@ fn new_with_disabled_bundled_skills_removes_stale_cached_system_skills() { fs::write(stale_system_skill_dir.join("SKILL.md"), "# stale\n") .expect("write stale system skill"); - let _skills_manager = SkillsManager::new( + let _skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ false, ); @@ -199,14 +201,14 @@ async fn skills_for_config_reuses_cache_for_same_effective_config() { let codex_home = tempfile::tempdir().expect("tempdir"); let cwd = tempfile::tempdir().expect("tempdir"); let config_layer_stack = config_stack(&codex_home, ""); - let skills_manager = SkillsManager::new( + let skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, ); write_user_skill(&codex_home, "a", "skill-a", "from a"); let outcome1 = - skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + skills_for_config_with_stack(&skills_service, &cwd, &config_layer_stack, &[]).await; assert!( outcome1.skills.iter().any(|s| s.name == "skill-a"), "expected skill-a to be discovered" @@ -216,7 +218,7 @@ async fn skills_for_config_reuses_cache_for_same_effective_config() { // entry because the effective skill config is unchanged. write_user_skill(&codex_home, "b", "skill-b", "from b"); let outcome2 = - skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + skills_for_config_with_stack(&skills_service, &cwd, &config_layer_stack, &[]).await; assert_eq!(outcome2.errors, outcome1.errors); assert_eq!(outcome2.skills, outcome1.skills); } @@ -227,7 +229,7 @@ async fn set_extra_roots_replaces_runtime_roots_and_clears_cache() { let cwd = tempfile::tempdir().expect("tempdir"); let extra_root = tempfile::tempdir().expect("tempdir"); let config_layer_stack = config_stack(&codex_home, ""); - let skills_manager = SkillsManager::new( + let skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, ); @@ -238,13 +240,14 @@ async fn set_extra_roots_replaces_runtime_roots_and_clears_cache() { config_layer_stack.clone(), bundled_skills_enabled_from_stack(&config_layer_stack), ); - let empty_outcome = skills_manager - .skills_for_cwd( + let empty_snapshot = skills_service + .snapshot_for_cwd( &skills_input, /*force_reload*/ false, Some(Arc::clone(&LOCAL_FS)), ) .await; + let empty_outcome = empty_snapshot.outcome(); assert!( empty_outcome .skills @@ -260,15 +263,16 @@ async fn set_extra_roots_replaces_runtime_roots_and_clears_cache() { "---\nname: runtime-skill\ndescription: runtime skill\n---\n\n# Body\n", ) .expect("write skill"); - skills_manager.set_extra_roots(vec![extra_skills_root.abs()]); + skills_service.set_extra_roots(vec![extra_skills_root.abs()]); - let runtime_outcome = skills_manager - .skills_for_cwd( + let runtime_snapshot = skills_service + .snapshot_for_cwd( &skills_input, /*force_reload*/ false, Some(Arc::clone(&LOCAL_FS)), ) .await; + let runtime_outcome = runtime_snapshot.outcome(); assert!( runtime_outcome .skills @@ -276,14 +280,15 @@ async fn set_extra_roots_replaces_runtime_roots_and_clears_cache() { .any(|skill| skill.name == "runtime-skill") ); - skills_manager.set_extra_roots(vec![extra_root.path().join("missing-skills").abs()]); - let replaced_outcome = skills_manager - .skills_for_cwd( + skills_service.set_extra_roots(vec![extra_root.path().join("missing-skills").abs()]); + let replaced_snapshot = skills_service + .snapshot_for_cwd( &skills_input, /*force_reload*/ false, Some(Arc::clone(&LOCAL_FS)), ) .await; + let replaced_outcome = replaced_snapshot.outcome(); assert_eq!(replaced_outcome.errors, Vec::new()); assert!( replaced_outcome @@ -299,13 +304,13 @@ async fn set_extra_roots_applies_to_config_loads_and_empty_clears() { let cwd = tempfile::tempdir().expect("tempdir"); let extra_root = tempfile::tempdir().expect("tempdir"); let config_layer_stack = config_stack(&codex_home, ""); - let skills_manager = SkillsManager::new( + let skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, ); let empty_outcome = - skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + skills_for_config_with_stack(&skills_service, &cwd, &config_layer_stack, &[]).await; assert!( empty_outcome .skills @@ -321,10 +326,10 @@ async fn set_extra_roots_applies_to_config_loads_and_empty_clears() { "---\nname: runtime-skill\ndescription: runtime skill\n---\n\n# Body\n", ) .expect("write skill"); - skills_manager.set_extra_roots(vec![extra_skills_root.abs()]); + skills_service.set_extra_roots(vec![extra_skills_root.abs()]); let runtime_outcome = - skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + skills_for_config_with_stack(&skills_service, &cwd, &config_layer_stack, &[]).await; assert!( runtime_outcome .skills @@ -332,9 +337,9 @@ async fn set_extra_roots_applies_to_config_loads_and_empty_clears() { .any(|skill| skill.name == "runtime-skill") ); - skills_manager.set_extra_roots(Vec::new()); + skills_service.set_extra_roots(Vec::new()); let cleared_outcome = - skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + skills_for_config_with_stack(&skills_service, &cwd, &config_layer_stack, &[]).await; assert!( cleared_outcome .skills @@ -360,13 +365,13 @@ async fn skills_for_config_disables_plugin_skills_by_name() { &name_toggle_config("sample:sample-search", /*enabled*/ false), ); let plugin_skill_root = plugin_skill_root_for_skill_path(&skill_path, "test-plugin@test"); - let skills_manager = SkillsManager::new( + let skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, ); let outcome = skills_for_config_with_stack( - &skills_manager, + &skills_service, &cwd, &config_layer_stack, &[plugin_skill_root], @@ -427,18 +432,19 @@ async fn skills_for_cwd_loads_repo_and_user_roots_with_local_fs() { config_layer_stack.clone(), bundled_skills_enabled_from_stack(&config_layer_stack), ); - let skills_manager = SkillsManager::new( + let skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, ); - let outcome = skills_manager - .skills_for_cwd( + let snapshot = skills_service + .snapshot_for_cwd( &skills_input, /*force_reload*/ true, Some(Arc::clone(&LOCAL_FS)), ) .await; + let outcome = snapshot.outcome(); assert!( outcome.errors.is_empty(), @@ -490,14 +496,15 @@ async fn skills_for_cwd_without_fs_skips_repo_roots() { config_layer_stack.clone(), bundled_skills_enabled_from_stack(&config_layer_stack), ); - let skills_manager = SkillsManager::new( + let skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, ); - let outcome = skills_manager - .skills_for_cwd(&skills_input, /*force_reload*/ true, /*fs*/ None) + let snapshot = skills_service + .snapshot_for_cwd(&skills_input, /*force_reload*/ true, /*fs*/ None) .await; + let outcome = snapshot.outcome(); assert!( outcome.errors.is_empty(), @@ -525,7 +532,7 @@ async fn skills_for_config_excludes_bundled_skills_when_disabled_in_config() { ) .expect("write bundled skill"); let config_layer_stack = config_stack(&codex_home, "[skills.bundled]\nenabled = false\n"); - let skills_manager = SkillsManager::new( + let skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ false, ); @@ -540,7 +547,7 @@ async fn skills_for_config_excludes_bundled_skills_when_disabled_in_config() { .expect("rewrite bundled skill"); let outcome = - skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + skills_for_config_with_stack(&skills_service, &cwd, &config_layer_stack, &[]).await; assert!( outcome .skills @@ -560,24 +567,25 @@ async fn skills_for_cwd_uses_cached_result_until_force_reload() { let codex_home = tempfile::tempdir().expect("tempdir"); let cwd = tempfile::tempdir().expect("tempdir"); let config_layer_stack = config_stack(&codex_home, ""); - let skills_manager = SkillsManager::new( + let skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, ); - let _ = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + let _ = skills_for_config_with_stack(&skills_service, &cwd, &config_layer_stack, &[]).await; let base_input = SkillsLoadInput::new( cwd.path().abs(), Vec::new(), config_layer_stack.clone(), bundled_skills_enabled_from_stack(&config_layer_stack), ); - let outcome_a = skills_manager - .skills_for_cwd( + let snapshot_a = skills_service + .snapshot_for_cwd( &base_input, /*force_reload*/ false, Some(Arc::clone(&LOCAL_FS)), ) .await; + let outcome_a = snapshot_a.outcome(); assert!( outcome_a .skills @@ -587,13 +595,14 @@ async fn skills_for_cwd_uses_cached_result_until_force_reload() { write_user_skill(&codex_home, "late", "late-skill", "added after cache"); - let outcome_b = skills_manager - .skills_for_cwd( + let snapshot_b = skills_service + .snapshot_for_cwd( &base_input, /*force_reload*/ false, Some(Arc::clone(&LOCAL_FS)), ) .await; + let outcome_b = snapshot_b.outcome(); assert!( outcome_b .skills @@ -601,13 +610,14 @@ async fn skills_for_cwd_uses_cached_result_until_force_reload() { .all(|skill| skill.name != "late-skill") ); - let outcome_reloaded = skills_manager - .skills_for_cwd( + let snapshot_reloaded = skills_service + .snapshot_for_cwd( &base_input, /*force_reload*/ true, Some(Arc::clone(&LOCAL_FS)), ) .await; + let outcome_reloaded = snapshot_reloaded.outcome(); assert!( outcome_reloaded .skills @@ -775,7 +785,7 @@ async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill() let parent_stack = config_stack(&codex_home, &disabled_skill_config); let child_stack = config_stack_with_session_flags(&codex_home, &disabled_skill_config, &enabled_skill_config); - let skills_manager = SkillsManager::new( + let skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, ); @@ -786,13 +796,14 @@ async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill() bundled_skills_enabled_from_stack(&parent_stack), ); - let parent_outcome = skills_manager - .skills_for_cwd( + let parent_snapshot = skills_service + .snapshot_for_cwd( &parent_input, /*force_reload*/ true, Some(Arc::clone(&LOCAL_FS)), ) .await; + let parent_outcome = parent_snapshot.outcome(); let parent_skill = parent_outcome .skills .iter() @@ -801,7 +812,7 @@ async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill() assert_eq!(parent_outcome.is_skill_enabled(parent_skill), false); let child_outcome = - skills_for_config_with_stack(&skills_manager, &cwd, &child_stack, &[]).await; + skills_for_config_with_stack(&skills_service, &cwd, &child_stack, &[]).await; let child_skill = child_outcome .skills .iter() diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index 5461323a36..e258c0ad90 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::SkillsManager; +use crate::SkillsService; use crate::config::ConfigBuilder; use crate::skills_load_input_from_config; use codex_config::ConfigLayerStackOrdering; @@ -416,18 +416,19 @@ enabled = false .expect("custom role should apply"); let plugins_manager = Arc::new(PluginsManager::new(home.path().to_path_buf())); - let skills_manager = - SkillsManager::new(home.path().abs(), /*bundled_skills_enabled*/ true); + let skills_service = + SkillsService::new(home.path().abs(), /*bundled_skills_enabled*/ true); let plugins_input = config.plugins_config_input(); let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await; let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); let skills_input = skills_load_input_from_config(&config, effective_skill_roots); - let outcome = skills_manager - .skills_for_config( + let snapshot = skills_service + .snapshot_for_config( &skills_input, Some(Arc::clone(&codex_exec_server::LOCAL_FS)), ) .await; + let outcome = snapshot.outcome(); let skill = outcome .skills .iter() diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index d31dc138b5..16398a2f28 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -94,7 +94,7 @@ pub(crate) async fn run_codex_thread_interactive( .services .turn_environments .environment_manager(), - skills_manager: Arc::clone(&parent_session.services.skills_manager), + skills_service: Arc::clone(&parent_session.services.skills_service), plugins_manager: Arc::clone(&parent_session.services.plugins_manager), mcp_manager: Arc::clone(&parent_session.services.mcp_manager), extensions: Arc::clone(&parent_session.services.extensions), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 3a6120b9f0..72d3d45c9d 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -3874,7 +3874,7 @@ impl Config { } pub fn bundled_skills_enabled(&self) -> bool { - crate::manager::bundled_skills_enabled_from_stack(&self.config_layer_stack) + crate::skills::service::bundled_skills_enabled_from_stack(&self.config_layer_stack) } } diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index fefe15cb77..b19cd9d35e 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -1678,7 +1678,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() "---\nname: {GUARDIAN_SKILL_NAME}\ndescription: Guardian skill injection probe.\n---\n\n{GUARDIAN_SKILL_BODY_PROBE}\n" ), )?; - session.services.skills_manager.clear_cache(); + session.services.skills_service.clear_cache(); turn.config = Arc::clone(&config); turn.provider = create_model_provider(config.model_provider.clone(), turn.auth_manager.clone()); let session = Arc::new(session); diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 073c06cede..0641d7226e 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -87,16 +87,14 @@ mod session_prefix; mod session_startup_prewarm; pub mod skills; pub(crate) use skills::SkillInjections; -pub(crate) use skills::SkillLoadOutcome; pub(crate) use skills::SkillMetadata; -pub(crate) use skills::SkillsManager; +pub(crate) use skills::SkillsService; pub(crate) use skills::build_available_skills; pub(crate) use skills::build_skill_injections; pub(crate) use skills::build_skill_name_counts; pub(crate) use skills::collect_explicit_skill_mentions; pub(crate) use skills::default_skill_metadata_budget; pub(crate) use skills::injection; -pub(crate) use skills::manager; pub(crate) use skills::maybe_emit_implicit_skill_invocation; pub(crate) use skills::skills_load_input_from_config; mod stream_events_utils; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index d61de71bc5..01d6246ce7 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -291,11 +291,9 @@ pub(crate) struct PreviousTurnSettings { pub(crate) realtime_active: Option, } -#[cfg(test)] -use crate::SkillLoadOutcome; #[cfg(test)] use crate::SkillMetadata; -use crate::SkillsManager; +use crate::SkillsService; use crate::agents_md::load_project_instructions; use crate::exec_policy::ExecPolicyUpdateError; use crate::guardian::GuardianReviewSessionManager; @@ -304,6 +302,8 @@ use crate::network_policy_decision::execpolicy_network_rule_amendment; use crate::rollout::map_session_init_error; use crate::session_startup_prewarm::SessionStartupPrewarmHandle; use crate::shell; +#[cfg(test)] +use crate::skills::SkillLoadOutcome; use crate::state::AutoCompactWindowSnapshot; use crate::state::PendingRequestPermissions; use crate::state::SessionServices; @@ -409,7 +409,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) auth_manager: Arc, pub(crate) models_manager: SharedModelsManager, pub(crate) environment_manager: Arc, - pub(crate) skills_manager: Arc, + pub(crate) skills_service: Arc, pub(crate) plugins_manager: Arc, pub(crate) mcp_manager: Arc, pub(crate) extensions: Arc>, @@ -495,7 +495,7 @@ impl Codex { auth_manager, models_manager, environment_manager, - skills_manager, + skills_service, plugins_manager, mcp_manager, extensions, @@ -651,7 +651,7 @@ impl Codex { agent_status_tx.clone(), conversation_history, session_source_clone, - skills_manager, + skills_service, plugins_manager, mcp_manager.clone(), extensions, @@ -1507,7 +1507,7 @@ impl Session { (previous_config, new_config, config) }; self.emit_config_changed_contributors(previous_config.as_ref(), new_config.as_ref()); - self.services.skills_manager.clear_cache(); + self.services.skills_service.clear_cache(); self.services.plugins_manager.clear_cache(); let environments = self.services.turn_environments.snapshot().await; let hooks = build_hooks_for_config( @@ -2969,7 +2969,7 @@ impl Session { } if turn_context.config.include_skill_instructions { let available_skills = build_available_skills( - &turn_context.turn_skills.outcome, + turn_context.turn_skills.snapshot.outcome(), default_skill_metadata_budget(turn_context.model_info.context_window), SkillRenderSideEffects::ThreadStart { session_telemetry: &self.services.session_telemetry, diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 0296d9e44a..bbdfc3f984 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -1,5 +1,4 @@ use super::*; -use codex_core_skills::HostLoadedSkills; use std::sync::atomic::AtomicBool; /// Spawn a review thread using the given prompt. @@ -100,9 +99,7 @@ pub(super) async fn spawn_review_thread( let extension_data = Arc::new(codex_extension_api::ExtensionData::new( review_turn_id.clone(), )); - extension_data.insert(HostLoadedSkills::new( - parent_turn_context.turn_skills.outcome.clone(), - )); + extension_data.insert(parent_turn_context.turn_skills.snapshot.clone()); let review_turn_context = TurnContext { sub_id: review_turn_id.clone(), @@ -138,7 +135,7 @@ pub(super) async fn spawn_review_thread( dynamic_tools: parent_turn_context.dynamic_tools.clone(), turn_metadata_state, extension_data, - turn_skills: TurnSkillsContext::new(parent_turn_context.turn_skills.outcome.clone()), + turn_skills: TurnSkillsContext::new(parent_turn_context.turn_skills.snapshot.clone()), turn_timing_state: Arc::new(TurnTimingState::default()), terminal_error: Arc::new(Mutex::new(None)), server_model_warning_emitted: AtomicBool::new(false), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 578152814b..c4bc064a47 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -438,7 +438,7 @@ pub(crate) struct AppServerClientMetadata { async fn warm_plugins_and_skills_for_session_init( config: Arc, plugins_manager: Arc, - skills_manager: Arc, + skills_service: Arc, turn_environments: &TurnEnvironmentSnapshot, ) -> Vec { let fs = turn_environments.primary_filesystem(); @@ -446,10 +446,12 @@ async fn warm_plugins_and_skills_for_session_init( let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await; let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); let skills_input = skills_load_input_from_config(config.as_ref(), effective_skill_roots); - skills_manager - .skills_for_config(&skills_input, fs) + skills_service + .snapshot_for_config(&skills_input, fs) .await + .outcome() .errors + .clone() } impl Session { @@ -477,7 +479,7 @@ impl Session { agent_status: watch::Sender, initial_history: InitialHistory, session_source: SessionSource, - skills_manager: Arc, + skills_service: Arc, plugins_manager: Arc, mcp_manager: Arc, extensions: Arc>, @@ -828,7 +830,7 @@ impl Session { let plugin_skill_errors = warm_plugins_and_skills_for_session_init( Arc::clone(&config), Arc::clone(&plugins_manager), - Arc::clone(&skills_manager), + Arc::clone(&skills_service), &resolved_environments, ) .instrument(info_span!( @@ -999,7 +1001,7 @@ impl Session { guardian_rejections: Mutex::new(HashMap::new()), guardian_rejection_circuit_breaker: Mutex::new(Default::default()), runtime_handle: tokio::runtime::Handle::current(), - skills_manager, + skills_service, plugins_manager: Arc::clone(&plugins_manager), mcp_manager: Arc::clone(&mcp_manager), extensions, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index a26fb6599f..84c4a38887 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -24,6 +24,7 @@ use codex_config::RequirementSource; use codex_config::Sourced; use codex_config::loader::project_trust_key; use codex_config::types::ToolSuggestDisabledTool; +use codex_core_skills::HostSkillsSnapshot; use core_test_support::test_codex::local_selections; use codex_features::Feature; @@ -4434,15 +4435,16 @@ async fn new_default_turn_uses_config_aware_skills_for_role_overrides() { .default_environment() .map(|environment| environment.get_filesystem()) .unwrap_or_else(|| std::sync::Arc::clone(&codex_exec_server::LOCAL_FS)); - let parent_outcome = session + let parent_snapshot = session .services - .skills_manager - .skills_for_cwd( + .skills_service + .snapshot_for_cwd( &crate::skills_load_input_from_config(&parent_config, Vec::new()), /*force_reload*/ true, Some(Arc::clone(&skill_fs)), ) .await; + let parent_outcome = parent_snapshot.outcome(); let parent_skill = parent_outcome .skills .iter() @@ -4488,13 +4490,18 @@ enabled = false .await; let child_skill = child_turn .turn_skills - .outcome + .snapshot + .outcome() .skills .iter() .find(|skill| skill.name == "demo-skill") .expect("demo skill should be discovered"); assert_eq!( - child_turn.turn_skills.outcome.is_skill_enabled(child_skill), + child_turn + .turn_skills + .snapshot + .outcome() + .is_skill_enabled(child_skill), false ); } @@ -4825,7 +4832,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new( + let skills_service = Arc::new(SkillsService::new( config.codex_home.clone(), /*bundled_skills_enabled*/ true, )); @@ -4842,7 +4849,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { agent_status_tx, InitialHistory::New, SessionSource::Exec, - skills_manager, + skills_service, plugins_manager, mcp_manager, Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), @@ -4961,7 +4968,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { ); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new( + let skills_service = Arc::new(SkillsService::new( config.codex_home.clone(), /*bundled_skills_enabled*/ true, )); @@ -5000,7 +5007,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { guardian_rejections: Mutex::new(std::collections::HashMap::new()), guardian_rejection_circuit_breaker: Mutex::new(Default::default()), runtime_handle: tokio::runtime::Handle::current(), - skills_manager, + skills_service, plugins_manager, mcp_manager, extensions: Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), @@ -5045,12 +5052,10 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let skills_input = crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots); let skill_fs = environment.get_filesystem(); - let skills_outcome = Arc::new( - services - .skills_manager - .skills_for_config(&skills_input, Some(Arc::clone(&skill_fs))) - .await, - ); + let skills_snapshot = services + .skills_service + .snapshot_for_config(&skills_input, Some(Arc::clone(&skill_fs))) + .await; let turn_context = Session::make_turn_context( thread_id, SessionId::from(thread_id), @@ -5069,7 +5074,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { resolved_turn_environments, session_configuration.cwd().clone(), "turn_id".to_string(), - skills_outcome, + skills_snapshot, ); let session = Session { @@ -5173,7 +5178,7 @@ async fn make_session_with_config_and_rx( let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new( + let skills_service = Arc::new(SkillsService::new( config.codex_home.clone(), /*bundled_skills_enabled*/ true, )); @@ -5191,7 +5196,7 @@ async fn make_session_with_config_and_rx( agent_status_tx, InitialHistory::New, SessionSource::Exec, - skills_manager, + skills_service, plugins_manager, mcp_manager, Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), @@ -5277,7 +5282,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new( + let skills_service = Arc::new(SkillsService::new( config.codex_home.clone(), /*bundled_skills_enabled*/ true, )); @@ -5295,7 +5300,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( agent_status_tx, initial_history, session_source, - skills_manager, + skills_service, plugins_manager, mcp_manager, Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), @@ -7006,7 +7011,7 @@ where ); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new( + let skills_service = Arc::new(SkillsService::new( config.codex_home.clone(), /*bundled_skills_enabled*/ true, )); @@ -7045,7 +7050,7 @@ where guardian_rejections: Mutex::new(std::collections::HashMap::new()), guardian_rejection_circuit_breaker: Mutex::new(Default::default()), runtime_handle: tokio::runtime::Handle::current(), - skills_manager, + skills_service, plugins_manager, mcp_manager, extensions: Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), @@ -7090,12 +7095,10 @@ where let skills_input = crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots); let skill_fs = environment.get_filesystem(); - let skills_outcome = Arc::new( - services - .skills_manager - .skills_for_config(&skills_input, Some(Arc::clone(&skill_fs))) - .await, - ); + let skills_snapshot = services + .skills_service + .snapshot_for_config(&skills_input, Some(Arc::clone(&skill_fs))) + .await; let turn_context = Arc::new(Session::make_turn_context( thread_id, SessionId::from(thread_id), @@ -7114,7 +7117,7 @@ where resolved_turn_environments, session_configuration.cwd().clone(), "turn_id".to_string(), - skills_outcome, + skills_snapshot, )); let session = Arc::new(Session { @@ -7781,7 +7784,7 @@ async fn build_initial_context_trims_skill_metadata_from_context_window_budget() }, ]; turn_context.model_info.context_window = Some(100); - turn_context.turn_skills = TurnSkillsContext::new(Arc::new(outcome)); + turn_context.turn_skills = TurnSkillsContext::new(HostSkillsSnapshot::new(Arc::new(outcome))); let initial_context = session.build_initial_context(&turn_context).await; let developer_texts = developer_input_texts(&initial_context); @@ -7932,7 +7935,7 @@ async fn build_initial_context_emits_thread_start_skill_warning_on_repeated_buil }, ]; turn_context.model_info.context_window = Some(100); - turn_context.turn_skills = TurnSkillsContext::new(Arc::new(outcome)); + turn_context.turn_skills = TurnSkillsContext::new(HostSkillsSnapshot::new(Arc::new(outcome))); let _ = session.build_initial_context(&turn_context).await; let warning_event = timeout(Duration::from_secs(1), rx.recv()) diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 43cd91a99d..b9727a0401 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -694,7 +694,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { config.model_provider.clone(), ); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf())); - let skills_manager = Arc::new(SkillsManager::new( + let skills_service = Arc::new(SkillsService::new( config.codex_home.clone(), /*bundled_skills_enabled*/ true, )); @@ -711,7 +711,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { auth_manager, models_manager, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), - skills_manager, + skills_service, plugins_manager, mcp_manager, extensions: codex_extension_api::empty_extension_registry(), diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 7da157cf7b..4beef792bb 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -528,7 +528,7 @@ async fn build_skills_and_plugins( } else { Vec::new() }; - let skills_outcome = turn_context.turn_skills.outcome.as_ref(); + let skills_outcome = turn_context.turn_skills.snapshot.outcome(); let connector_slug_counts = build_connector_slug_counts(&available_connectors); let extension_injection_items = build_extension_turn_input_items(sess, turn_context, &user_input, cancellation_token) diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index b76c9384f7..cd01f92b9d 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -1,9 +1,8 @@ use super::*; -use crate::SkillLoadOutcome; use crate::agents_md::LoadedAgentsMd; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::shell_snapshot::ShellSnapshotFile; -use codex_core_skills::HostLoadedSkills; +use codex_core_skills::HostSkillsSnapshot; use codex_file_system::FileSystemSandboxContext; use codex_model_provider::SharedModelProvider; use codex_model_provider::create_model_provider; @@ -26,14 +25,14 @@ use tracing::instrument; #[derive(Clone, Debug)] pub(crate) struct TurnSkillsContext { - pub(crate) outcome: Arc, + pub(crate) snapshot: HostSkillsSnapshot, pub(crate) implicit_invocation_seen_skills: Arc>>, } impl TurnSkillsContext { - pub(crate) fn new(outcome: Arc) -> Self { + pub(crate) fn new(snapshot: HostSkillsSnapshot) -> Self { Self { - outcome, + snapshot, implicit_invocation_seen_skills: Arc::new(Mutex::new(HashSet::new())), } } @@ -488,7 +487,7 @@ impl Session { environments: TurnEnvironmentSnapshot, cwd: AbsolutePathBuf, sub_id: String, - skills_outcome: Arc, + skills_snapshot: HostSkillsSnapshot, ) -> TurnContext { let reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); let reasoning_summary = session_configuration @@ -531,7 +530,7 @@ impl Session { )); let (current_date, timezone) = local_time_context(); let extension_data = Arc::new(codex_extension_api::ExtensionData::new(sub_id.clone())); - extension_data.insert(HostLoadedSkills::new(Arc::clone(&skills_outcome))); + extension_data.insert(skills_snapshot.clone()); TurnContext { sub_id, trace_id: current_span_trace_id(), @@ -569,7 +568,7 @@ impl Session { dynamic_tools: session_configuration.dynamic_tools.clone(), turn_metadata_state, extension_data, - turn_skills: TurnSkillsContext::new(skills_outcome), + turn_skills: TurnSkillsContext::new(skills_snapshot), turn_timing_state: Arc::new(TurnTimingState::default()), terminal_error: Arc::new(Mutex::new(None)), server_model_warning_emitted: AtomicBool::new(false), @@ -726,12 +725,11 @@ impl Session { let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots); let fs = primary_turn_environment .map(|turn_environment| turn_environment.environment.get_filesystem()); - let skills_outcome = Arc::new( - self.services - .skills_manager - .skills_for_config(&skills_input, fs) - .await, - ); + let skills_snapshot = self + .services + .skills_service + .snapshot_for_config(&skills_input, fs) + .await; let mut turn_context: TurnContext = Self::make_turn_context( self.thread_id(), self.session_id(), @@ -759,7 +757,7 @@ impl Session { turn_environments, cwd, sub_id, - skills_outcome, + skills_snapshot, ); turn_context.realtime_active = self.conversation.running_state().await.is_some(); diff --git a/codex-rs/core/src/skills.rs b/codex-rs/core/src/skills.rs index b11f79260a..2c1ba8245a 100644 --- a/codex-rs/core/src/skills.rs +++ b/codex-rs/core/src/skills.rs @@ -14,7 +14,7 @@ pub use codex_core_skills::SkillMetadata; pub use codex_core_skills::SkillPolicy; pub use codex_core_skills::SkillRenderReport; pub use codex_core_skills::SkillsLoadInput; -pub use codex_core_skills::SkillsManager; +pub use codex_core_skills::SkillsService; pub use codex_core_skills::build_available_skills; pub use codex_core_skills::build_skill_name_counts; pub use codex_core_skills::config_rules; @@ -26,11 +26,11 @@ pub use codex_core_skills::injection::SkillInjections; pub use codex_core_skills::injection::build_skill_injections; pub use codex_core_skills::injection::collect_explicit_skill_mentions; pub use codex_core_skills::loader; -pub use codex_core_skills::manager; pub use codex_core_skills::model; pub use codex_core_skills::remote; pub use codex_core_skills::render; pub use codex_core_skills::render::SkillRenderSideEffects; +pub use codex_core_skills::service; pub use codex_core_skills::system; pub(crate) fn skills_load_input_from_config( @@ -52,7 +52,7 @@ pub(crate) async fn maybe_emit_implicit_skill_invocation( workdir: &AbsolutePathBuf, ) { let Some(candidate) = detect_implicit_skill_invocation_for_command( - turn_context.turn_skills.outcome.as_ref(), + turn_context.turn_skills.snapshot.outcome(), command, workdir, ) else { diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 00380c6d64..9724d8b3ad 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::SkillsManager; +use crate::SkillsService; use crate::agent::AgentControl; use crate::attestation::AttestationProvider; use crate::client::ModelClient; @@ -61,7 +61,7 @@ pub(crate) struct SessionServices { pub(crate) guardian_rejections: Mutex>, pub(crate) guardian_rejection_circuit_breaker: Mutex, pub(crate) runtime_handle: Handle, - pub(crate) skills_manager: Arc, + pub(crate) skills_service: Arc, pub(crate) plugins_manager: Arc, pub(crate) mcp_manager: Arc, pub(crate) extensions: Arc>, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 2adb713a0e..63335c0129 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1,4 +1,4 @@ -use crate::SkillsManager; +use crate::SkillsService; use crate::agent::AgentControl; use crate::attestation::AttestationProvider; use crate::codex_thread::CodexThread; @@ -207,7 +207,7 @@ pub(crate) struct ThreadManagerState { auth_manager: Arc, models_manager: SharedModelsManager, environment_manager: Arc, - skills_manager: Arc, + skills_service: Arc, plugins_manager: Arc, mcp_manager: Arc, extensions: Arc>, @@ -281,7 +281,7 @@ impl ThreadManager { Arc::clone(&plugins_manager), Arc::clone(&extensions), )); - let skills_manager = Arc::new(SkillsManager::new_with_restriction_product( + let skills_service = Arc::new(SkillsService::new_with_restriction_product( codex_home, config.bundled_skills_enabled(), restriction_product, @@ -292,7 +292,7 @@ impl ThreadManager { thread_created_tx, models_manager: build_models_manager(config, auth_manager.clone()), environment_manager, - skills_manager, + skills_service, plugins_manager, mcp_manager, extensions, @@ -373,7 +373,7 @@ impl ThreadManager { auth_manager.get_api_auth_mode(), )); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new_with_restriction_product( + let skills_service = Arc::new(SkillsService::new_with_restriction_product( skills_codex_home, /*bundled_skills_enabled*/ true, restriction_product, @@ -395,7 +395,7 @@ impl ThreadManager { models_manager: create_model_provider(provider, Some(auth_manager.clone())) .models_manager(codex_home, /*config_model_catalog*/ None), environment_manager, - skills_manager, + skills_service, plugins_manager, mcp_manager, extensions: empty_extension_registry(), @@ -424,8 +424,8 @@ impl ThreadManager { self.state.auth_manager.clone() } - pub fn skills_manager(&self) -> Arc { - self.state.skills_manager.clone() + pub fn skills_service(&self) -> Arc { + self.state.skills_service.clone() } pub fn plugins_manager(&self) -> Arc { @@ -1406,7 +1406,7 @@ impl ThreadManagerState { auth_manager, models_manager: Arc::clone(&self.models_manager), environment_manager: Arc::clone(&self.environment_manager), - skills_manager: Arc::clone(&self.skills_manager), + skills_service: Arc::clone(&self.skills_service), plugins_manager: Arc::clone(&self.plugins_manager), mcp_manager: Arc::clone(&self.mcp_manager), extensions: Arc::clone(&self.extensions), diff --git a/codex-rs/ext/skills/src/extension.rs b/codex-rs/ext/skills/src/extension.rs index bf4d86d364..c82ce66423 100644 --- a/codex-rs/ext/skills/src/extension.rs +++ b/codex-rs/ext/skills/src/extension.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use codex_core_skills::HostLoadedSkills; +use codex_core_skills::HostSkillsSnapshot; use codex_core_skills::injection::InjectedHostSkillPrompts; use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_extension_api::ConfigContributor; @@ -121,7 +121,7 @@ where SkillListQuery { turn_id: thread_store.level_id().to_string(), executor_roots: thread_state.selected_roots().to_vec(), - host: None, + host_snapshot: None, include_host_skills: false, include_bundled_skills: config.bundled_skills_enabled, include_orchestrator_skills: thread_state.orchestrator_skills_enabled(), @@ -184,11 +184,11 @@ where }; let config = thread_state.config(); - let host_loaded_skills = turn_store.get::(); + let host_snapshot = turn_store.get::(); let query = SkillListQuery { turn_id: input.turn_id.clone(), executor_roots: thread_state.selected_roots().to_vec(), - host: host_loaded_skills.clone(), + host_snapshot: host_snapshot.clone(), include_host_skills: true, include_bundled_skills: config.bundled_skills_enabled, include_orchestrator_skills: thread_state.orchestrator_skills_enabled(), @@ -217,12 +217,7 @@ where let mut injected_host_skill_prompts = InjectedHostSkillPrompts::default(); for entry in &selected_entries { match self - .read_main_prompt( - entry, - host_loaded_skills.clone(), - session_store, - &thread_state, - ) + .read_main_prompt(entry, host_snapshot.clone(), session_store, &thread_state) .await { Ok(read_result) => { @@ -259,12 +254,12 @@ where } } - if let Some(host_loaded_skills) = &host_loaded_skills { + if let Some(host_snapshot) = &host_snapshot { for entry in selected_entries .iter() .filter(|entry| entry.authority.kind != SkillSourceKind::Host) { - for host_skill in host_loaded_skills + for host_skill in host_snapshot .outcome() .skills .iter() @@ -319,7 +314,7 @@ impl SkillsExtension { async fn read_main_prompt( &self, entry: &SkillCatalogEntry, - host_loaded_skills: Option>, + host_snapshot: Option>, session_store: &ExtensionData, thread_state: &SkillsThreadState, ) -> Result { @@ -330,7 +325,7 @@ impl SkillsExtension { authority: entry.authority.clone(), package: entry.id.clone(), resource: entry.main_prompt.clone(), - host: host_loaded_skills, + host_snapshot, mcp_resources: session_store.get::(), }, ) diff --git a/codex-rs/ext/skills/src/provider.rs b/codex-rs/ext/skills/src/provider.rs index 329c0c5813..405592e934 100644 --- a/codex-rs/ext/skills/src/provider.rs +++ b/codex-rs/ext/skills/src/provider.rs @@ -6,7 +6,7 @@ mod executor; mod host; mod orchestrator; -use codex_core_skills::HostLoadedSkills; +use codex_core_skills::HostSkillsSnapshot; use codex_mcp::McpResourceClient; use codex_protocol::capabilities::SelectedCapabilityRoot; @@ -26,7 +26,7 @@ pub use orchestrator::OrchestratorSkillProvider; pub struct SkillListQuery { pub turn_id: String, pub executor_roots: Vec, - pub host: Option>, + pub host_snapshot: Option>, pub include_host_skills: bool, pub include_bundled_skills: bool, pub include_orchestrator_skills: bool, @@ -38,7 +38,7 @@ pub struct SkillReadRequest { pub authority: SkillAuthority, pub package: SkillPackageId, pub resource: SkillResourceId, - pub host: Option>, + pub host_snapshot: Option>, pub mcp_resources: Option>, } diff --git a/codex-rs/ext/skills/src/provider/host.rs b/codex-rs/ext/skills/src/provider/host.rs index 63d0d4588c..2cee694216 100644 --- a/codex-rs/ext/skills/src/provider/host.rs +++ b/codex-rs/ext/skills/src/provider/host.rs @@ -18,12 +18,10 @@ use crate::provider::SkillSearchRequest; const HOST_AUTHORITY_ID: &str = "host"; -/// Host-owned skill provider backed by the already-loaded turn skills. +/// Host-owned skill provider backed by an immutable service snapshot. /// -/// The provider intentionally does not reload or cache host skills. Core owns -/// skill loading, including plugin roots, runtime extra roots, and the primary -/// environment filesystem. This adapter only maps that loaded outcome into the -/// skills-extension catalog/read contract. +/// Discovery and caching belong to `SkillsService`; this provider only maps a +/// snapshot into the authority-aware catalog/read contract. #[derive(Clone, Default)] pub struct HostSkillProvider; @@ -36,24 +34,24 @@ impl HostSkillProvider { impl SkillProvider for HostSkillProvider { fn list(&self, query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> { Box::pin(async move { - let Some(host_loaded_skills) = query.host else { + let Some(host_snapshot) = query.host_snapshot else { return Err(SkillProviderError::new( - "host skill provider requires loaded host skills", + "host skill provider requires a host skills snapshot", )); }; - Ok(catalog_from_outcome(host_loaded_skills.outcome())) + Ok(catalog_from_outcome(host_snapshot.outcome())) }) } fn read(&self, request: SkillReadRequest) -> SkillProviderFuture<'_, SkillReadResult> { Box::pin(async move { - let Some(host_loaded_skills) = request.host else { + let Some(host_snapshot) = request.host_snapshot else { return Err(SkillProviderError::new( - "host skill provider requires loaded host skills", + "host skill provider requires a host skills snapshot", )); }; - let Some(skill) = host_loaded_skills.outcome().skills.iter().find(|skill| { + let Some(skill) = host_snapshot.outcome().skills.iter().find(|skill| { let skill_path = skill.path_to_skills_md.to_string_lossy(); skill_path == request.resource.as_str() || skill_path.replace('\\', "/") == request.resource.as_str() @@ -64,15 +62,12 @@ impl SkillProvider for HostSkillProvider { ))); }; - let contents = host_loaded_skills - .read_skill_text(skill) - .await - .map_err(|err| { - SkillProviderError::new(format!( - "failed to read host skill resource {}: {err}", - request.resource.as_str() - )) - })?; + let contents = host_snapshot.read_skill_text(skill).await.map_err(|err| { + SkillProviderError::new(format!( + "failed to read host skill resource {}: {err}", + request.resource.as_str() + )) + })?; Ok(SkillReadResult { resource: request.resource, diff --git a/codex-rs/ext/skills/src/tools/mod.rs b/codex-rs/ext/skills/src/tools/mod.rs index a1898b1a2a..0d05064afb 100644 --- a/codex-rs/ext/skills/src/tools/mod.rs +++ b/codex-rs/ext/skills/src/tools/mod.rs @@ -68,7 +68,7 @@ impl SkillToolContext { self.providers.list_orchestrator_for_turn(SkillListQuery { turn_id: turn_id.to_string(), executor_roots: Vec::new(), - host: None, + host_snapshot: None, include_host_skills: false, include_bundled_skills: false, include_orchestrator_skills: true, diff --git a/codex-rs/ext/skills/src/tools/read.rs b/codex-rs/ext/skills/src/tools/read.rs index 358842aa1e..64587cab95 100644 --- a/codex-rs/ext/skills/src/tools/read.rs +++ b/codex-rs/ext/skills/src/tools/read.rs @@ -82,7 +82,7 @@ impl ToolExecutor for ReadTool { authority, package: SkillPackageId(args.package), resource: requested_resource.clone(), - host: None, + host_snapshot: None, mcp_resources: self.context.mcp_resources.clone(), }, ) diff --git a/codex-rs/ext/skills/tests/executor_file_system_authority.rs b/codex-rs/ext/skills/tests/executor_file_system_authority.rs index f3d17e3525..1eff53c3b9 100644 --- a/codex-rs/ext/skills/tests/executor_file_system_authority.rs +++ b/codex-rs/ext/skills/tests/executor_file_system_authority.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; -use codex_core_skills::HostLoadedSkills; +use codex_core_skills::HostSkillsSnapshot; use codex_core_skills::loader::SkillRoot; use codex_core_skills::loader::load_skills_from_roots; use codex_exec_server::CopyOptions; @@ -208,7 +208,7 @@ async fn skill_loading_and_reads_use_the_supplied_executor_file_system() { skill.path_to_skills_md, canonical_root.join("skill/SKILL.md") ); - let loaded = HostLoadedSkills::new(Arc::new(outcome)); + let loaded = HostSkillsSnapshot::new(Arc::new(outcome)); assert_eq!( loaded.read_skill_text(&skill).await.expect("skill body"), SKILL_CONTENTS @@ -242,7 +242,7 @@ async fn selected_root_id_distinguishes_identical_executor_paths() { }, }) .collect(), - host: None, + host_snapshot: None, include_host_skills: false, include_bundled_skills: true, include_orchestrator_skills: false, diff --git a/codex-rs/ext/skills/tests/skills_extension.rs b/codex-rs/ext/skills/tests/skills_extension.rs index bcf058d6b4..cd6f46d9c9 100644 --- a/codex-rs/ext/skills/tests/skills_extension.rs +++ b/codex-rs/ext/skills/tests/skills_extension.rs @@ -4,7 +4,7 @@ use std::sync::Mutex; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; -use codex_core_skills::HostLoadedSkills; +use codex_core_skills::HostSkillsSnapshot; use codex_core_skills::SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS; use codex_core_skills::SKILLS_INTRO_WITH_ABSOLUTE_PATHS; use codex_core_skills::SkillLoadOutcome; @@ -52,7 +52,7 @@ const DEMO_SKILL_CONTENTS: &str = "---\nname: demo\ndescription: Demo skill.\n---\n# Demo\n\nUse the demo skill.\n"; #[tokio::test] -async fn installed_extension_uses_host_loaded_skills() -> TestResult { +async fn installed_extension_uses_host_service_snapshot() -> TestResult { let codex_home = test_codex_home(); let skill_path = codex_home.join("skills").join("demo").join("SKILL.md"); std::fs::create_dir_all( @@ -97,7 +97,7 @@ async fn installed_extension_uses_host_loaded_skills() -> TestResult { let loaded_skills = Arc::new(outcome); let skill_prompt_path = skill_path_string.replace('\\', "/"); let turn_store = ExtensionData::new("turn-1"); - turn_store.insert(HostLoadedSkills::new(Arc::clone(&loaded_skills))); + turn_store.insert(HostSkillsSnapshot::new(Arc::clone(&loaded_skills))); let fragments = registry.turn_input_contributors()[0] .contribute( From c78911e37f0e59b01703af1c0f3c8e033b66255f Mon Sep 17 00:00:00 2001 From: Alex Zamoshchin Date: Wed, 17 Jun 2026 08:50:39 -0700 Subject: [PATCH 30/70] [ez][codex-rs] Support apps._default.default_tools_approval_mode (#27965) [from codex] ## Summary - add `default_tools_approval_mode` to `[apps._default]` and expose it through app-server v2 `config/read` - apply it after managed, per-tool, and per-app approval settings, before the built-in `auto` fallback - document the precedence, regenerate config/app-server schemas, and add unit plus end-to-end approval coverage ## Configuration ```toml [apps._default] default_tools_approval_mode = "prompt" ``` The effective precedence is managed requirements, tool-specific `approval_mode`, app-specific `default_tools_approval_mode`, `apps._default.default_tools_approval_mode`, then `auto`. ## Test plan - `just write-config-schema` - `just write-app-server-schema` - `just write-app-server-schema --experimental` - `just test -p codex-core app_tool_policy` - `just test -p codex-core mcp_turn_metadata` - `just test -p codex-config` - `just test -p codex-app-server-protocol` - `just test -p codex-app-server config_read_includes_apps` - `just fix -p codex-config -p codex-core -p codex-app-server-protocol -p codex-app-server` - `just fmt` --- .../codex_app_server_protocol.schemas.json | 10 ++++ .../codex_app_server_protocol.v2.schemas.json | 10 ++++ .../schema/json/v2/ConfigReadResponse.json | 10 ++++ .../schema/typescript/v2/AppsDefaultConfig.ts | 3 +- .../src/protocol/v2/config.rs | 1 + codex-rs/app-server/README.md | 9 +++ .../app-server/tests/suite/v2/config_rpc.rs | 12 ++++ codex-rs/config/src/types.rs | 4 ++ codex-rs/connectors/src/app_tool_policy.rs | 6 ++ .../connectors/src/app_tool_policy_tests.rs | 55 ++++++++++++++++++- codex-rs/core/config.schema.json | 8 +++ .../core/tests/suite/mcp_turn_metadata.rs | 11 ++-- 12 files changed, 130 insertions(+), 9 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index bd894898d1..60966655bd 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -6696,6 +6696,16 @@ } ] }, + "default_tools_approval_mode": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppToolApproval" + }, + { + "type": "null" + } + ] + }, "destructive_enabled": { "default": true, "type": "boolean" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index fdc3a342cf..9a121fa87b 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -898,6 +898,16 @@ } ] }, + "default_tools_approval_mode": { + "anyOf": [ + { + "$ref": "#/definitions/AppToolApproval" + }, + { + "type": "null" + } + ] + }, "destructive_enabled": { "default": true, "type": "boolean" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 3bc0a8e0a9..c7fa352940 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -143,6 +143,16 @@ } ] }, + "default_tools_approval_mode": { + "anyOf": [ + { + "$ref": "#/definitions/AppToolApproval" + }, + { + "type": "null" + } + ] + }, "destructive_enabled": { "default": true, "type": "boolean" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.ts index 8277320e69..6b841ef356 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AppToolApproval } from "./AppToolApproval"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; -export type AppsDefaultConfig = { enabled: boolean, approvals_reviewer: ApprovalsReviewer | null, destructive_enabled: boolean, open_world_enabled: boolean, }; +export type AppsDefaultConfig = { enabled: boolean, approvals_reviewer: ApprovalsReviewer | null, destructive_enabled: boolean, open_world_enabled: boolean, default_tools_approval_mode: AppToolApproval | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 7190e78765..4f299d0227 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -177,6 +177,7 @@ pub struct AppsDefaultConfig { pub destructive_enabled: bool, #[serde(default = "default_enabled")] pub open_world_enabled: bool, + pub default_tools_approval_mode: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 336f769d71..d1a4648588 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1825,15 +1825,24 @@ approvals_reviewer = "auto_review" [apps._default] approvals_reviewer = "user" +default_tools_approval_mode = "prompt" [apps.demo-app] approvals_reviewer = "auto_review" +default_tools_approval_mode = "approve" ``` Setting the app value to `"user"` routes its approval prompts to the user instead of Guardian; setting it to `"auto_review"` opts that app into Guardian review when allowed by configuration requirements. +Use `apps._default.default_tools_approval_mode` to set the approval mode for +tools without a per-app or per-tool override. Supported values are `"auto"`, +`"prompt"`, and `"approve"`. Tool-level `approval_mode` takes precedence over +the per-app `default_tools_approval_mode`, which takes precedence over the +`apps._default` value. Managed tool requirements take precedence over all of +these settings. When none are configured, the mode defaults to `"auto"`. + Invoke an app by inserting `$` in the text input. The slug is derived from the app name and lowercased with non-alphanumeric characters replaced by `-` (for example, "Demo App" becomes `$demo-app`). Add a `mention` input item (recommended) so the server uses the exact `app://` path rather than guessing by name. Plugins use the same `mention` item shape, but with `plugin://@` paths from `plugin/installed` or `plugin/list`. Example: diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index bfb7d81720..81ef145ef0 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -363,6 +363,7 @@ async fn config_read_includes_apps() -> Result<()> { r#" [apps._default] approvals_reviewer = "auto_review" +default_tools_approval_mode = "approve" [apps.app1] enabled = false @@ -402,6 +403,7 @@ default_tools_approval_mode = "prompt" approvals_reviewer: Some(ApprovalsReviewer::AutoReview), destructive_enabled: true, open_world_enabled: true, + default_tools_approval_mode: Some(AppToolApproval::Approve), }), apps: std::collections::HashMap::from([( "app1".to_string(), @@ -427,6 +429,16 @@ default_tools_approval_mode = "prompt" profile: None, } ); + assert_eq!( + origins + .get("apps._default.default_tools_approval_mode") + .expect("origin") + .name, + ConfigLayerSource::User { + file: user_file.clone(), + profile: None, + } + ); assert_eq!( origins.get("apps.app1.enabled").expect("origin").name, ConfigLayerSource::User { diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 044d09ca85..e459f9585b 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -417,6 +417,10 @@ pub struct AppsDefaultConfig { skip_serializing_if = "std::clone::Clone::clone" )] pub open_world_enabled: bool, + + /// Approval mode for tools unless overridden by per-app or per-tool settings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tools_approval_mode: Option, } /// Per-tool settings for a single app tool. diff --git a/codex-rs/connectors/src/app_tool_policy.rs b/codex-rs/connectors/src/app_tool_policy.rs index 5f1e512bec..fbc53693bf 100644 --- a/codex-rs/connectors/src/app_tool_policy.rs +++ b/codex-rs/connectors/src/app_tool_policy.rs @@ -162,6 +162,12 @@ fn app_tool_policy_from_apps_config( let approval = managed_approval .or_else(|| tool_config.and_then(|tool| tool.approval_mode)) .or_else(|| app.and_then(|app| app.default_tools_approval_mode)) + .or_else(|| { + input + .connector_id + .and(apps_config.default.as_ref()) + .and_then(|defaults| defaults.default_tools_approval_mode) + }) .unwrap_or(AppToolApproval::Auto); if !app_is_enabled(apps_config, input.connector_id) { diff --git a/codex-rs/connectors/src/app_tool_policy_tests.rs b/codex-rs/connectors/src/app_tool_policy_tests.rs index 45e01cabdf..6338e6c868 100644 --- a/codex-rs/connectors/src/app_tool_policy_tests.rs +++ b/codex-rs/connectors/src/app_tool_policy_tests.rs @@ -521,9 +521,59 @@ fn default_tools_enable_overrides_app_level_hints() { } #[test] -fn evaluator_uses_default_tools_approval_mode() { +fn evaluator_uses_apps_default_tools_approval_mode_only_with_connector_id() { let apps_config = AppsConfigToml { - default: None, + default: Some(AppsDefaultConfig { + default_tools_approval_mode: Some(AppToolApproval::Prompt), + ..defaults( + /*enabled*/ true, /*destructive_enabled*/ true, + /*open_world_enabled*/ true, + ) + }), + apps: HashMap::new(), + }; + + assert_eq!( + [ + policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/list", + /*tool_title*/ None, + /*destructive_hint*/ None, + /*open_world_hint*/ None, + /*managed_approval*/ None, + ), + policy_from_apps_config( + Some(&apps_config), + /*connector_id*/ None, + "events/list", + /*tool_title*/ None, + /*destructive_hint*/ None, + /*open_world_hint*/ None, + /*managed_approval*/ None, + ), + ], + [ + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Prompt, + }, + AppToolPolicy::default(), + ] + ); +} + +#[test] +fn evaluator_prefers_app_default_tools_approval_mode_over_apps_default() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + default_tools_approval_mode: Some(AppToolApproval::Approve), + ..defaults( + /*enabled*/ true, /*destructive_enabled*/ true, + /*open_world_enabled*/ true, + ) + }), apps: HashMap::from([( "calendar".to_string(), AppConfig { @@ -720,5 +770,6 @@ fn defaults( approvals_reviewer: None, destructive_enabled, open_world_enabled, + default_tools_approval_mode: None, } } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 66325bf0a5..9944c2994c 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -218,6 +218,14 @@ ], "description": "Reviewer for approval prompts unless overridden by per-app settings." }, + "default_tools_approval_mode": { + "allOf": [ + { + "$ref": "#/definitions/AppToolApproval" + } + ], + "description": "Approval mode for tools unless overridden by per-app or per-tool settings." + }, "destructive_enabled": { "description": "Whether tools with `destructive_hint = true` are allowed by default.", "type": "boolean" diff --git a/codex-rs/core/tests/suite/mcp_turn_metadata.rs b/codex-rs/core/tests/suite/mcp_turn_metadata.rs index 7fe0a95f44..0d403180d7 100644 --- a/codex-rs/core/tests/suite/mcp_turn_metadata.rs +++ b/codex-rs/core/tests/suite/mcp_turn_metadata.rs @@ -60,7 +60,7 @@ default_tools_approval_mode = "{approval_mode}" .with_user_config(&user_config_path, user_config); } -fn set_calendar_approval_mode_and_default_reviewer( +fn set_default_app_approval_mode_and_reviewer( config: &mut Config, approval_mode: AppToolApproval, default_approvals_reviewer: ApprovalsReviewer, @@ -75,8 +75,6 @@ fn set_calendar_approval_mode_and_default_reviewer( r#" [apps._default] approvals_reviewer = "{default_approvals_reviewer}" - -[apps.calendar] default_tools_approval_mode = "{approval_mode}" "# )) @@ -167,7 +165,7 @@ async fn approved_mcp_tool_call_metadata_records_prior_user_input_request() -> R .features .enable(Feature::ToolCallMcpElicitation) .expect("test config should allow feature update"); - set_calendar_approval_mode_and_default_reviewer( + set_default_app_approval_mode_and_reviewer( config, AppToolApproval::Prompt, ApprovalsReviewer::User, @@ -231,7 +229,8 @@ async fn approved_mcp_tool_call_metadata_records_prior_user_input_request() -> R } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn apps_default_auto_review_routes_actual_mcp_approval_to_guardian() -> Result<()> { +async fn apps_default_prompt_with_auto_review_routes_actual_mcp_approval_to_guardian() -> Result<()> +{ skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -285,7 +284,7 @@ async fn apps_default_auto_review_routes_actual_mcp_approval_to_guardian() -> Re .features .enable(Feature::ToolCallMcpElicitation) .expect("test config should allow feature update"); - set_calendar_approval_mode_and_default_reviewer( + set_default_app_approval_mode_and_reviewer( config, AppToolApproval::Prompt, ApprovalsReviewer::AutoReview, From ef75171f18fc5e57cc1a22d17efb274561e7de5b Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Wed, 17 Jun 2026 10:00:42 -0700 Subject: [PATCH 31/70] Run fs helper through Windows sandbox wrapper (#28359) ## Why This is the final PR in the Windows fs-helper sandbox stack and contains the actual bug fix. The exec-server filesystem helper is a direct-spawn path: it asks `SandboxManager` for a `SandboxExecRequest`, then launches the returned argv itself. That works on macOS and Linux because the transformed argv is already a self-contained sandbox wrapper. On Windows, the transformed request carried `WindowsRestrictedToken` metadata, but the direct-spawn fs-helper runner still launched the helper argv directly. That means Windows filesystem built-ins backed by the fs-helper could run with the parent Codex process permissions instead of the configured Windows sandbox. This PR makes the direct-spawn transform produce a self-contained Windows wrapper argv before fs-helper launches it. ## What Changed - Added `SandboxManager::transform_for_direct_spawn()` for callers that launch the returned argv themselves. - Wrapped Windows restricted-token direct-spawn requests with `codex.exe --run-as-windows-sandbox` and then marked the outer request as unsandboxed, matching the macOS/Linux wrapper argv shape. - Updated `exec-server/src/fs_sandbox.rs` to use the direct-spawn transform for fs-helper launches. - Materialized the inner `codex.exe --codex-run-as-fs-helper` executable into `.sandbox-bin` so the sandboxed user can run it. - Carried runtime workspace roots through `FileSystemSandboxContext` as `PathUri` values so `:workspace_roots` policies resolve correctly without sending native client paths over exec-server JSON. - Preserved wrapper setup identity environment needed by Windows sandbox setup without changing the serialized inner helper environment. ## Verification - `just bazel-lock-update` - `just bazel-lock-check` - `just test -p codex-sandboxing transform_for_direct_spawn_windows` - `just test -p codex-exec-server fs_sandbox::tests` - `just fix -p codex-windows-sandbox -p codex-sandboxing -p codex-exec-server -p codex-core -p codex-file-system` Local note: `just fmt` completed Rust formatting, but this workstation still fails the non-Rust formatter phases because uv cannot open its cache and the local buildifier/dotslash path is missing. --- codex-rs/Cargo.lock | 2 + codex-rs/core/src/exec.rs | 386 +----------------- codex-rs/core/src/sandboxing/mod.rs | 2 +- codex-rs/core/src/session/turn_context.rs | 6 + .../core/src/tools/runtimes/apply_patch.rs | 6 + codex-rs/exec-server/src/fs_sandbox.rs | 63 ++- codex-rs/exec-server/tests/common/mod.rs | 4 + .../exec-server/tests/file_system_windows.rs | 60 +++ codex-rs/file-system/src/lib.rs | 4 + codex-rs/sandboxing/Cargo.toml | 4 + codex-rs/sandboxing/src/lib.rs | 12 + codex-rs/sandboxing/src/manager.rs | 163 ++++++++ codex-rs/sandboxing/src/manager_tests.rs | 152 +++++++ codex-rs/sandboxing/src/windows.rs | 382 +++++++++++++++++ .../src/helper_materialization.rs | 12 +- codex-rs/windows-sandbox-rs/src/lib.rs | 2 + 16 files changed, 861 insertions(+), 399 deletions(-) create mode 100644 codex-rs/sandboxing/src/windows.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a0713e1711..844a84ef73 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3792,7 +3792,9 @@ dependencies = [ "codex-network-proxy", "codex-protocol", "codex-utils-absolute-path", + "codex-utils-home-dir", "codex-utils-path-uri", + "codex-windows-sandbox", "dunce", "libc", "pretty_assertions", diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 9468534d8f..2b390ddba2 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -1,9 +1,9 @@ #[cfg(unix)] use std::os::unix::process::ExitStatusExt; -use std::collections::BTreeSet; use std::collections::HashMap; use std::io; +#[cfg(target_os = "windows")] use std::path::Path; use std::path::PathBuf; use std::process::ExitStatus; @@ -24,7 +24,6 @@ use crate::spawn::SpawnChildRequest; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use codex_network_proxy::NetworkProxy; -use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::error::CodexErr; use codex_protocol::error::Result; use codex_protocol::error::SandboxErr; @@ -42,7 +41,14 @@ use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; -use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile; +use codex_sandboxing::WindowsSandboxFilesystemOverrides; +#[cfg(test)] +use codex_sandboxing::permission_profile_supports_windows_restricted_token_sandbox; +use codex_sandboxing::resolve_windows_elevated_filesystem_overrides; +use codex_sandboxing::resolve_windows_restricted_token_filesystem_overrides; +#[cfg(test)] +use codex_sandboxing::unsupported_windows_restricted_token_sandbox_reason; +use codex_sandboxing::windows_sandbox_uses_elevated_backend; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; @@ -96,34 +102,6 @@ pub struct ExecParams { pub arg0: Option, } -/// Resolved filesystem overrides for the Windows sandbox backends. -/// -/// The elevated Windows backend consumes extra deny-read paths plus explicit -/// read and write roots during setup/refresh. The unelevated restricted-token -/// backend only consumes extra deny-write carveouts on top of the legacy -/// `WorkspaceWrite` allow set. Read-root overrides are layered on top of the -/// baseline helper roots that the elevated setup path needs to launch the -/// sandboxed command; split policies that opt into platform defaults carry -/// that explicitly with the override. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct WindowsSandboxFilesystemOverrides { - pub(crate) read_roots_override: Option>, - pub(crate) read_roots_include_platform_defaults: bool, - pub(crate) write_roots_override: Option>, - pub(crate) additional_deny_read_paths: Vec, - pub(crate) additional_deny_write_paths: Vec, -} - -fn windows_sandbox_uses_elevated_backend( - sandbox_level: WindowsSandboxLevel, - proxy_enforced: bool, -) -> bool { - // Windows firewall enforcement is tied to the logon-user sandbox identities, so - // proxy-enforced sessions must use that backend even when the configured mode is - // the default restricted-token sandbox. - proxy_enforced || matches!(sandbox_level, WindowsSandboxLevel::Elevated) -} - #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ExecCapturePolicy { /// Shell-like execs keep the historical output cap and timeout behavior. @@ -997,352 +975,6 @@ async fn exec( consume_output(child, expiration, capture_policy, stdout_stream).await } -#[cfg_attr(not(target_os = "windows"), allow(dead_code))] -fn permission_profile_supports_windows_restricted_token_sandbox( - permission_profile: &PermissionProfile, -) -> bool { - match permission_profile { - PermissionProfile::Managed { file_system, .. } => { - !file_system.to_sandbox_policy().has_full_disk_write_access() - } - PermissionProfile::Disabled | PermissionProfile::External { .. } => false, - } -} - -#[cfg_attr(not(test), allow(dead_code))] -pub(crate) fn unsupported_windows_restricted_token_sandbox_reason( - sandbox: SandboxType, - permission_profile: &PermissionProfile, - sandbox_policy_cwd: &AbsolutePathBuf, - windows_sandbox_level: WindowsSandboxLevel, -) -> Option { - if windows_sandbox_level == WindowsSandboxLevel::Elevated { - resolve_windows_elevated_filesystem_overrides( - sandbox, - permission_profile, - sandbox_policy_cwd, - windows_sandbox_level == WindowsSandboxLevel::Elevated, - ) - .err() - } else { - resolve_windows_restricted_token_filesystem_overrides( - sandbox, - permission_profile, - sandbox_policy_cwd, - windows_sandbox_level, - ) - .err() - } -} - -pub(crate) fn resolve_windows_restricted_token_filesystem_overrides( - sandbox: SandboxType, - permission_profile: &PermissionProfile, - sandbox_policy_cwd: &AbsolutePathBuf, - windows_sandbox_level: WindowsSandboxLevel, -) -> std::result::Result, String> { - if sandbox != SandboxType::WindowsRestrictedToken - || windows_sandbox_level == WindowsSandboxLevel::Elevated - { - return Ok(None); - } - - let (file_system_sandbox_policy, network_sandbox_policy) = - permission_profile.to_runtime_permissions(); - - let needs_direct_runtime_enforcement = file_system_sandbox_policy - .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd); - - if permission_profile_supports_windows_restricted_token_sandbox(permission_profile) - && !needs_direct_runtime_enforcement - { - return Ok(None); - } - - if !permission_profile_supports_windows_restricted_token_sandbox(permission_profile) { - let permission_profile_name = permission_profile_display_name(permission_profile); - return Err(format!( - "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, permission_profile={permission_profile_name}; refusing to run unsandboxed", - file_system_sandbox_policy.kind, - )); - } - - // The restricted-token backend can still enforce split write restrictions, - // but its WRITE_RESTRICTED token does not make capability SID deny-read ACEs - // participate in read access checks. Read restrictions therefore require the - // elevated backend, even when the filesystem root remains readable. - if !windows_policy_has_root_read_access(&file_system_sandbox_policy, sandbox_policy_cwd) { - return Err( - "windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed" - .to_string(), - ); - } - - let additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths( - &file_system_sandbox_policy, - sandbox_policy_cwd, - )?; - if !additional_deny_read_paths.is_empty() { - return Err( - "windows unelevated restricted-token sandbox cannot enforce deny-read restrictions directly; refusing to run unsandboxed" - .to_string(), - ); - } - - let legacy_projection = compatibility_sandbox_policy_for_permission_profile( - permission_profile, - sandbox_policy_cwd.as_path(), - ); - let legacy_writable_roots = legacy_projection.get_writable_roots_with_cwd(sandbox_policy_cwd); - let split_writable_roots = - file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); - let legacy_root_paths: BTreeSet = legacy_writable_roots - .iter() - .map(|root| normalize_windows_override_path(root.root.as_path())) - .collect::>()?; - let split_root_paths: BTreeSet = split_writable_roots - .iter() - .map(|root| normalize_windows_override_path(root.root.as_path())) - .collect::>()?; - - if legacy_root_paths != split_root_paths { - return Err( - "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" - .to_string(), - ); - } - - for writable_root in &split_writable_roots { - for read_only_subpath in &writable_root.read_only_subpaths { - if split_writable_roots.iter().any(|candidate| { - candidate.root.as_path() != writable_root.root.as_path() - && candidate - .root - .as_path() - .starts_with(read_only_subpath.as_path()) - }) { - return Err( - "windows unelevated restricted-token sandbox cannot reopen writable descendants under read-only carveouts directly; refusing to run unsandboxed" - .to_string(), - ); - } - } - } - - let mut additional_deny_write_paths = BTreeSet::new(); - for split_root in &split_writable_roots { - let split_root_path = normalize_windows_override_path(split_root.root.as_path())?; - let Some(legacy_root) = legacy_writable_roots.iter().find(|candidate| { - normalize_windows_override_path(candidate.root.as_path()) - .is_ok_and(|candidate_path| candidate_path == split_root_path) - }) else { - return Err( - "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" - .to_string(), - ); - }; - - for read_only_subpath in &split_root.read_only_subpaths { - if !legacy_root - .read_only_subpaths - .iter() - .any(|candidate| candidate == read_only_subpath) - { - additional_deny_write_paths.insert(normalize_windows_override_path( - read_only_subpath.as_path(), - )?); - } - } - } - - if additional_deny_read_paths.is_empty() && additional_deny_write_paths.is_empty() { - return Ok(None); - } - - Ok(Some(WindowsSandboxFilesystemOverrides { - read_roots_override: None, - read_roots_include_platform_defaults: false, - write_roots_override: None, - additional_deny_read_paths, - additional_deny_write_paths: additional_deny_write_paths - .into_iter() - .map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string())) - .collect::>()?, - })) -} - -fn normalize_windows_override_path(path: &Path) -> std::result::Result { - AbsolutePathBuf::from_absolute_path(dunce::simplified(path)) - .map(AbsolutePathBuf::into_path_buf) - .map_err(|err| err.to_string()) -} - -fn windows_policy_has_root_read_access( - file_system_sandbox_policy: &FileSystemSandboxPolicy, - cwd: &AbsolutePathBuf, -) -> bool { - let Some(root) = cwd.as_path().ancestors().last() else { - return false; - }; - file_system_sandbox_policy.can_read_path_with_cwd(root, cwd.as_path()) -} - -pub(crate) fn resolve_windows_elevated_filesystem_overrides( - sandbox: SandboxType, - permission_profile: &PermissionProfile, - sandbox_policy_cwd: &AbsolutePathBuf, - use_windows_elevated_backend: bool, -) -> std::result::Result, String> { - if sandbox != SandboxType::WindowsRestrictedToken || !use_windows_elevated_backend { - return Ok(None); - } - - let (file_system_sandbox_policy, network_sandbox_policy) = - permission_profile.to_runtime_permissions(); - - if !permission_profile_supports_windows_restricted_token_sandbox(permission_profile) { - let permission_profile_name = permission_profile_display_name(permission_profile); - return Err(format!( - "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, permission_profile={permission_profile_name}; refusing to run unsandboxed", - file_system_sandbox_policy.kind, - )); - } - - let additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths( - &file_system_sandbox_policy, - sandbox_policy_cwd, - )?; - - let split_writable_roots = - file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); - if has_reopened_writable_descendant(&split_writable_roots) { - return Err( - "windows elevated sandbox cannot reopen writable descendants under read-only carveouts directly; refusing to run unsandboxed" - .to_string(), - ); - } - - let needs_direct_runtime_enforcement = file_system_sandbox_policy - .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd); - let normalize_path = |path: PathBuf| dunce::canonicalize(&path).unwrap_or(path); - let legacy_projection = compatibility_sandbox_policy_for_permission_profile( - permission_profile, - sandbox_policy_cwd.as_path(), - ); - let legacy_writable_roots = legacy_projection.get_writable_roots_with_cwd(sandbox_policy_cwd); - let legacy_root_paths: BTreeSet = legacy_writable_roots - .iter() - .map(|root| normalize_path(root.root.to_path_buf())) - .collect(); - let split_readable_roots: Vec = file_system_sandbox_policy - .get_readable_roots_with_cwd(sandbox_policy_cwd) - .into_iter() - .map(codex_utils_absolute_path::AbsolutePathBuf::into_path_buf) - .map(&normalize_path) - .collect(); - let split_root_paths: Vec = split_writable_roots - .iter() - .map(|root| normalize_path(root.root.to_path_buf())) - .collect(); - let split_root_path_set: BTreeSet = split_root_paths.iter().cloned().collect(); - - // `has_full_disk_read_access()` is intentionally false when deny-read - // entries exist. For Windows setup overrides, the important question is - // whether the baseline still reads from the filesystem root and only needs - // additional deny ACLs layered on top. - let split_has_root_read_access = - windows_policy_has_root_read_access(&file_system_sandbox_policy, sandbox_policy_cwd); - let read_roots_override = if split_has_root_read_access { - None - } else { - Some(split_readable_roots) - }; - - let write_roots_override = if split_root_path_set == legacy_root_paths { - None - } else { - Some(split_root_paths) - }; - - let additional_deny_write_paths = if needs_direct_runtime_enforcement { - let mut deny_paths = BTreeSet::new(); - for writable_root in &split_writable_roots { - let writable_root_path = normalize_path(writable_root.root.to_path_buf()); - let legacy_root = legacy_writable_roots.iter().find(|candidate| { - normalize_path(candidate.root.to_path_buf()) == writable_root_path - }); - for read_only_subpath in &writable_root.read_only_subpaths { - let read_only_subpath_suffix = read_only_subpath - .as_path() - .strip_prefix(writable_root.root.as_path()) - .ok(); - let already_denied_by_legacy = legacy_root.is_some_and(|legacy_root| { - legacy_root.read_only_subpaths.iter().any(|candidate| { - candidate - .as_path() - .strip_prefix(legacy_root.root.as_path()) - .ok() - == read_only_subpath_suffix - }) - }); - if !already_denied_by_legacy { - deny_paths.insert(normalize_path(read_only_subpath.to_path_buf())); - } - } - } - deny_paths - .into_iter() - .map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string())) - .collect::>()? - } else { - Vec::new() - }; - - if read_roots_override.is_none() - && write_roots_override.is_none() - && additional_deny_read_paths.is_empty() - && additional_deny_write_paths.is_empty() - { - return Ok(None); - } - - Ok(Some(WindowsSandboxFilesystemOverrides { - read_roots_include_platform_defaults: read_roots_override.is_some() - && file_system_sandbox_policy.include_platform_defaults(), - read_roots_override, - write_roots_override, - additional_deny_read_paths, - additional_deny_write_paths, - })) -} - -fn permission_profile_display_name(permission_profile: &PermissionProfile) -> &'static str { - match permission_profile { - PermissionProfile::Managed { .. } => "Managed", - PermissionProfile::Disabled => "Disabled", - PermissionProfile::External { .. } => "External", - } -} - -fn has_reopened_writable_descendant( - writable_roots: &[codex_protocol::protocol::WritableRoot], -) -> bool { - writable_roots.iter().any(|writable_root| { - writable_root - .read_only_subpaths - .iter() - .any(|read_only_subpath| { - writable_roots.iter().any(|candidate| { - candidate.root.as_path() != writable_root.root.as_path() - && candidate - .root - .as_path() - .starts_with(read_only_subpath.as_path()) - }) - }) - }) -} - /// Consumes the output of a child process according to the configured capture /// policy. async fn consume_output( diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index f1f53d80ef..009cd71da8 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -10,7 +10,6 @@ ExecRequest for execution. use crate::exec::ExecCapturePolicy; use crate::exec::ExecExpiration; use crate::exec::StdoutStream; -use crate::exec::WindowsSandboxFilesystemOverrides; use crate::exec::execute_exec_request; #[cfg(target_os = "macos")] use crate::spawn::CODEX_SANDBOX_ENV_VAR; @@ -24,6 +23,7 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_sandboxing::SandboxExecRequest; use codex_sandboxing::SandboxType; +use codex_sandboxing::WindowsSandboxFilesystemOverrides; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index cd01f92b9d..91880bdefa 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -330,6 +330,12 @@ impl TurnContext { FileSystemSandboxContext { permissions: permissions.into(), cwd: Some(cwd.clone()), + workspace_roots: self + .config + .effective_workspace_roots() + .iter() + .map(PathUri::from_abs_path) + .collect(), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self .config diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 9ad9d284c7..9d23fcab3c 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -33,6 +33,7 @@ use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; use codex_sandboxing::policy_transforms::effective_permission_profile; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use futures::future::BoxFuture; use std::path::PathBuf; use std::time::Instant; @@ -99,6 +100,11 @@ impl ApplyPatchRuntime { Some(FileSystemSandboxContext { permissions: permissions.into(), cwd: Some(attempt.sandbox_cwd.clone()), + workspace_roots: attempt + .workspace_roots + .iter() + .map(PathUri::from_abs_path) + .collect(), windows_sandbox_level: attempt.windows_sandbox_level, windows_sandbox_private_desktop: attempt.windows_sandbox_private_desktop, use_legacy_landlock: attempt.use_legacy_landlock, diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index 5b42f9a2f1..9e9dbcf6b4 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -9,6 +9,7 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxDirectSpawnTransformRequest; use codex_sandboxing::SandboxExecRequest; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; @@ -88,7 +89,7 @@ impl FileSystemSandboxRunner { &file_system_policy, network_policy, ); - let command = self.sandbox_exec_request(&permission_profile, &cwd.uri, sandbox)?; + let command = self.sandbox_exec_request(&permission_profile, &cwd, sandbox)?; let request_json = serde_json::to_vec(&request).map_err(json_error)?; run_command(command, request_json).await } @@ -96,7 +97,7 @@ impl FileSystemSandboxRunner { fn sandbox_exec_request( &self, permission_profile: &PermissionProfile, - cwd: &PathUri, + cwd: &SandboxCwd, sandbox_context: &FileSystemSandboxContext, ) -> Result { let helper = &self.runtime_paths.codex_self_exe; @@ -112,22 +113,36 @@ impl FileSystemSandboxRunner { let command = SandboxCommand { program: helper.as_path().as_os_str().to_owned(), args: vec![CODEX_FS_HELPER_ARG1.to_string()], - cwd: cwd.clone(), + cwd: cwd.uri.clone(), env: self.helper_env.clone(), additional_permissions: None, }; + let native_workspace_roots = sandbox_context + .workspace_roots + .iter() + .map(native_workspace_root) + .collect::, _>>()?; + let workspace_roots = if native_workspace_roots.is_empty() { + std::slice::from_ref(&cwd.native) + } else { + native_workspace_roots.as_slice() + }; sandbox_manager - .transform(SandboxTransformRequest { - command, - permissions: permission_profile, - sandbox, - enforce_managed_network: false, - network: None, - sandbox_policy_cwd: cwd, - codex_linux_sandbox_exe: self.runtime_paths.codex_linux_sandbox_exe.as_deref(), - use_legacy_landlock: sandbox_context.use_legacy_landlock, - windows_sandbox_level: sandbox_context.windows_sandbox_level, - windows_sandbox_private_desktop: sandbox_context.windows_sandbox_private_desktop, + .transform_for_direct_spawn(SandboxDirectSpawnTransformRequest { + workspace_roots, + transform: SandboxTransformRequest { + command, + permissions: permission_profile, + sandbox, + enforce_managed_network: false, + network: None, + sandbox_policy_cwd: &cwd.uri, + codex_linux_sandbox_exe: self.runtime_paths.codex_linux_sandbox_exe.as_deref(), + use_legacy_landlock: sandbox_context.use_legacy_landlock, + windows_sandbox_level: sandbox_context.windows_sandbox_level, + windows_sandbox_private_desktop: sandbox_context + .windows_sandbox_private_desktop, + }, }) .map_err(|err| invalid_request(format!("failed to prepare fs sandbox: {err}"))) } @@ -158,6 +173,14 @@ fn native_sandbox_cwd(cwd: &PathUri) -> Result Result { + root.to_abs_path().map_err(|err| { + invalid_request(format!( + "file system sandbox workspace root is not native to this exec-server host: {err}" + )) + }) +} + fn helper_read_roots(runtime_paths: &ExecServerRuntimePaths) -> Vec { let mut roots = Vec::new(); for path in std::iter::once(runtime_paths.codex_self_exe.as_path()) @@ -517,15 +540,21 @@ mod tests { let runner = FileSystemSandboxRunner::new(runtime_paths); let native_cwd = AbsolutePathBuf::current_dir().expect("cwd"); let cwd = PathUri::from_abs_path(&native_cwd); - let file_system_policy = - restricted_policy(vec![path_entry(native_cwd, FileSystemAccessMode::Write)]); + let file_system_policy = restricted_policy(vec![path_entry( + native_cwd.clone(), + FileSystemAccessMode::Write, + )]); let network_policy = NetworkSandboxPolicy::Restricted; let permission_profile = PermissionProfile::from_runtime_permissions(&file_system_policy, network_policy); let sandbox_context = sandbox_context_with_cwd(&file_system_policy, cwd.clone()); + let sandbox_cwd = SandboxCwd { + uri: cwd, + native: native_cwd, + }; let request = runner - .sandbox_exec_request(&permission_profile, &cwd, &sandbox_context) + .sandbox_exec_request(&permission_profile, &sandbox_cwd, &sandbox_context) .expect("sandbox exec request"); assert_eq!(request.env.get(&path_key), Some(&path)); diff --git a/codex-rs/exec-server/tests/common/mod.rs b/codex-rs/exec-server/tests/common/mod.rs index 387edf36db..9eba3c87c6 100644 --- a/codex-rs/exec-server/tests/common/mod.rs +++ b/codex-rs/exec-server/tests/common/mod.rs @@ -19,6 +19,7 @@ pub(crate) mod exec_server; pub(crate) const DELAYED_OUTPUT_AFTER_EXIT_PARENT_ARG: &str = "--codex-test-delayed-output-after-exit-parent"; +const CODEX_WINDOWS_SANDBOX_ARG1: &str = "--run-as-windows-sandbox"; const DELAYED_OUTPUT_AFTER_EXIT_CHILD_ARG: &str = "--codex-test-delayed-output-after-exit-child"; #[ctor] @@ -27,6 +28,9 @@ pub static TEST_BINARY_DISPATCH_GUARD: Option = { if argv1 == Some(CODEX_FS_HELPER_ARG1) { return TestBinaryDispatchMode::DispatchArg0Only; } + if argv1 == Some(CODEX_WINDOWS_SANDBOX_ARG1) { + return TestBinaryDispatchMode::DispatchArg0Only; + } if exe_name == CODEX_LINUX_SANDBOX_ARG0 { return TestBinaryDispatchMode::DispatchArg0Only; } diff --git a/codex-rs/exec-server/tests/file_system_windows.rs b/codex-rs/exec-server/tests/file_system_windows.rs index 9dd25343a1..fbf8b07330 100644 --- a/codex-rs/exec-server/tests/file_system_windows.rs +++ b/codex-rs/exec-server/tests/file_system_windows.rs @@ -12,9 +12,14 @@ use std::path::Path; use std::process::Command; use anyhow::Result; +use codex_exec_server::FileSystemSandboxContext; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::protocol::SandboxPolicy; +use codex_utils_path_uri::PathUri; use test_case::test_case; use crate::support::FileSystemImplementation; +use crate::support::create_file_system_context; fn create_directory_junction(target: &Path, alias: &Path) -> Result<()> { let output = Command::new("cmd") @@ -54,3 +59,58 @@ async fn file_system_sandboxed_canonicalize_resolves_directory_junction( ) .await } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_remote_fs_helper_respects_windows_sandbox_write_policy() -> Result<()> { + let context = create_file_system_context(FileSystemImplementation::Remote).await?; + let file_system = context.file_system; + let tmp = tempfile::TempDir::new()?; + let readonly_dir = tmp.path().join("readonly"); + std::fs::create_dir_all(&readonly_dir)?; + + let mut sandbox = read_only_sandbox_for_cwd(readonly_dir.clone())?; + sandbox.windows_sandbox_level = WindowsSandboxLevel::RestrictedToken; + + let readable_file = readonly_dir.join("readable.txt"); + std::fs::write(&readable_file, b"readable")?; + let read_result = file_system + .read_file(&PathUri::from_path(&readable_file)?, Some(&sandbox)) + .await; + // Some local Windows hosts cannot create restricted tokens. Reaching that + // error still proves the remote fs helper went through the Windows sandbox + // launcher; before the wrapper fix this read would have run unsandboxed. + if is_unsupported_restricted_token_host(&read_result) { + return Ok(()); + } + assert_eq!(read_result?, b"readable"); + + let blocked_file = readonly_dir.join("blocked.txt"); + let error = file_system + .write_file( + &PathUri::from_path(&blocked_file)?, + b"blocked".to_vec(), + Some(&sandbox), + ) + .await + .expect_err("write outside the sandbox should fail"); + assert!( + !blocked_file.exists(), + "sandboxed fs helper must not create blocked file after error: {error}" + ); + + Ok(()) +} + +fn read_only_sandbox_for_cwd(cwd: std::path::PathBuf) -> Result { + Ok(FileSystemSandboxContext::from_legacy_sandbox_policy( + SandboxPolicy::new_read_only_policy(), + PathUri::from_path(cwd)?, + )?) +} + +fn is_unsupported_restricted_token_host(result: &std::io::Result) -> bool { + result.as_ref().err().is_some_and(|err| { + err.to_string() + .contains("windows sandbox failed: CreateRestrictedToken failed: 87") + }) +} diff --git a/codex-rs/file-system/src/lib.rs b/codex-rs/file-system/src/lib.rs index 83c984b998..3ba7874404 100644 --- a/codex-rs/file-system/src/lib.rs +++ b/codex-rs/file-system/src/lib.rs @@ -62,6 +62,8 @@ pub struct FileSystemSandboxContext { pub permissions: PermissionProfile, #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub workspace_roots: Vec, pub windows_sandbox_level: WindowsSandboxLevel, #[serde(default)] pub windows_sandbox_private_desktop: bool, @@ -109,6 +111,7 @@ impl FileSystemSandboxContext { Self { permissions: permissions.into(), cwd, + workspace_roots: Vec::new(), windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, use_legacy_landlock: false, @@ -151,6 +154,7 @@ impl FileSystemSandboxContext { pub fn drop_cwd_if_unused(mut self) -> Self { if !self.has_cwd_dependent_permissions() { self.cwd = None; + self.workspace_roots.clear(); } self } diff --git a/codex-rs/sandboxing/Cargo.toml b/codex-rs/sandboxing/Cargo.toml index ccdcd87681..3185d782ba 100644 --- a/codex-rs/sandboxing/Cargo.toml +++ b/codex-rs/sandboxing/Cargo.toml @@ -17,6 +17,7 @@ codex-network-proxy = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path-uri = { workspace = true } +codex-windows-sandbox = { workspace = true } dunce = { workspace = true } libc = { workspace = true } serde_json = { workspace = true } @@ -25,6 +26,9 @@ tracing = { workspace = true, features = ["log"] } url = { workspace = true } which = { workspace = true } +[target.'cfg(windows)'.dependencies] +codex-utils-home-dir = { workspace = true } + [dev-dependencies] anyhow = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index 7210acd0df..bebfefe067 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -5,12 +5,14 @@ mod manager; pub mod policy_transforms; #[cfg(target_os = "macos")] pub mod seatbelt; +mod windows; #[cfg(target_os = "linux")] pub use bwrap::find_system_bwrap_in_path; #[cfg(target_os = "linux")] pub use bwrap::system_bwrap_warning; pub use manager::SandboxCommand; +pub use manager::SandboxDirectSpawnTransformRequest; pub use manager::SandboxExecRequest; pub use manager::SandboxManager; pub use manager::SandboxTransformError; @@ -20,6 +22,12 @@ pub use manager::SandboxablePreference; pub use manager::compatibility_sandbox_policy_for_permission_profile; pub use manager::get_platform_sandbox; pub use manager::with_managed_mitm_ca_readable_root; +pub use windows::WindowsSandboxFilesystemOverrides; +pub use windows::permission_profile_supports_windows_restricted_token_sandbox; +pub use windows::resolve_windows_elevated_filesystem_overrides; +pub use windows::resolve_windows_restricted_token_filesystem_overrides; +pub use windows::unsupported_windows_restricted_token_sandbox_reason; +pub use windows::windows_sandbox_uses_elevated_backend; use codex_protocol::error::CodexErr; @@ -48,6 +56,10 @@ impl From for CodexErr { SandboxTransformError::SeatbeltUnavailable => CodexErr::UnsupportedOperation( "seatbelt sandbox is only available on macOS".to_string(), ), + #[cfg(target_os = "windows")] + SandboxTransformError::WindowsSandboxPreparation(message) => { + CodexErr::UnsupportedOperation(message) + } } } } diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 35ebaeac5b..6bfbaa3459 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -7,6 +7,12 @@ use crate::landlock::allow_network_for_proxy; use crate::landlock::create_linux_sandbox_command_args_for_permission_profile; use crate::policy_transforms::effective_permission_profile; use crate::policy_transforms::should_require_platform_sandbox; +#[cfg(target_os = "windows")] +use crate::resolve_windows_elevated_filesystem_overrides; +#[cfg(target_os = "windows")] +use crate::resolve_windows_restricted_token_filesystem_overrides; +#[cfg(target_os = "windows")] +use crate::windows_sandbox_uses_elevated_backend; use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; @@ -21,6 +27,9 @@ use std::ffi::OsString; use std::io; use std::path::Path; +#[cfg(target_os = "windows")] +const WINDOWS_SANDBOX_WRAPPER_SETUP_ENV_ALLOWLIST: &[&str] = &["USERNAME", "USERPROFILE"]; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SandboxType { None, @@ -131,6 +140,16 @@ pub struct SandboxTransformRequest<'a> { pub windows_sandbox_private_desktop: bool, } +/// Bundled arguments for a sandbox transformation whose result will be spawned +/// directly from argv. +/// +/// Direct-spawn callers will not run a later platform-specific launcher, so the +/// returned command must encode any sandbox wrapper it needs. +pub struct SandboxDirectSpawnTransformRequest<'a> { + pub transform: SandboxTransformRequest<'a>, + pub workspace_roots: &'a [AbsolutePathBuf], +} + #[derive(Debug)] pub enum SandboxTransformError { InvalidCommandCwd { @@ -146,6 +165,8 @@ pub enum SandboxTransformError { Wsl1UnsupportedForBubblewrap, #[cfg(not(target_os = "macos"))] SeatbeltUnavailable, + #[cfg(target_os = "windows")] + WindowsSandboxPreparation(String), } impl std::fmt::Display for SandboxTransformError { @@ -168,6 +189,10 @@ impl std::fmt::Display for SandboxTransformError { Self::Wsl1UnsupportedForBubblewrap => write!(f, "{WSL1_BWRAP_WARNING}"), #[cfg(not(target_os = "macos"))] Self::SeatbeltUnavailable => write!(f, "seatbelt sandbox is only available on macOS"), + #[cfg(target_os = "windows")] + Self::WindowsSandboxPreparation(err) => { + write!(f, "failed to prepare windows sandbox wrapper: {err}") + } } } } @@ -182,6 +207,8 @@ impl std::error::Error for SandboxTransformError { Self::Wsl1UnsupportedForBubblewrap => None, #[cfg(not(target_os = "macos"))] Self::SeatbeltUnavailable => None, + #[cfg(target_os = "windows")] + Self::WindowsSandboxPreparation(_) => None, } } } @@ -336,6 +363,142 @@ impl SandboxManager { arg0: arg0_override, }) } + + pub fn transform_for_direct_spawn( + &self, + request: SandboxDirectSpawnTransformRequest<'_>, + ) -> Result { + #[cfg(target_os = "windows")] + { + let codex_home = codex_utils_home_dir::find_codex_home() + .map_err(|err| SandboxTransformError::WindowsSandboxPreparation(err.to_string()))?; + self.transform_for_direct_spawn_with_codex_home(request, codex_home.as_path()) + } + + #[cfg(not(target_os = "windows"))] + { + self.transform(request.transform) + } + } + + #[cfg(target_os = "windows")] + fn transform_for_direct_spawn_with_codex_home( + &self, + request: SandboxDirectSpawnTransformRequest<'_>, + codex_home: &Path, + ) -> Result { + let workspace_roots = request.workspace_roots; + let mut request = self.transform(request.transform)?; + if request.sandbox == SandboxType::WindowsRestrictedToken { + wrap_windows_sandbox_exec_request_for_direct_spawn( + &mut request, + workspace_roots, + codex_home, + )?; + } + Ok(request) + } +} + +#[cfg(target_os = "windows")] +fn wrap_windows_sandbox_exec_request_for_direct_spawn( + request: &mut SandboxExecRequest, + workspace_roots: &[AbsolutePathBuf], + codex_home: &Path, +) -> Result<(), SandboxTransformError> { + let Some(program) = request.command.first_mut() else { + return Err(SandboxTransformError::WindowsSandboxPreparation( + "sandbox command was empty".to_string(), + )); + }; + let source = std::path::PathBuf::from(&program); + let helper = codex_windows_sandbox::resolve_exe_for_launch(source.as_path(), codex_home); + *program = helper.to_string_lossy().into_owned(); + + let inner_command = std::mem::take(&mut request.command); + let proxy_enforced = request.network.is_some(); + let use_elevated = + windows_sandbox_uses_elevated_backend(request.windows_sandbox_level, proxy_enforced); + let overrides = if use_elevated { + resolve_windows_elevated_filesystem_overrides( + request.sandbox, + &request.permission_profile, + &request.sandbox_policy_cwd, + use_elevated, + ) + } else { + resolve_windows_restricted_token_filesystem_overrides( + request.sandbox, + &request.permission_profile, + &request.sandbox_policy_cwd, + request.windows_sandbox_level, + ) + } + .map_err(SandboxTransformError::WindowsSandboxPreparation)?; + let empty_paths: &[AbsolutePathBuf] = &[]; + let read_roots_override = overrides + .as_ref() + .and_then(|overrides| overrides.read_roots_override.as_deref()); + let read_roots_include_platform_defaults = overrides + .as_ref() + .is_some_and(|overrides| overrides.read_roots_include_platform_defaults); + let write_roots_override = overrides + .as_ref() + .and_then(|overrides| overrides.write_roots_override.as_deref()); + let deny_read_paths_override = overrides.as_ref().map_or(empty_paths, |overrides| { + overrides.additional_deny_read_paths.as_slice() + }); + let deny_write_paths_override = overrides.as_ref().map_or(empty_paths, |overrides| { + overrides.additional_deny_write_paths.as_slice() + }); + let mut wrapper_args = + codex_windows_sandbox::create_windows_sandbox_command_args_for_permission_profile( + inner_command, + &request.cwd, + workspace_roots, + &request.env, + &request.permission_profile, + request.windows_sandbox_level, + request.windows_sandbox_private_desktop, + proxy_enforced, + read_roots_override, + read_roots_include_platform_defaults, + write_roots_override, + deny_read_paths_override, + deny_write_paths_override, + codex_home, + ); + + request.command = Vec::with_capacity(1 + wrapper_args.len()); + request.command.push(source.to_string_lossy().into_owned()); + request.command.append(&mut wrapper_args); + request.sandbox = SandboxType::None; + request.arg0 = None; + add_windows_sandbox_wrapper_setup_env(&mut request.env); + Ok(()) +} + +#[cfg(target_os = "windows")] +fn add_windows_sandbox_wrapper_setup_env(env: &mut HashMap) { + add_windows_sandbox_wrapper_setup_env_from_vars(env, std::env::vars_os()); +} + +#[cfg(target_os = "windows")] +fn add_windows_sandbox_wrapper_setup_env_from_vars( + env: &mut HashMap, + vars: impl IntoIterator, +) { + for (key, value) in vars { + let key = key.to_string_lossy().into_owned(); + if !WINDOWS_SANDBOX_WRAPPER_SETUP_ENV_ALLOWLIST + .iter() + .any(|allowed| key.eq_ignore_ascii_case(allowed)) + { + continue; + } + env.retain(|existing, _| !existing.eq_ignore_ascii_case(&key)); + env.insert(key, value.to_string_lossy().into_owned()); + } } pub fn compatibility_sandbox_policy_for_permission_profile( diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index d76a1fd325..8f736b994f 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -1,4 +1,6 @@ use super::SandboxCommand; +#[cfg(target_os = "windows")] +use super::SandboxDirectSpawnTransformRequest; use super::SandboxManager; use super::SandboxTransformRequest; use super::SandboxType; @@ -410,3 +412,153 @@ fn transform_linux_seccomp_uses_helper_alias_when_launcher_is_not_helper_path() assert_eq!(exec_request.arg0, Some("codex-linux-sandbox".to_string())); } + +#[cfg(target_os = "windows")] +#[test] +fn transform_for_direct_spawn_windows_preserves_only_wrapper_setup_identity() { + let mut env = HashMap::from([ + ("Path".to_string(), r"C:\Windows\System32".to_string()), + ("username".to_string(), "wrong-user".to_string()), + ("UserProfile".to_string(), r"C:\wrong".to_string()), + ]); + + super::add_windows_sandbox_wrapper_setup_env_from_vars( + &mut env, + [ + ("USERNAME", "alice"), + ("USERPROFILE", r"C:\Users\alice"), + ("OPENAI_API_KEY", "secret"), + ] + .map(|(key, value)| { + ( + std::ffi::OsString::from(key), + std::ffi::OsString::from(value), + ) + }), + ); + + assert_eq!( + env, + HashMap::from([ + ("Path".to_string(), r"C:\Windows\System32".to_string()), + ("USERNAME".to_string(), "alice".to_string()), + ("USERPROFILE".to_string(), r"C:\Users\alice".to_string()), + ]) + ); +} + +#[cfg(target_os = "windows")] +#[test] +fn transform_for_direct_spawn_windows_materializes_inner_helper() { + let codex_home = tempfile::TempDir::new().expect("codex home"); + let helper_dir = tempfile::TempDir::new().expect("helper dir"); + let configured_helper = helper_dir.path().join("configured-codex-helper.exe"); + std::fs::write(&configured_helper, b"helper").expect("write configured helper"); + let cwd = AbsolutePathBuf::from_absolute_path(helper_dir.path()).expect("absolute cwd"); + let cwd_uri = PathUri::from_abs_path(&cwd); + let blocked = cwd.join("blocked"); + std::fs::create_dir_all(blocked.as_path()).expect("create blocked path"); + let permissions = PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: blocked }, + access: FileSystemAccessMode::Deny, + }, + ]), + NetworkSandboxPolicy::Restricted, + ); + let other_workspace = tempfile::TempDir::new().expect("other workspace"); + let other_workspace_root = AbsolutePathBuf::from_absolute_path(other_workspace.path()) + .expect("absolute other workspace"); + let workspace_roots = vec![cwd, other_workspace_root]; + let manager = SandboxManager::new(); + let exec_request = manager + .transform_for_direct_spawn_with_codex_home( + SandboxDirectSpawnTransformRequest { + workspace_roots: workspace_roots.as_slice(), + transform: SandboxTransformRequest { + command: SandboxCommand { + program: configured_helper.as_os_str().to_owned(), + args: vec!["--codex-run-as-fs-helper".to_string()], + cwd: cwd_uri.clone(), + env: HashMap::from([( + "Path".to_string(), + r"C:\Windows\System32".to_string(), + )]), + additional_permissions: None, + }, + permissions: &permissions, + sandbox: SandboxType::WindowsRestrictedToken, + enforce_managed_network: false, + network: None, + sandbox_policy_cwd: &cwd_uri, + codex_linux_sandbox_exe: None, + use_legacy_landlock: false, + windows_sandbox_level: WindowsSandboxLevel::Elevated, + windows_sandbox_private_desktop: false, + }, + }, + codex_home.path(), + ) + .expect("transform for direct spawn"); + + let separator_index = exec_request + .command + .iter() + .position(|arg| arg == "--") + .expect("wrapper argv separator"); + let materialized_helper = std::path::PathBuf::from(&exec_request.command[separator_index + 1]); + assert_eq!(exec_request.sandbox, SandboxType::None); + assert_eq!( + exec_request.command.first(), + Some(&configured_helper.display().to_string()) + ); + assert!( + exec_request + .command + .iter() + .any(|arg| arg == "--run-as-windows-sandbox") + ); + assert!( + exec_request + .command + .iter() + .any(|arg| arg == "--deny-read-paths-json") + ); + assert_eq!( + exec_request.command[separator_index + 2], + "--codex-run-as-fs-helper" + ); + assert_eq!( + exec_request + .command + .windows(2) + .filter_map(|args| { + (args[0] == "--workspace-root").then_some(std::path::PathBuf::from(&args[1])) + }) + .collect::>(), + workspace_roots + .iter() + .map(|root| root.as_path().to_path_buf()) + .collect::>() + ); + assert_eq!( + materialized_helper + .parent() + .and_then(std::path::Path::file_name), + Some(std::ffi::OsStr::new(".sandbox-bin")) + ); + assert!(materialized_helper.exists()); +} diff --git a/codex-rs/sandboxing/src/windows.rs b/codex-rs/sandboxing/src/windows.rs new file mode 100644 index 0000000000..a57281145c --- /dev/null +++ b/codex-rs/sandboxing/src/windows.rs @@ -0,0 +1,382 @@ +use std::collections::BTreeSet; +use std::path::Path; +use std::path::PathBuf; + +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::protocol::WritableRoot; +use codex_utils_absolute_path::AbsolutePathBuf; + +use crate::SandboxType; +use crate::compatibility_sandbox_policy_for_permission_profile; + +/// Resolved filesystem overrides for the Windows sandbox backends. +/// +/// The elevated Windows backend consumes extra deny-read paths plus explicit +/// read and write roots during setup/refresh. The unelevated restricted-token +/// backend only consumes extra deny-write carveouts on top of the legacy +/// `WorkspaceWrite` allow set. Read-root overrides are layered on top of the +/// baseline helper roots that the elevated setup path needs to launch the +/// sandboxed command; split policies that opt into platform defaults carry +/// that explicitly with the override. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WindowsSandboxFilesystemOverrides { + pub read_roots_override: Option>, + pub read_roots_include_platform_defaults: bool, + pub write_roots_override: Option>, + pub additional_deny_read_paths: Vec, + pub additional_deny_write_paths: Vec, +} + +pub fn windows_sandbox_uses_elevated_backend( + sandbox_level: WindowsSandboxLevel, + proxy_enforced: bool, +) -> bool { + // Windows firewall enforcement is tied to the logon-user sandbox identities, so + // proxy-enforced sessions must use that backend even when the configured mode is + // the default restricted-token sandbox. + proxy_enforced || matches!(sandbox_level, WindowsSandboxLevel::Elevated) +} + +pub fn permission_profile_supports_windows_restricted_token_sandbox( + permission_profile: &PermissionProfile, +) -> bool { + match permission_profile { + PermissionProfile::Managed { file_system, .. } => { + !file_system.to_sandbox_policy().has_full_disk_write_access() + } + PermissionProfile::Disabled | PermissionProfile::External { .. } => false, + } +} + +pub fn unsupported_windows_restricted_token_sandbox_reason( + sandbox: SandboxType, + permission_profile: &PermissionProfile, + sandbox_policy_cwd: &AbsolutePathBuf, + windows_sandbox_level: WindowsSandboxLevel, +) -> Option { + if windows_sandbox_level == WindowsSandboxLevel::Elevated { + resolve_windows_elevated_filesystem_overrides( + sandbox, + permission_profile, + sandbox_policy_cwd, + windows_sandbox_level == WindowsSandboxLevel::Elevated, + ) + .err() + } else { + resolve_windows_restricted_token_filesystem_overrides( + sandbox, + permission_profile, + sandbox_policy_cwd, + windows_sandbox_level, + ) + .err() + } +} + +pub fn resolve_windows_restricted_token_filesystem_overrides( + sandbox: SandboxType, + permission_profile: &PermissionProfile, + sandbox_policy_cwd: &AbsolutePathBuf, + windows_sandbox_level: WindowsSandboxLevel, +) -> std::result::Result, String> { + if sandbox != SandboxType::WindowsRestrictedToken + || windows_sandbox_level == WindowsSandboxLevel::Elevated + { + return Ok(None); + } + + let (file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + + let needs_direct_runtime_enforcement = file_system_sandbox_policy + .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd); + + if permission_profile_supports_windows_restricted_token_sandbox(permission_profile) + && !needs_direct_runtime_enforcement + { + return Ok(None); + } + + if !permission_profile_supports_windows_restricted_token_sandbox(permission_profile) { + let permission_profile_name = permission_profile_display_name(permission_profile); + return Err(format!( + "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, permission_profile={permission_profile_name}; refusing to run unsandboxed", + file_system_sandbox_policy.kind, + )); + } + + // The restricted-token backend can still enforce split write restrictions, + // but its WRITE_RESTRICTED token does not make capability SID deny-read ACEs + // participate in read access checks. Read restrictions therefore require the + // elevated backend, even when the filesystem root remains readable. + if !windows_policy_has_root_read_access(&file_system_sandbox_policy, sandbox_policy_cwd) { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed" + .to_string(), + ); + } + + let additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths( + &file_system_sandbox_policy, + sandbox_policy_cwd, + )?; + if !additional_deny_read_paths.is_empty() { + return Err( + "windows unelevated restricted-token sandbox cannot enforce deny-read restrictions directly; refusing to run unsandboxed" + .to_string(), + ); + } + + let legacy_projection = compatibility_sandbox_policy_for_permission_profile( + permission_profile, + sandbox_policy_cwd.as_path(), + ); + let legacy_writable_roots = legacy_projection.get_writable_roots_with_cwd(sandbox_policy_cwd); + let split_writable_roots = + file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); + let legacy_root_paths: BTreeSet = legacy_writable_roots + .iter() + .map(|root| normalize_windows_override_path(root.root.as_path())) + .collect::>()?; + let split_root_paths: BTreeSet = split_writable_roots + .iter() + .map(|root| normalize_windows_override_path(root.root.as_path())) + .collect::>()?; + + if legacy_root_paths != split_root_paths { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" + .to_string(), + ); + } + + for writable_root in &split_writable_roots { + for read_only_subpath in &writable_root.read_only_subpaths { + if split_writable_roots.iter().any(|candidate| { + candidate.root.as_path() != writable_root.root.as_path() + && candidate + .root + .as_path() + .starts_with(read_only_subpath.as_path()) + }) { + return Err( + "windows unelevated restricted-token sandbox cannot reopen writable descendants under read-only carveouts directly; refusing to run unsandboxed" + .to_string(), + ); + } + } + } + + let mut additional_deny_write_paths = BTreeSet::new(); + for split_root in &split_writable_roots { + let split_root_path = normalize_windows_override_path(split_root.root.as_path())?; + let Some(legacy_root) = legacy_writable_roots.iter().find(|candidate| { + normalize_windows_override_path(candidate.root.as_path()) + .is_ok_and(|candidate_path| candidate_path == split_root_path) + }) else { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" + .to_string(), + ); + }; + + for read_only_subpath in &split_root.read_only_subpaths { + if !legacy_root + .read_only_subpaths + .iter() + .any(|candidate| candidate == read_only_subpath) + { + additional_deny_write_paths.insert(normalize_windows_override_path( + read_only_subpath.as_path(), + )?); + } + } + } + + if additional_deny_read_paths.is_empty() && additional_deny_write_paths.is_empty() { + return Ok(None); + } + + Ok(Some(WindowsSandboxFilesystemOverrides { + read_roots_override: None, + read_roots_include_platform_defaults: false, + write_roots_override: None, + additional_deny_read_paths, + additional_deny_write_paths: additional_deny_write_paths + .into_iter() + .map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string())) + .collect::>()?, + })) +} + +pub fn resolve_windows_elevated_filesystem_overrides( + sandbox: SandboxType, + permission_profile: &PermissionProfile, + sandbox_policy_cwd: &AbsolutePathBuf, + use_windows_elevated_backend: bool, +) -> std::result::Result, String> { + if sandbox != SandboxType::WindowsRestrictedToken || !use_windows_elevated_backend { + return Ok(None); + } + + let (file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + + if !permission_profile_supports_windows_restricted_token_sandbox(permission_profile) { + let permission_profile_name = permission_profile_display_name(permission_profile); + return Err(format!( + "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, permission_profile={permission_profile_name}; refusing to run unsandboxed", + file_system_sandbox_policy.kind, + )); + } + + let additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths( + &file_system_sandbox_policy, + sandbox_policy_cwd, + )?; + + let split_writable_roots = + file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); + if has_reopened_writable_descendant(&split_writable_roots) { + return Err( + "windows elevated sandbox cannot reopen writable descendants under read-only carveouts directly; refusing to run unsandboxed" + .to_string(), + ); + } + + let needs_direct_runtime_enforcement = file_system_sandbox_policy + .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd); + let normalize_path = |path: PathBuf| dunce::canonicalize(&path).unwrap_or(path); + let legacy_projection = compatibility_sandbox_policy_for_permission_profile( + permission_profile, + sandbox_policy_cwd.as_path(), + ); + let legacy_writable_roots = legacy_projection.get_writable_roots_with_cwd(sandbox_policy_cwd); + let legacy_root_paths: BTreeSet = legacy_writable_roots + .iter() + .map(|root| normalize_path(root.root.to_path_buf())) + .collect(); + let split_readable_roots: Vec = file_system_sandbox_policy + .get_readable_roots_with_cwd(sandbox_policy_cwd) + .into_iter() + .map(AbsolutePathBuf::into_path_buf) + .map(&normalize_path) + .collect(); + let split_root_paths: Vec = split_writable_roots + .iter() + .map(|root| normalize_path(root.root.to_path_buf())) + .collect(); + let split_root_path_set: BTreeSet = split_root_paths.iter().cloned().collect(); + + // `has_full_disk_read_access()` is intentionally false when deny-read + // entries exist. For Windows setup overrides, the important question is + // whether the baseline still reads from the filesystem root and only needs + // additional deny ACLs layered on top. + let split_has_root_read_access = + windows_policy_has_root_read_access(&file_system_sandbox_policy, sandbox_policy_cwd); + let read_roots_override = if split_has_root_read_access { + None + } else { + Some(split_readable_roots) + }; + + let write_roots_override = if split_root_path_set == legacy_root_paths { + None + } else { + Some(split_root_paths) + }; + + let additional_deny_write_paths = if needs_direct_runtime_enforcement { + let mut deny_paths = BTreeSet::new(); + for writable_root in &split_writable_roots { + let writable_root_path = normalize_path(writable_root.root.to_path_buf()); + let legacy_root = legacy_writable_roots.iter().find(|candidate| { + normalize_path(candidate.root.to_path_buf()) == writable_root_path + }); + for read_only_subpath in &writable_root.read_only_subpaths { + let read_only_subpath_suffix = read_only_subpath + .as_path() + .strip_prefix(writable_root.root.as_path()) + .ok(); + let already_denied_by_legacy = legacy_root.is_some_and(|legacy_root| { + legacy_root.read_only_subpaths.iter().any(|candidate| { + candidate + .as_path() + .strip_prefix(legacy_root.root.as_path()) + .ok() + == read_only_subpath_suffix + }) + }); + if !already_denied_by_legacy { + deny_paths.insert(normalize_path(read_only_subpath.to_path_buf())); + } + } + } + deny_paths + .into_iter() + .map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string())) + .collect::>()? + } else { + Vec::new() + }; + + if read_roots_override.is_none() + && write_roots_override.is_none() + && additional_deny_read_paths.is_empty() + && additional_deny_write_paths.is_empty() + { + return Ok(None); + } + + Ok(Some(WindowsSandboxFilesystemOverrides { + read_roots_include_platform_defaults: read_roots_override.is_some() + && file_system_sandbox_policy.include_platform_defaults(), + read_roots_override, + write_roots_override, + additional_deny_read_paths, + additional_deny_write_paths, + })) +} + +fn normalize_windows_override_path(path: &Path) -> std::result::Result { + AbsolutePathBuf::from_absolute_path(dunce::simplified(path)) + .map(AbsolutePathBuf::into_path_buf) + .map_err(|err| err.to_string()) +} + +fn windows_policy_has_root_read_access( + file_system_sandbox_policy: &FileSystemSandboxPolicy, + cwd: &AbsolutePathBuf, +) -> bool { + let Some(root) = cwd.as_path().ancestors().last() else { + return false; + }; + file_system_sandbox_policy.can_read_path_with_cwd(root, cwd.as_path()) +} + +fn permission_profile_display_name(permission_profile: &PermissionProfile) -> &'static str { + match permission_profile { + PermissionProfile::Managed { .. } => "Managed", + PermissionProfile::Disabled => "Disabled", + PermissionProfile::External { .. } => "External", + } +} + +fn has_reopened_writable_descendant(writable_roots: &[WritableRoot]) -> bool { + writable_roots.iter().any(|writable_root| { + writable_root + .read_only_subpaths + .iter() + .any(|read_only_subpath| { + writable_roots.iter().any(|candidate| { + candidate.root.as_path() != writable_root.root.as_path() + && candidate + .root + .as_path() + .starts_with(read_only_subpath.as_path()) + }) + }) + }) +} diff --git a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs index e5b202dede..bcc09f1250 100644 --- a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs +++ b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs @@ -96,22 +96,26 @@ pub fn resolve_current_exe_for_launch(codex_home: &Path, fallback_executable: &s Ok(path) => path, Err(_) => return PathBuf::from(fallback_executable), }; + resolve_exe_for_launch(&source, codex_home) +} + +pub fn resolve_exe_for_launch(source: &Path, codex_home: &Path) -> PathBuf { let Some(file_name) = source.file_name() else { - return source; + return source.to_path_buf(); }; let destination = helper_bin_dir(codex_home).join(file_name); - match copy_from_source_if_needed(&source, &destination) { + match copy_from_source_if_needed(source, &destination) { Ok(_) => destination, Err(err) => { let sandbox_log_dir = crate::sandbox_dir(codex_home); log_note( &format!( - "helper copy failed for current executable: {err:#}; falling back to legacy path {}", + "helper copy failed for executable: {err:#}; falling back to legacy path {}", source.display() ), Some(&sandbox_log_dir), ); - source + source.to_path_buf() } } } diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 4c1858430b..a7f3df6e2a 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -174,6 +174,8 @@ pub use elevated_impl::run_windows_sandbox_capture_for_permission_profile as run #[cfg(target_os = "windows")] pub use helper_materialization::resolve_current_exe_for_launch; #[cfg(target_os = "windows")] +pub use helper_materialization::resolve_exe_for_launch; +#[cfg(target_os = "windows")] pub use hide_users::hide_current_user_profile_dir; #[cfg(target_os = "windows")] pub use hide_users::hide_newly_created_users; From 0065c3a17d628abad3c3793d380ab9b9005fcbd3 Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Wed, 17 Jun 2026 10:37:14 -0700 Subject: [PATCH 32/70] [codex] Repair invalid skill frontmatter scalars (#28628) ## Why The community marketplace audit found many skill frontmatter parse failures where values were intended as prose, but were not valid YAML. Common examples include unquoted scalar values with `: `, such as `description: Build for AWS: ECS` or `argument-hint: `, and flow-looking values such as `tags: [next,@supabase/ssr]`. `serde_yaml` does not expose a permissive mode for this. The parser fails before unknown frontmatter fields can be ignored, so a compatibility repair has to happen before retrying YAML parsing. ## What changed Skill frontmatter loading still uses `serde_yaml` as the primary parser. If that parse fails, the loader performs a line-oriented repair of scalar frontmatter field values, then retries parsing. The fallback now: - applies to any frontmatter mapping field, not just `description` / `short-description` - quotes unquoted scalar values that contain a YAML colon separator such as `: ` - quotes invalid flow-looking scalar values that start with `[`, `{`, `@`, or backtick - preserves already quoted values - skips `|` / `>` block scalar bodies so multiline descriptions are not rewritten - returns the original YAML error if the repaired frontmatter still cannot parse ## Examples This previously failed because the second `: ` was parsed as YAML structure: ```yaml description: AWS deployment patterns: ECS Fargate, Lambda, and S3 ``` The fallback now parses it as if it had been written explicitly as: ```yaml description: 'AWS deployment patterns: ECS Fargate, Lambda, and S3' ``` The same repair now applies to ignored frontmatter fields that still need to be valid YAML for the parser to get through the document: ```yaml argument-hint: tags: [next,@supabase/ssr] ``` Valid YAML multiline descriptions continue to work through normal parsing without repair: ```yaml description: |- Build for AWS: ECS and Lambda ``` ## Validation - Added loader coverage for unquoted `description` values containing `: `. - Added loader coverage for unquoted `metadata.short-description` values containing `: ` and an apostrophe. - Added loader coverage for unrecognized frontmatter fields that need quoting, including `argument-hint` and `tags`. - Added block-scalar coverage to ensure multiline description bodies are preserved while other fields are repaired. - `just test -p codex-core-skills` (106 passed) - `just fix -p codex-core-skills` --- codex-rs/core-skills/src/loader.rs | 100 +++++++++++++++++- codex-rs/core-skills/src/loader_tests.rs | 128 +++++++++++++++++++++++ 2 files changed, 226 insertions(+), 2 deletions(-) diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index 3b59e908d6..e2877349db 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -651,8 +651,19 @@ async fn parse_skill_file( let frontmatter = extract_frontmatter(&contents).ok_or(SkillParseError::MissingFrontmatter)?; - let parsed: SkillFrontmatter = - serde_yaml::from_str(&frontmatter).map_err(SkillParseError::InvalidYaml)?; + let parsed: SkillFrontmatter = match serde_yaml::from_str(&frontmatter) { + Ok(parsed) => Ok(parsed), + Err(original_error) => match repair_frontmatter_scalar_fields(&frontmatter) { + // Some third-party skills use prose like `description: Build for AWS: ECS` + // or `argument-hint: `. Keep the repair line-oriented + // so unrelated invalid YAML still surfaces. + Some(repaired_frontmatter) => { + serde_yaml::from_str(&repaired_frontmatter).map_err(|_| original_error) + } + None => Err(original_error), + }, + } + .map_err(SkillParseError::InvalidYaml)?; let base_name = parsed .name @@ -997,6 +1008,91 @@ fn sanitize_single_line(raw: &str) -> String { raw.split_whitespace().collect::>().join(" ") } +fn repair_frontmatter_scalar_fields(frontmatter: &str) -> Option { + let mut changed = false; + let mut block_scalar_indent: Option = None; + let mut repaired_lines: Vec = Vec::new(); + for line in frontmatter.lines() { + let indent = line + .chars() + .take_while(|character| *character == ' ') + .count(); + if let Some(block_indent) = block_scalar_indent { + if line.trim().is_empty() || indent > block_indent { + repaired_lines.push(line.to_string()); + continue; + } + block_scalar_indent = None; + } + + let Some((key, value)) = line.split_once(':') else { + repaired_lines.push(line.to_string()); + continue; + }; + if key.trim().is_empty() || !value.chars().next().is_none_or(char::is_whitespace) { + repaired_lines.push(line.to_string()); + continue; + } + + let trimmed_start = value.trim_start(); + let leading_whitespace = &value[..value.len() - trimmed_start.len()]; + let mut scalar = trimmed_start; + let mut comment = ""; + for (index, character) in trimmed_start.char_indices() { + if character == '#' + && (index == 0 + || trimmed_start[..index] + .chars() + .next_back() + .is_some_and(char::is_whitespace)) + { + let comment_start = trimmed_start[..index].trim_end().len(); + scalar = &trimmed_start[..comment_start]; + comment = &trimmed_start[comment_start..]; + break; + } + } + + let scalar = scalar.trim_end(); + let Some(first_char) = scalar.chars().next() else { + repaired_lines.push(line.to_string()); + continue; + }; + if matches!(first_char, '|' | '>') { + block_scalar_indent = Some(indent); + repaired_lines.push(line.to_string()); + continue; + } + if matches!(first_char, '\'' | '"') { + repaired_lines.push(line.to_string()); + continue; + } + let mut has_colon_separator = false; + let mut chars = scalar.chars().peekable(); + while let Some(character) = chars.next() { + if character == ':' + && matches!(chars.peek(), Some(next_character) if next_character.is_whitespace()) + { + has_colon_separator = true; + break; + } + } + let invalid_flow_like_scalar = matches!(first_char, '[' | '{' | '@' | '`') + && serde_yaml::from_str::(scalar).is_err(); + if !has_colon_separator && !invalid_flow_like_scalar { + repaired_lines.push(line.to_string()); + continue; + } + + let quoted_scalar = format!("'{}'", scalar.replace('\'', "''")); + repaired_lines.push(format!( + "{key}:{leading_whitespace}{quoted_scalar}{comment}" + )); + changed = true; + } + changed.then(|| repaired_lines.join("\n")) +} + fn validate_len( value: &str, max_len: usize, diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index f6c71b2be5..cc1d8c8e04 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -1411,6 +1411,134 @@ async fn loads_short_description_from_metadata() { ); } +#[tokio::test] +async fn loads_unquoted_description_containing_colon_space() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_raw_skill_at( + &codex_home.path().join("skills"), + "colon-description", + "name: colon-description\ndescription: AWS deployment patterns: ECS Fargate, Lambda, and S3", + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "colon-description".to_string(), + description: "AWS deployment patterns: ECS Fargate, Lambda, and S3".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: None, + }] + ); +} + +#[tokio::test] +async fn loads_unquoted_short_description_containing_colon_space_and_apostrophe() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_raw_skill_at( + &codex_home.path().join("skills"), + "colon-short-description", + "name: colon-short-description\ndescription: long description\nmetadata:\n short-description: What's included: builds and tests", + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "colon-short-description".to_string(), + description: "long description".to_string(), + short_description: Some("What's included: builds and tests".to_string()), + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: None, + }] + ); +} + +#[tokio::test] +async fn loads_unrecognized_frontmatter_fields_that_need_quotes() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_raw_skill_at( + &codex_home.path().join("skills"), + "repaired-unknown-fields", + "name: repaired-unknown-fields\ndescription: valid description\nargument-hint: \ntags: [next,@supabase/ssr]", + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "repaired-unknown-fields".to_string(), + description: "valid description".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: None, + }] + ); +} + +#[tokio::test] +async fn preserves_block_scalar_body_while_repairing_other_fields() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_raw_skill_at( + &codex_home.path().join("skills"), + "block-description-with-repair", + "name: block-description-with-repair\ndescription: |-\n Build for AWS: ECS\nargument-hint: ", + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "block-description-with-repair".to_string(), + description: "Build for AWS: ECS".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: None, + }] + ); +} + #[tokio::test] async fn enforces_short_description_length_limits() { let codex_home = tempfile::tempdir().expect("tempdir"); From b947695a9811b0717b3ff919fa7ebcf132ac8a40 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Wed, 17 Jun 2026 10:51:41 -0700 Subject: [PATCH 33/70] Tell codex to avoid changing rollout format. (#28632) Just adds a requirement to the path-types skill to nudge Codex away from touching rollout types while migrating paths. --- .codex/skills/path-types/SKILL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.codex/skills/path-types/SKILL.md b/.codex/skills/path-types/SKILL.md index ccf1a9f41d..96caa98058 100644 --- a/.codex/skills/path-types/SKILL.md +++ b/.codex/skills/path-types/SKILL.md @@ -31,6 +31,7 @@ Keep these requirements in mind while migrating code to conform with the above g * path reasoning must work before the related environment has come online * URIs cannot explicitly encode the executor’s path convention or operating system * users must not configure the environment’s OS/path convention explicitly +* URIs should not yet be stored in rollouts It is OK if the conversion between paths and URIs is somewhat lossy as long as it will do the right thing for real users. From 1391d786bc63262aeaac2288fffbc7acc316e01d Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 17 Jun 2026 18:52:43 +0100 Subject: [PATCH 34/70] Scope command approvals by execution environment (#28738) ## Why Command approval cache keys included the command and working directory, but not the execution environment. An approval for `/workspace` locally could therefore be reused for the same command and path on an executor. ## What changed - Include the selected environment ID in shell and unified-exec approval cache keys. - Carry that ID through the normal command approval request so clients can show which environment is being approved. - Expose the environment through app-server as a required nullable `environmentId` and show it in the inline TUI approval prompt. - Keep older recorded approval events compatible when the environment is absent. For example, `echo ok` in local `/workspace` and `echo ok` in executor `/workspace` now produce different approval keys and separate prompts. ## Scope This PR does not change network approvals, Guardian review actions, MCP elicitation, full-screen TUI rendering, or environment-ID validation. Remote `shell_command` execution itself remains in #28722; this PR only makes its approval key environment-aware. --- .../analytics/src/analytics_client_tests.rs | 1 + ...CommandExecutionRequestApprovalParams.json | 8 ++++ .../schema/json/ServerRequest.json | 8 ++++ .../codex_app_server_protocol.schemas.json | 8 ++++ .../CommandExecutionRequestApprovalParams.ts | 3 ++ .../src/protocol/common.rs | 1 + .../src/protocol/v2/item.rs | 3 ++ codex-rs/app-server-test-client/src/lib.rs | 4 ++ codex-rs/app-server/README.md | 2 +- .../app-server/src/bespoke_event_handling.rs | 2 + codex-rs/app-server/src/outgoing_message.rs | 1 + codex-rs/app-server/src/transport_tests.rs | 2 + .../app-server/tests/suite/v2/turn_start.rs | 1 + codex-rs/core/src/codex_delegate.rs | 2 + codex-rs/core/src/codex_delegate_tests.rs | 1 + codex-rs/core/src/session/mod.rs | 2 + codex-rs/core/src/tools/network_approval.rs | 1 + codex-rs/core/src/tools/runtimes/shell.rs | 8 ++++ .../tools/runtimes/shell/unix_escalation.rs | 5 +++ .../runtimes/shell/unix_escalation_tests.rs | 4 ++ .../core/src/tools/runtimes/shell_tests.rs | 42 +++++++++++++++++++ .../core/src/tools/runtimes/unified_exec.rs | 23 ++++++++++ codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/protocol/src/approvals.rs | 10 +++++ codex-rs/tui/src/app/app_server_requests.rs | 2 + .../tui/src/app/pending_interactive_replay.rs | 1 + codex-rs/tui/src/app/tests.rs | 1 + codex-rs/tui/src/app/thread_events.rs | 1 + codex-rs/tui/src/app/thread_routing.rs | 1 + codex-rs/tui/src/approval_events.rs | 2 + .../tui/src/bottom_pane/approval_overlay.rs | 22 ++++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 1 + codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/interrupts.rs | 1 + .../src/chatwidget/tests/approval_requests.rs | 9 ++++ .../tui/src/chatwidget/tests/exec_flow.rs | 8 ++++ ...al_requests__exec_approval_modal_exec.snap | 30 +++++++------ .../src/chatwidget/tests/status_and_layout.rs | 1 + .../src/chatwidget/tests/terminal_title.rs | 4 ++ codex-rs/tui/src/chatwidget/tool_requests.rs | 1 + 40 files changed, 215 insertions(+), 14 deletions(-) create mode 100644 codex-rs/core/src/tools/runtimes/shell_tests.rs diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 7c634ce370..4aae796562 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -861,6 +861,7 @@ fn sample_command_approval_request(request_id: i64, approval_id: Option<&str>) - item_id: "item-1".to_string(), started_at_ms: 1_000, approval_id: approval_id.map(str::to_string), + environment_id: None, reason: None, network_approval_context: None, command: Some("echo hi".to_string()), diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index 323247e16c..4e096cbe04 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -555,6 +555,14 @@ ], "description": "The command's working directory." }, + "environmentId": { + "default": null, + "description": "Environment in which the command will run.", + "type": [ + "string", + "null" + ] + }, "itemId": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 70f54f35ce..97934dd5bd 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -379,6 +379,14 @@ ], "description": "The command's working directory." }, + "environmentId": { + "default": null, + "description": "Environment in which the command will run.", + "type": [ + "string", + "null" + ] + }, "itemId": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 60966655bd..6251f7dc7a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -2386,6 +2386,14 @@ ], "description": "The command's working directory." }, + "environmentId": { + "default": null, + "description": "Environment in which the command will run.", + "type": [ + "string", + "null" + ] + }, "itemId": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts index 0e9100836a..230e5b7864 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts @@ -20,6 +20,9 @@ startedAtMs: number, /** * (a UUID) used to disambiguate routing. */ approvalId?: string | null, /** + * Environment in which the command will run. + */ +environmentId: string | null, /** * Optional explanatory reason (e.g. request for network access). */ reason?: string | null, /** diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 7611c27ff1..801e9b46b8 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -3538,6 +3538,7 @@ mod tests { item_id: "call_123".to_string(), started_at_ms: 0, approval_id: None, + environment_id: None, reason: None, network_approval_context: None, command: Some("cat file".to_string()), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index 502eb62e10..5638e3bd41 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -1321,6 +1321,9 @@ pub struct CommandExecutionRequestApprovalParams { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] pub approval_id: Option, + /// Environment in which the command will run. + #[serde(default)] + pub environment_id: Option, /// Optional explanatory reason (e.g. request for network access). #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 6edaa045b9..3b5e7d8a3f 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -2058,6 +2058,7 @@ impl CodexClient { item_id, started_at_ms: _, approval_id, + environment_id, reason, network_approval_context, command, @@ -2075,6 +2076,9 @@ impl CodexClient { ); self.command_approval_count += 1; self.command_approval_item_ids.push(item_id.clone()); + if let Some(environment_id) = environment_id.as_deref() { + println!("< environment: {environment_id}"); + } if let Some(reason) = reason.as_deref() { println!("< reason: {reason}"); } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d1a4648588..62c40555e6 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1429,7 +1429,7 @@ Certain actions (shell commands or modifying files) may require explicit user ap Order of messages: 1. `item/started` — shows the pending `commandExecution` item with `command`, `cwd`, and other fields so you can render the proposed action. -2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), and `reason`. For normal command approvals, it also includes `command`, `cwd`, and `commandActions` for friendly display. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access; any filesystem paths in that payload are absolute on the wire, and network access is represented as `additionalPermissions.network.enabled`. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. Optional persistence hints may also be included via `proposedExecpolicyAmendment` and `proposedNetworkPolicyAmendments`. Clients can prefer `availableDecisions` when present to render the exact set of choices the server wants to expose, while still falling back to the older heuristics if it is omitted. +2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, the nullable `environmentId` where the command will run, optionally `approvalId` (for subcommand callbacks), and `reason`. New shell and unified-exec approvals set `environmentId`; older events that do not provide one are exposed as `null`. For normal command approvals, the request also includes `command`, `cwd`, and `commandActions` for friendly display. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access; any filesystem paths in that payload are absolute on the wire, and network access is represented as `additionalPermissions.network.enabled`. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. Optional persistence hints may also be included via `proposedExecpolicyAmendment` and `proposedNetworkPolicyAmendments`. Clients can prefer `availableDecisions` when present to render the exact set of choices the server wants to expose, while still falling back to the older heuristics if it is omitted. 3. Client response — for example `{ "decision": "accept" }`, `{ "decision": "acceptForSession" }`, `{ "decision": { "acceptWithExecpolicyAmendment": { "execpolicy_amendment": [...] } } }`, `{ "decision": { "applyNetworkPolicyAmendment": { "network_policy_amendment": { "host": "example.com", "action": "allow" } } } }`, `{ "decision": "decline" }`, or `{ "decision": "cancel" }`. 4. `serverRequest/resolved` — `{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt. 5. `item/completed` — final `commandExecution` item with `status: "completed" | "failed" | "declined"` and execution output. Render this as the authoritative result. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 868cef81fc..a78876a343 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -550,6 +550,7 @@ pub(crate) async fn apply_bespoke_event_handling( call_id, approval_id, turn_id, + environment_id, started_at_ms, command, cwd, @@ -626,6 +627,7 @@ pub(crate) async fn apply_bespoke_event_handling( item_id: call_id.clone(), started_at_ms, approval_id: approval_id.clone(), + environment_id, reason, network_approval_context, command, diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 52d74b8198..9ce815bafe 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -977,6 +977,7 @@ mod tests { item_id: "item-1".to_string(), started_at_ms: 0, approval_id: None, + environment_id: None, reason: None, network_approval_context: None, command: Some("echo hi".to_string()), diff --git a/codex-rs/app-server/src/transport_tests.rs b/codex-rs/app-server/src/transport_tests.rs index 4b9b387aaa..97372d6ae1 100644 --- a/codex-rs/app-server/src/transport_tests.rs +++ b/codex-rs/app-server/src/transport_tests.rs @@ -250,6 +250,7 @@ async fn command_execution_request_approval_strips_additional_permissions_withou item_id: "call_123".to_string(), started_at_ms: 0, approval_id: None, + environment_id: None, reason: Some("Need extra read access".to_string()), network_approval_context: None, command: Some("cat file".to_string()), @@ -315,6 +316,7 @@ async fn command_execution_request_approval_keeps_additional_permissions_with_ca item_id: "call_123".to_string(), started_at_ms: 0, approval_id: None, + environment_id: None, reason: Some("Need extra read access".to_string()), network_approval_context: None, command: Some("cat file".to_string()), 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 76b02a6d6e..dca58867c8 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -2067,6 +2067,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { panic!("expected CommandExecutionRequestApproval request"); }; assert_eq!(params.item_id, "call1"); + assert_eq!(params.environment_id.as_deref(), Some("local")); let resolved_request_id = request_id.clone(); // Approve and wait for task completion diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 16398a2f28..b3f0d6c43c 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -460,6 +460,7 @@ async fn handle_exec_approval( let ExecApprovalRequestEvent { call_id, approval_id, + environment_id, command, cwd, reason, @@ -505,6 +506,7 @@ async fn handle_exec_approval( parent_ctx, call_id, approval_id, + environment_id, command, cwd, reason, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index f97e81c6e8..21a86f224e 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -322,6 +322,7 @@ async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_f call_id: "command-item-1".to_string(), approval_id: Some("callback-approval-1".to_string()), turn_id: "child-turn-1".to_string(), + environment_id: Some("remote".to_string()), started_at_ms: 0, command: vec!["rm".to_string(), "-rf".to_string(), "tmp".to_string()], cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 01d6246ce7..cf19123591 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2039,6 +2039,7 @@ impl Session { turn_context: &TurnContext, call_id: String, approval_id: Option, + environment_id: Option, command: Vec, cwd: AbsolutePathBuf, reason: Option, @@ -2091,6 +2092,7 @@ impl Session { call_id, approval_id, turn_id: turn_context.sub_id.clone(), + environment_id, started_at_ms: now_unix_timestamp_ms(), command, cwd, diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index 9cc808a5b6..b05a745526 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -524,6 +524,7 @@ impl NetworkApprovalService { turn_context.as_ref(), guardian_approval_id, /*approval_id*/ None, + /*environment_id*/ None, prompt_command, #[allow(deprecated)] turn_context.cwd.clone(), diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 6cfa3b2dfc..f88c2be3d4 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -93,6 +93,7 @@ pub struct ShellRuntime { #[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)] pub(crate) struct ApprovalKey { + environment_id: String, command: Vec, cwd: AbsolutePathBuf, sandbox_permissions: SandboxPermissions, @@ -127,6 +128,7 @@ impl Approvable for ShellRuntime { fn approval_keys(&self, req: &ShellRequest) -> Vec { vec![ApprovalKey { + environment_id: req.turn_environment.environment_id.clone(), command: canonicalize_command_for_approval(&req.command), cwd: req.cwd.clone(), sandbox_permissions: req.sandbox_permissions, @@ -142,6 +144,7 @@ impl Approvable for ShellRuntime { let keys = self.approval_keys(req); let command = req.command.clone(); let cwd = req.cwd.clone(); + let environment_id = Some(req.turn_environment.environment_id.clone()); let retry_reason = ctx.retry_reason.clone(); let reason = retry_reason.clone().or_else(|| req.justification.clone()); let session = ctx.session; @@ -173,6 +176,7 @@ impl Approvable for ShellRuntime { turn, call_id, /*approval_id*/ None, + environment_id, command, cwd, reason, @@ -325,3 +329,7 @@ impl ToolRuntime for ShellRuntime { Ok(out) } } + +#[cfg(test)] +#[path = "shell_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index f2df497089..70390cb3c2 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -222,6 +222,7 @@ pub(super) async fn try_run_zsh_fork( session: Arc::clone(&ctx.session), turn: Arc::clone(&ctx.turn), call_id: ctx.call_id.clone(), + environment_id: req.turn_environment.environment_id.clone(), tool_name: GuardianCommandSource::Shell, approval_policy: ctx.turn.approval_policy.value(), permission_profile: command_executor.permission_profile.clone(), @@ -294,6 +295,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( session: Arc::clone(&ctx.session), turn: Arc::clone(&ctx.turn), call_id: ctx.call_id.clone(), + environment_id: req.turn_environment.environment_id.clone(), tool_name: GuardianCommandSource::UnifiedExec, approval_policy: ctx.turn.approval_policy.value(), permission_profile: exec_request.permission_profile.clone(), @@ -328,6 +330,7 @@ struct CoreShellActionProvider { session: Arc, turn: Arc, call_id: String, + environment_id: String, tool_name: GuardianCommandSource, approval_policy: AskForApproval, permission_profile: PermissionProfile, @@ -424,6 +427,7 @@ impl CoreShellActionProvider { let turn = self.turn.clone(); let call_id = self.call_id.clone(); let approval_id = Some(Uuid::new_v4().to_string()); + let environment_id = Some(self.environment_id.clone()); let source = self.tool_name; let guardian_review_id = routes_approval_to_guardian(&turn).then(new_guardian_review_id); Ok(stopwatch @@ -489,6 +493,7 @@ impl CoreShellActionProvider { &turn, call_id, approval_id, + environment_id, command, workdir.clone(), /*reason*/ None, diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 6d42e7c84d..2b00147612 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -425,6 +425,7 @@ async fn preapproved_additional_permissions_escalate_intercepted_exec() -> anyho session: Arc::new(session), turn: Arc::new(turn_context), call_id: "preapproved-additional-permissions".to_string(), + environment_id: "local".to_string(), tool_name: GuardianCommandSource::Shell, approval_policy: AskForApproval::OnRequest, permission_profile: permission_profile.clone(), @@ -560,6 +561,7 @@ async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Resul session: std::sync::Arc::new(session), turn: std::sync::Arc::new(turn_context), call_id: "execve-hook-call".to_string(), + environment_id: "local".to_string(), tool_name: GuardianCommandSource::Shell, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), @@ -770,6 +772,7 @@ prefix_rule(pattern = ["{cat_path_literal}"], decision = "allow") session: Arc::new(session), turn: Arc::new(turn_context), call_id: "deny-read-prefix-allow".to_string(), + environment_id: "local".to_string(), tool_name: GuardianCommandSource::Shell, approval_policy: AskForApproval::OnRequest, permission_profile, @@ -806,6 +809,7 @@ async fn denied_reads_keep_granular_sandbox_rejection_for_escalation() -> anyhow session: Arc::new(session), turn: Arc::new(turn_context), call_id: "deny-read-granular-sandbox-reject".to_string(), + environment_id: "local".to_string(), tool_name: GuardianCommandSource::Shell, approval_policy: AskForApproval::Granular(GranularApprovalConfig { sandbox_approval: false, diff --git a/codex-rs/core/src/tools/runtimes/shell_tests.rs b/codex-rs/core/src/tools/runtimes/shell_tests.rs new file mode 100644 index 0000000000..eaa7adf48c --- /dev/null +++ b/codex-rs/core/src/tools/runtimes/shell_tests.rs @@ -0,0 +1,42 @@ +use super::*; +use codex_exec_server::Environment; +use codex_utils_path_uri::PathUri; +use std::sync::Arc; + +#[tokio::test] +async fn approval_key_includes_environment_id() { + let cwd = AbsolutePathBuf::try_from(std::env::current_dir().expect("read current dir")) + .expect("current dir is absolute"); + let mut request = ShellRequest { + command: vec!["echo".to_string(), "hello".to_string()], + turn_environment: TurnEnvironment::new( + "remote".to_string(), + Arc::new(Environment::default_for_tests()), + PathUri::from_abs_path(&cwd), + /*shell*/ None, + ), + shell_type: None, + hook_command: "echo hello".to_string(), + cwd: cwd.clone(), + timeout_ms: None, + cancellation_token: CancellationToken::new(), + env: HashMap::new(), + explicit_env_overrides: HashMap::new(), + network: None, + sandbox_permissions: SandboxPermissions::UseDefault, + additional_permissions: None, + #[cfg(unix)] + additional_permissions_preapproved: false, + justification: None, + exec_approval_requirement: ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: None, + }, + }; + let runtime = ShellRuntime::for_shell_command(ShellRuntimeBackend::ShellCommandClassic); + let original_key = runtime.approval_keys(&request); + request.turn_environment.environment_id = "other".to_string(); + let other_key = runtime.approval_keys(&request); + + assert_ne!(original_key, other_key); +} diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index eb23fc0ced..0b141faabc 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -83,6 +83,7 @@ pub struct UnifiedExecRequest { /// unified-exec launches. #[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)] pub struct UnifiedExecApprovalKey { + pub environment_id: String, pub command: Vec, pub cwd: AbsolutePathBuf, pub tty: bool, @@ -135,6 +136,7 @@ impl Approvable for UnifiedExecRuntime<'_> { fn approval_keys(&self, req: &UnifiedExecRequest) -> Vec { vec![UnifiedExecApprovalKey { + environment_id: req.turn_environment.environment_id.clone(), command: canonicalize_command_for_approval(&req.command), cwd: req.cwd.clone(), tty: req.tty, @@ -154,6 +156,7 @@ impl Approvable for UnifiedExecRuntime<'_> { let call_id = ctx.call_id.to_string(); let command = req.command.clone(); let cwd = req.cwd.clone(); + let environment_id = Some(req.turn_environment.environment_id.clone()); let retry_reason = ctx.retry_reason.clone(); let reason = retry_reason.clone().or_else(|| req.justification.clone()); let guardian_review_id = ctx.guardian_review_id.clone(); @@ -183,6 +186,7 @@ impl Approvable for UnifiedExecRuntime<'_> { turn, call_id, /*approval_id*/ None, + environment_id, command, cwd.clone(), reason, @@ -463,6 +467,25 @@ mod tests { } } + #[tokio::test] + async fn approval_key_includes_environment_id() { + let manager = UnifiedExecProcessManager::default(); + let runtime = UnifiedExecRuntime::new(&manager, UnifiedExecShellMode::Direct); + let mut request = test_request( + SandboxPermissions::UseDefault, + ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: None, + }, + ); + request.turn_environment.environment_id = "remote".to_string(); + let original_key = runtime.approval_keys(&request); + request.turn_environment.environment_id = "other".to_string(); + let other_key = runtime.approval_keys(&request); + + assert_ne!(original_key, other_key); + } + #[tokio::test] async fn unified_exec_uses_the_trusted_sandbox_cwd() { let cwd_dir = tempdir().expect("create process temp dir"); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index c4ec218dc9..d605b640ca 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -220,6 +220,7 @@ async fn run_codex_tool_session_inner( let approval_id = ev.effective_approval_id(); let ExecApprovalRequestEvent { turn_id: _, + environment_id: _, started_at_ms: _, command, cwd, diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index ace096359c..314f6bd923 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -229,6 +229,16 @@ pub struct ExecApprovalRequestEvent { /// Uses `#[serde(default)]` for backwards compatibility. #[serde(default)] pub turn_id: String, + /// Environment in which the command will run. + #[serde( + default, + rename = "environmentId", + alias = "environment_id", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional)] + #[ts(rename = "environmentId")] + pub environment_id: Option, #[ts(type = "number")] pub started_at_ms: i64, /// The command to be executed. diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index e4ab347776..d61e84dcec 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -452,6 +452,7 @@ mod tests { item_id: "call-1".to_string(), started_at_ms: 0, approval_id: Some("approval-1".to_string()), + environment_id: None, reason: None, network_approval_context: None, command: Some("ls".to_string()), @@ -791,6 +792,7 @@ mod tests { item_id: "call-1".to_string(), started_at_ms: 0, approval_id: Some("approval-1".to_string()), + environment_id: None, reason: None, network_approval_context: None, command: Some("ls".to_string()), diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 4f90dbe9cf..406cd687ff 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -615,6 +615,7 @@ mod tests { item_id: call_id.to_string(), started_at_ms: 0, approval_id: approval_id.map(str::to_string), + environment_id: None, reason: None, network_approval_context: None, command: Some("echo hi".to_string()), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 87538251aa..6a88c4ccf0 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4690,6 +4690,7 @@ fn exec_approval_request( item_id: item_id.to_string(), started_at_ms: 0, approval_id: approval_id.map(str::to_string), + environment_id: None, reason: Some("needs approval".to_string()), network_approval_context: None, command: Some("echo hello".to_string()), diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 48dc1d0848..6b76a65186 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -488,6 +488,7 @@ mod tests { item_id: item_id.to_string(), started_at_ms: 0, approval_id: approval_id.map(str::to_string), + environment_id: None, reason: Some("needs approval".to_string()), network_approval_context: None, command: Some("echo hello".to_string()), diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index c83920556c..f436ac51b3 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -226,6 +226,7 @@ impl App { .approval_id .clone() .unwrap_or_else(|| params.item_id.clone()), + environment_id: params.environment_id.clone(), command: params .command .as_deref() diff --git a/codex-rs/tui/src/approval_events.rs b/codex-rs/tui/src/approval_events.rs index 57e0959d56..ba183225b8 100644 --- a/codex-rs/tui/src/approval_events.rs +++ b/codex-rs/tui/src/approval_events.rs @@ -26,6 +26,8 @@ pub(crate) struct ExecApprovalRequestEvent { pub(crate) approval_id: Option, #[serde(default)] pub(crate) turn_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) environment_id: Option, pub(crate) command: Vec, pub(crate) cwd: AbsolutePathBuf, pub(crate) reason: Option, diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index b321aeb159..3cc135da74 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -74,6 +74,7 @@ pub(crate) enum ApprovalRequest { thread_id: ThreadId, thread_label: Option, id: String, + environment_id: Option, command: Vec, reason: Option, available_decisions: Vec, @@ -675,6 +676,7 @@ fn build_header(request: &ApprovalRequest) -> Box { match request { ApprovalRequest::Exec { thread_label, + environment_id, reason, command, network_approval_context, @@ -689,6 +691,13 @@ fn build_header(request: &ApprovalRequest) -> Box { ])); header.push(Line::from("")); } + if let Some(environment_id) = environment_id { + header.push(Line::from(vec![ + "Environment: ".into(), + environment_id.clone().bold(), + ])); + header.push(Line::from("")); + } if let Some(reason) = reason { header.push(Line::from(vec!["Reason: ".into(), reason.clone().italic()])); header.push(Line::from("")); @@ -1220,6 +1229,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, id: "test".to_string(), + environment_id: None, command: vec!["echo".to_string(), "hi".to_string()], reason: Some("reason".to_string()), available_decisions: vec![ @@ -1360,6 +1370,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, id: "test".to_string(), + environment_id: None, command: vec!["echo".to_string(), "hi".to_string()], reason: None, available_decisions: vec![ @@ -1403,6 +1414,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, id: "test".to_string(), + environment_id: None, command: vec!["curl".to_string(), "https://example.com".to_string()], reason: None, available_decisions: vec![ @@ -1477,6 +1489,7 @@ mod tests { thread_id, thread_label: Some("Robie [explorer]".to_string()), id: "test".to_string(), + environment_id: None, command: vec!["echo".to_string(), "hi".to_string()], reason: None, available_decisions: vec![ @@ -1511,6 +1524,7 @@ mod tests { thread_id, thread_label: Some("Robie [explorer]".to_string()), id: "test".to_string(), + environment_id: None, command: vec!["echo".to_string(), "hi".to_string()], reason: None, available_decisions: vec![ @@ -1549,6 +1563,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: Some("Robie [explorer]".to_string()), id: "test".to_string(), + environment_id: None, command: vec!["echo".to_string(), "hi".to_string()], reason: None, available_decisions: vec![ @@ -1577,6 +1592,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, id: "test".to_string(), + environment_id: None, command: vec!["echo".to_string()], reason: None, available_decisions: vec![ @@ -1629,6 +1645,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, id: "test".to_string(), + environment_id: None, command: vec!["curl".to_string(), "https://example.com".to_string()], reason: None, available_decisions: vec![ @@ -1668,6 +1685,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, id: "test".into(), + environment_id: None, command, reason: None, available_decisions: vec![ @@ -1966,6 +1984,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, id: "test".into(), + environment_id: None, command: vec!["cat".into(), "/tmp/readme.txt".into()], reason: None, available_decisions: vec![ @@ -2022,6 +2041,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, id: "test".into(), + environment_id: None, command: vec!["cat".into(), "/tmp/readme.txt".into()], reason: Some("need filesystem access".into()), available_decisions: vec![ @@ -2102,6 +2122,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, id: "test".into(), + environment_id: None, command: vec!["curl".into(), "https://example.com".into()], reason: Some("network request blocked".into()), available_decisions: vec![ @@ -2238,6 +2259,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, id: "test".into(), + environment_id: None, command: vec![ "network-access".to_string(), "https://example.com:8443".to_string(), diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index e492024765..951c310d1f 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1896,6 +1896,7 @@ mod tests { thread_id: codex_protocol::ThreadId::new(), thread_label: None, id: "1".to_string(), + environment_id: None, command: vec!["echo".into(), "ok".into()], reason: None, available_decisions: vec![ diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 6495b8f1fe..60973c13d2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -852,6 +852,7 @@ fn exec_approval_request_from_params( additional_permissions: params.additional_permissions, turn_id: params.turn_id, approval_id: params.approval_id, + environment_id: params.environment_id, proposed_execpolicy_amendment: params.proposed_execpolicy_amendment, proposed_network_policy_amendments: params.proposed_network_policy_amendments, available_decisions: params.available_decisions, diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index 301f918418..f8ecb3d0aa 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -161,6 +161,7 @@ mod tests { call_id: call_id.to_string(), approval_id: approval_id.map(str::to_string), turn_id: "turn".to_string(), + environment_id: None, command: vec!["true".to_string()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: None, diff --git a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs index bde795ca95..5c9b92b517 100644 --- a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs +++ b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs @@ -13,6 +13,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() { call_id: "call-short".into(), approval_id: Some("call-short".into()), turn_id: "turn-short".into(), + environment_id: Some("remote".to_string()), command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some( @@ -57,6 +58,7 @@ fn app_server_exec_approval_request_splits_shell_wrapped_command() { item_id: "item-1".to_string(), started_at_ms: 0, approval_id: Some("approval-1".to_string()), + environment_id: None, reason: None, network_approval_context: None, command: Some( @@ -98,6 +100,7 @@ fn app_server_exec_approval_request_preserves_permissions_context() { item_id: "item-1".to_string(), started_at_ms: 0, approval_id: Some("approval-1".to_string()), + environment_id: None, reason: None, network_approval_context: Some(codex_app_server_protocol::NetworkApprovalContext { host: "example.com".to_string(), @@ -157,6 +160,7 @@ async fn network_exec_approval_history_describes_session_host_allowance() { item_id: "item-1".to_string(), started_at_ms: 0, approval_id: Some("approval-1".to_string()), + environment_id: None, reason: None, network_approval_context: Some(codex_app_server_protocol::NetworkApprovalContext { host: "example.com".to_string(), @@ -198,6 +202,7 @@ async fn network_exec_approval_history_describes_one_time_host_allowance() { item_id: "item-1".to_string(), started_at_ms: 0, approval_id: Some("approval-1".to_string()), + environment_id: None, reason: None, network_approval_context: Some(codex_app_server_protocol::NetworkApprovalContext { host: "example.com".to_string(), @@ -239,6 +244,7 @@ async fn network_exec_approval_history_describes_canceled_host_request() { item_id: "item-1".to_string(), started_at_ms: 0, approval_id: Some("approval-1".to_string()), + environment_id: None, reason: None, network_approval_context: Some(codex_app_server_protocol::NetworkApprovalContext { host: "example.com".to_string(), @@ -330,6 +336,7 @@ async fn exec_approval_uses_approval_id_when_present() { call_id: "call-parent".into(), approval_id: Some("approval-subcommand".into()), turn_id: "turn-short".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some( @@ -372,6 +379,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { call_id: "call-multi".into(), approval_id: Some("call-multi".into()), turn_id: "turn-multi".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo line1\necho line2".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some( @@ -423,6 +431,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { call_id: "call-long".into(), approval_id: Some("call-long".into()), turn_id: "turn-long".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), long], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: None, diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index ca1c9bc681..60eb2d891d 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -10,6 +10,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() { call_id: "call-short".into(), approval_id: Some("call-short".into()), turn_id: "turn-short".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some( @@ -56,6 +57,7 @@ fn app_server_exec_approval_request_splits_shell_wrapped_command() { item_id: "item-1".to_string(), started_at_ms: 0, approval_id: Some("approval-1".to_string()), + environment_id: None, reason: None, network_approval_context: None, command: Some( @@ -93,6 +95,7 @@ async fn exec_approval_uses_approval_id_when_present() { call_id: "call-parent".into(), approval_id: Some("approval-subcommand".into()), turn_id: "turn-short".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some( @@ -136,6 +139,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { call_id: "call-multi".into(), approval_id: Some("call-multi".into()), turn_id: "turn-multi".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo line1\necho line2".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some( @@ -189,6 +193,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { call_id: "call-long".into(), approval_id: Some("call-long".into()), turn_id: "turn-long".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), long], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: None, @@ -1098,6 +1103,7 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> { call_id: "call-approve-cmd".into(), approval_id: Some("call-approve-cmd".into()), turn_id: "turn-approve-cmd".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some( @@ -1155,6 +1161,7 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> { call_id: "call-approve-cmd-noreason".into(), approval_id: Some("call-approve-cmd-noreason".into()), turn_id: "turn-approve-cmd-noreason".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: None, @@ -1201,6 +1208,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() call_id: "call-approve-cmd-multiline-trunc".into(), approval_id: Some("call-approve-cmd-multiline-trunc".into()), turn_id: "turn-approve-cmd-multiline-trunc".into(), + environment_id: None, command: command.clone(), cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: None, diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__exec_approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__exec_approval_modal_exec.snap index 7e766d67ed..fbb12551fd 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__exec_approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__exec_approval_modal_exec.snap @@ -3,12 +3,14 @@ source: tui/src/chatwidget/tests/approval_requests.rs expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 13 }, + area: Rect { x: 0, y: 0, width: 80, height: 15 }, content: [ " ", " ", " Would you like to run the following command? ", " ", + " Environment: remote ", + " ", " Reason: this is a test reason such as one that would be produced by the ", " model ", " ", @@ -23,17 +25,19 @@ Buffer { x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, x: 46, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 10, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, - x: 73, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, - x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 7, fg: Rgb(137, 180, 250), bg: Reset, underline: Reset, modifier: NONE, - x: 8, y: 7, fg: Rgb(205, 214, 244), bg: Reset, underline: Reset, modifier: NONE, - x: 20, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, - x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 51, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 15, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 21, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 10, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, + x: 73, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, + x: 7, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 9, fg: Rgb(137, 180, 250), bg: Reset, underline: Reset, modifier: NONE, + x: 8, y: 9, fg: Rgb(205, 214, 244), bg: Reset, underline: Reset, modifier: NONE, + x: 20, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 11, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 21, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 48, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 51, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, ] } diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index f748d94e81..1667501335 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1864,6 +1864,7 @@ async fn status_widget_and_approval_modal_snapshot() { call_id: "call-approve-exec".into(), approval_id: Some("call-approve-exec".into()), turn_id: "turn-approve-exec".into(), + environment_id: None, command: vec!["echo".into(), "hello world".into()], cwd: test_path_buf("/tmp").abs(), reason: Some( diff --git a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs index 317942485c..7445162759 100644 --- a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs +++ b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs @@ -13,6 +13,7 @@ async fn terminal_title_shows_action_required_while_exec_approval_is_pending() { call_id: "call-action-required".into(), approval_id: Some("call-action-required".into()), turn_id: "turn-action-required".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo hello".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some("need confirmation".into()), @@ -55,6 +56,7 @@ async fn terminal_title_action_required_respects_spinner_setting() { call_id: "call-no-spinner".into(), approval_id: Some("call-no-spinner".into()), turn_id: "turn-no-spinner".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo hello".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some("need confirmation".into()), @@ -83,6 +85,7 @@ async fn terminal_title_action_required_blinks_when_animations_are_enabled() { call_id: "call-blink".into(), approval_id: Some("call-blink".into()), turn_id: "turn-blink".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo hello".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some("need confirmation".into()), @@ -118,6 +121,7 @@ async fn terminal_title_activity_indicators_do_not_animate_when_animations_are_d call_id: "call-no-animations".into(), approval_id: Some("call-no-animations".into()), turn_id: "turn-no-animations".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo hello".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some("need confirmation".into()), diff --git a/codex-rs/tui/src/chatwidget/tool_requests.rs b/codex-rs/tui/src/chatwidget/tool_requests.rs index 3001563451..99b0c65207 100644 --- a/codex-rs/tui/src/chatwidget/tool_requests.rs +++ b/codex-rs/tui/src/chatwidget/tool_requests.rs @@ -296,6 +296,7 @@ impl ChatWidget { thread_id: self.thread_id.unwrap_or_default(), thread_label: None, id: ev.effective_approval_id(), + environment_id: ev.environment_id, command: ev.command, reason: ev.reason, available_decisions, From d9e0551564f7a71fe114b8c6c744750920c321aa Mon Sep 17 00:00:00 2001 From: Adrian <145513011+adrian-openai@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:23:39 -0700 Subject: [PATCH 35/70] feat: add run task identity primitives (#19047) ## Stack This is PR 1 of the simplified HAI single-run-task stack: - [#19047](https://github.com/openai/codex/pull/19047) Agent Identity assertion and task-registration primitives, including the shared run-task helper used by existing Agent Identity JWT auth. - [#19049](https://github.com/openai/codex/pull/19049) Disabled-by-default ChatGPT auth opt-in that provisions/reuses persisted Agent Identity runtime auth and its single run task. - [#19051](https://github.com/openai/codex/pull/19051) Run-scoped provider auth that uses one backend-owned task id for first-party inference and compaction requests. [#19054](https://github.com/openai/codex/pull/19054) collapsed out of the active stack because the simplified design no longer needs a separate background/control-plane task helper. ## Summary The simplified POC shape is one backend-owned task per Agent Identity run. This PR makes the first layer match that final shape directly instead of introducing task targets, caller-owned external task refs, or intermediate wrappers that later PRs would need to undo. What changed: - keeps the `AgentAssertion` wire payload as `agent_runtime_id`, `task_id`, `timestamp`, and `signature` - exposes `register_agent_task` as the single task-registration helper for both existing Agent Identity JWT auth and the ChatGPT-registration path added later in the stack - makes task registration send only the signed registration timestamp; the backend owns the returned opaque task id - removes the unused target/task-kind/external-task-ref surfaces from `codex-agent-identity` - keeps Agent Identity JWT JWKS lookup separate from agent/task registration URL derivation - updates Agent Identity JWT auth to register one run task during auth construction and share that task across cloned auth handles This PR intentionally does not enable ChatGPT-derived Agent Identity. That opt-in and config gate are added in the next PR. ## Testing - `just test -p codex-agent-identity` --- codex-rs/agent-identity/src/lib.rs | 404 +++++++++++++++++----- codex-rs/cli/src/main.rs | 9 +- codex-rs/exec/src/lib.rs | 1 + codex-rs/login/src/auth/agent_identity.rs | 220 +++++++----- codex-rs/login/src/auth/auth_tests.rs | 45 ++- codex-rs/login/src/auth/manager.rs | 58 +++- codex-rs/model-provider/src/auth.rs | 6 +- codex-rs/tui/src/lib.rs | 1 + 8 files changed, 546 insertions(+), 198 deletions(-) diff --git a/codex-rs/agent-identity/src/lib.rs b/codex-rs/agent-identity/src/lib.rs index 7aad81a34f..0e29a38700 100644 --- a/codex-rs/agent-identity/src/lib.rs +++ b/codex-rs/agent-identity/src/lib.rs @@ -1,4 +1,6 @@ use std::collections::BTreeMap; +use std::error::Error as StdError; +use std::fmt; use std::time::Duration; use anyhow::Context; @@ -34,21 +36,66 @@ const AGENT_TASK_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(30); const AGENT_IDENTITY_JWKS_TIMEOUT: Duration = Duration::from_secs(10); const AGENT_IDENTITY_JWT_AUDIENCE: &str = "codex-app-server"; const AGENT_IDENTITY_JWT_ISSUER: &str = "https://chatgpt.com/codex-backend/agent-identity"; +const AGENT_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(15); +const PROD_AGENT_IDENTITY_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts"; +const STAGING_AGENT_IDENTITY_AUTHAPI_BASE_URL: &str = "https://auth.api.openai.org/api/accounts"; +const AGENT_IDENTITY_KEY_SEED_BYTES: usize = 64; +const AGENT_IDENTITY_KEY_DERIVATION_CONTEXT: &[u8] = b"codex-agent-identity-ed25519-v1"; -/// Stored key material for a registered agent identity. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ChatGptEnvironment { + #[default] + Production, + Staging, +} + +impl ChatGptEnvironment { + pub fn from_chatgpt_base_url(chatgpt_base_url: &str) -> Result { + match chatgpt_base_url.trim_end_matches('/') { + "https://chatgpt.com" + | "https://chatgpt.com/backend-api" + | "https://chatgpt.com/codex" + | "https://chatgpt.com/backend-api/codex" + | "https://chat.openai.com" + | "https://chat.openai.com/backend-api" + | "https://chat.openai.com/codex" + | "https://chat.openai.com/backend-api/codex" => Ok(Self::Production), + "https://chatgpt-staging.com" + | "https://chatgpt-staging.com/backend-api" + | "https://chatgpt-staging.com/codex" + | "https://chatgpt-staging.com/backend-api/codex" => Ok(Self::Staging), + _ => anyhow::bail!( + "Agent Identity only supports production and staging ChatGPT environments" + ), + } + } + + pub fn chatgpt_base_url(self) -> &'static str { + match self { + Self::Production => "https://chatgpt.com/backend-api", + Self::Staging => "https://chatgpt-staging.com/backend-api", + } + } + + pub fn agent_identity_authapi_base_url(self) -> &'static str { + match self { + Self::Production => PROD_AGENT_IDENTITY_AUTHAPI_BASE_URL, + Self::Staging => STAGING_AGENT_IDENTITY_AUTHAPI_BASE_URL, + } + } +} + +/// Borrowed durable signing material for a registered agent identity. +/// +/// This intentionally does not include a task id. Task ids are scoped to a +/// single Codex run, while the agent runtime id and private key are the +/// reusable identity material used to register and sign that run task. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct AgentIdentityKey<'a> { pub agent_runtime_id: &'a str, pub private_key_pkcs8_base64: &'a str, } -/// Task binding to use when constructing a task-scoped AgentAssertion. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct AgentTaskAuthorizationTarget<'a> { - pub agent_runtime_id: &'a str, - pub task_id: &'a str, -} - #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct AgentBillOfMaterials { pub agent_version: String, @@ -103,23 +150,92 @@ struct RegisterTaskResponse { encrypted_task_id_camel: Option, } +#[derive(Debug, Serialize)] +struct RegisterAgentRequest { + abom: AgentBillOfMaterials, + agent_public_key: String, + capabilities: Vec, + ttl: Option, +} + +#[derive(Debug, Deserialize)] +struct RegisterAgentResponse { + agent_runtime_id: String, +} + +/// HTTP status failure returned by Agent Identity registration endpoints. +#[derive(Debug)] +pub struct AgentIdentityRegistrationHttpError { + operation: &'static str, + status: reqwest::StatusCode, + body: String, +} + +impl AgentIdentityRegistrationHttpError { + fn new(operation: &'static str, status: reqwest::StatusCode, body: String) -> Self { + Self { + operation, + status, + body, + } + } + + /// HTTP status returned by the registration endpoint. + pub fn status(&self) -> reqwest::StatusCode { + self.status + } +} + +impl fmt::Display for AgentIdentityRegistrationHttpError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.body.is_empty() { + write!(f, "{} failed with status {}", self.operation, self.status) + } else { + write!( + f, + "{} failed with status {}: {}", + self.operation, self.status, self.body + ) + } + } +} + +impl StdError for AgentIdentityRegistrationHttpError {} + +/// Returns whether an Agent Identity registration error is safe to retry. +pub fn is_retryable_registration_error(error: &anyhow::Error) -> bool { + error.chain().any(is_retryable_registration_cause) +} + +fn is_retryable_registration_cause(cause: &(dyn StdError + 'static)) -> bool { + if let Some(error) = cause.downcast_ref::() { + return is_retryable_registration_status(error.status()); + } + + if let Some(error) = cause.downcast_ref::() { + if let Some(status) = error.status() { + return is_retryable_registration_status(status); + } + return error.is_timeout() || error.is_connect() || error.is_request(); + } + + false +} + +fn is_retryable_registration_status(status: reqwest::StatusCode) -> bool { + status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error() +} + pub fn authorization_header_for_agent_task( key: AgentIdentityKey<'_>, - target: AgentTaskAuthorizationTarget<'_>, + task_id: &str, ) -> Result { - anyhow::ensure!( - key.agent_runtime_id == target.agent_runtime_id, - "agent task runtime {} does not match stored agent identity {}", - target.agent_runtime_id, - key.agent_runtime_id - ); - let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); let envelope = AgentAssertionEnvelope { - agent_runtime_id: target.agent_runtime_id.to_string(), - task_id: target.task_id.to_string(), + agent_runtime_id: key.agent_runtime_id.to_string(), + task_id: task_id.to_string(), timestamp: timestamp.clone(), - signature: sign_agent_assertion_payload(key, target.task_id, ×tamp)?, + signature: sign_agent_assertion_payload(key, task_id, ×tamp)?, }; let serialized_assertion = serialize_agent_assertion(&envelope)?; Ok(format!("AgentAssertion {serialized_assertion}")) @@ -127,10 +243,10 @@ pub fn authorization_header_for_agent_task( pub async fn fetch_agent_identity_jwks( client: &reqwest::Client, - chatgpt_base_url: &str, + agent_identity_jwt_base_url: &str, ) -> Result { let response = client - .get(agent_identity_jwks_url(chatgpt_base_url)) + .get(agent_identity_jwks_url(agent_identity_jwt_base_url)) .timeout(AGENT_IDENTITY_JWKS_TIMEOUT) .send() .await @@ -195,7 +311,7 @@ pub fn sign_task_registration_payload( pub async fn register_agent_task( client: &reqwest::Client, - chatgpt_base_url: &str, + agent_identity_authapi_base_url: &str, key: AgentIdentityKey<'_>, ) -> Result { let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); @@ -203,7 +319,7 @@ pub async fn register_agent_task( signature: sign_task_registration_payload(key, ×tamp)?, timestamp, }; - let url = agent_task_registration_url(chatgpt_base_url, key.agent_runtime_id); + let url = agent_task_registration_url(agent_identity_authapi_base_url, key.agent_runtime_id); let response = client .post(url) @@ -220,7 +336,12 @@ pub async fn register_agent_task( } else { body }; - anyhow::bail!("failed to register agent task with status {status}: {body}"); + return Err(AgentIdentityRegistrationHttpError::new( + "agent task registration", + status, + body, + ) + .into()); } let response = response @@ -231,6 +352,45 @@ pub async fn register_agent_task( task_id_from_register_task_response(key, response) } +pub async fn register_agent_identity( + client: &reqwest::Client, + agent_identity_authapi_base_url: &str, + access_token: &str, + is_fedramp_account: bool, + key_material: &GeneratedAgentKeyMaterial, + abom: AgentBillOfMaterials, + capabilities: Vec, +) -> Result { + let url = agent_registration_url(agent_identity_authapi_base_url); + let request = RegisterAgentRequest { + abom, + agent_public_key: key_material.public_key_ssh.clone(), + capabilities, + ttl: None, + }; + + let mut request_builder = client + .post(&url) + .bearer_auth(access_token) + .json(&request) + .timeout(AGENT_REGISTRATION_TIMEOUT); + if is_fedramp_account { + request_builder = request_builder.header("X-OpenAI-Fedramp", "true"); + } + + let response = request_builder + .send() + .await + .with_context(|| format!("failed to send agent identity registration request to {url}"))? + .error_for_status() + .with_context(|| format!("agent identity registration failed for {url}"))? + .json::() + .await + .with_context(|| format!("failed to parse agent identity response from {url}"))?; + + Ok(response.agent_runtime_id) +} + fn task_id_from_register_task_response( key: AgentIdentityKey<'_>, response: RegisterTaskResponse, @@ -260,10 +420,17 @@ pub fn decrypt_task_id_response( } pub fn generate_agent_key_material() -> Result { - let mut secret_key_bytes = [0u8; 32]; + let mut seed_material = [0u8; AGENT_IDENTITY_KEY_SEED_BYTES]; OsRng - .try_fill_bytes(&mut secret_key_bytes) - .context("failed to generate agent identity private key bytes")?; + .try_fill_bytes(&mut seed_material) + .context("failed to generate agent identity private key seed material")?; + // Ed25519 stores a 32-byte seed, so derive it from all sampled seed material. + let mut digest = Sha512::new(); + digest.update(AGENT_IDENTITY_KEY_DERIVATION_CONTEXT); + digest.update(seed_material); + let digest = digest.finalize(); + let mut secret_key_bytes = [0u8; 32]; + secret_key_bytes.copy_from_slice(&digest[..32]); let signing_key = SigningKey::from_bytes(&secret_key_bytes); let private_key_pkcs8 = signing_key .to_pkcs8_der() @@ -296,23 +463,22 @@ pub fn curve25519_secret_key_from_private_key_pkcs8_base64( Ok(curve25519_secret_key_from_signing_key(&signing_key)) } -pub fn agent_registration_url(chatgpt_base_url: &str) -> String { - let trimmed = chatgpt_base_url.trim_end_matches('/'); - format!("{trimmed}/v1/agent/register") +pub fn agent_registration_url(agent_identity_authapi_base_url: &str) -> String { + agent_identity_authapi_url(agent_identity_authapi_base_url, "/v1/agent/register") } -pub fn agent_task_registration_url(chatgpt_base_url: &str, agent_runtime_id: &str) -> String { - let trimmed = chatgpt_base_url.trim_end_matches('/'); - format!("{trimmed}/v1/agent/{agent_runtime_id}/task/register") +pub fn agent_task_registration_url( + agent_identity_authapi_base_url: &str, + agent_runtime_id: &str, +) -> String { + agent_identity_authapi_url( + agent_identity_authapi_base_url, + &format!("/v1/agent/{agent_runtime_id}/task/register"), + ) } -pub fn agent_identity_biscuit_url(chatgpt_base_url: &str) -> String { - let trimmed = chatgpt_base_url.trim_end_matches('/'); - format!("{trimmed}/authenticate_app_v2") -} - -pub fn agent_identity_jwks_url(chatgpt_base_url: &str) -> String { - let trimmed = chatgpt_base_url.trim_end_matches('/'); +pub fn agent_identity_jwks_url(agent_identity_jwt_base_url: &str) -> String { + let trimmed = agent_identity_jwt_base_url.trim_end_matches('/'); if trimmed.contains("/backend-api") { format!("{trimmed}/wham/agent-identities/jwks") } else { @@ -320,15 +486,9 @@ pub fn agent_identity_jwks_url(chatgpt_base_url: &str) -> String { } } -pub fn agent_identity_request_id() -> Result { - let mut request_id_bytes = [0u8; 16]; - OsRng - .try_fill_bytes(&mut request_id_bytes) - .context("failed to generate agent identity request id")?; - Ok(format!( - "codex-agent-identity-{}", - URL_SAFE_NO_PAD.encode(request_id_bytes) - )) +fn agent_identity_authapi_url(agent_identity_authapi_base_url: &str, api_path: &str) -> String { + let base_url = agent_identity_authapi_base_url.trim_end_matches('/'); + format!("{base_url}{api_path}") } pub fn build_abom(session_source: SessionSource) -> AgentBillOfMaterials { @@ -412,6 +572,24 @@ mod tests { use super::*; + #[test] + fn register_task_request_uses_single_run_task_shape() { + let request = RegisterTaskRequest { + timestamp: "2026-04-23T00:00:00Z".to_string(), + signature: "signature".to_string(), + }; + + let serialized = serde_json::to_value(request).expect("serialize request"); + + assert_eq!( + serialized, + serde_json::json!({ + "timestamp": "2026-04-23T00:00:00Z", + "signature": "signature", + }) + ); + } + #[test] fn authorization_header_for_agent_task_serializes_signed_agent_assertion() { let signing_key = SigningKey::from_bytes(&[7u8; 32]); @@ -422,13 +600,9 @@ mod tests { agent_runtime_id: "agent-123", private_key_pkcs8_base64: &BASE64_STANDARD.encode(private_key.as_bytes()), }; - let target = AgentTaskAuthorizationTarget { - agent_runtime_id: "agent-123", - task_id: "task-123", - }; - let header = - authorization_header_for_agent_task(key, target).expect("build agent assertion header"); + let header = authorization_header_for_agent_task(key, "task-123") + .expect("build agent assertion header"); let token = header .strip_prefix("AgentAssertion ") .expect("agent assertion scheme"); @@ -464,31 +638,6 @@ mod tests { .expect("signature should verify"); } - #[test] - fn authorization_header_for_agent_task_rejects_mismatched_runtime() { - let signing_key = SigningKey::from_bytes(&[7u8; 32]); - let private_key = signing_key - .to_pkcs8_der() - .expect("encode test key material"); - let private_key_pkcs8_base64 = BASE64_STANDARD.encode(private_key.as_bytes()); - let key = AgentIdentityKey { - agent_runtime_id: "agent-123", - private_key_pkcs8_base64: &private_key_pkcs8_base64, - }; - let target = AgentTaskAuthorizationTarget { - agent_runtime_id: "agent-456", - task_id: "task-123", - }; - - let error = authorization_header_for_agent_task(key, target) - .expect_err("runtime mismatch should fail"); - - assert_eq!( - error.to_string(), - "agent task runtime agent-456 does not match stored agent identity agent-123" - ); - } - #[test] fn decode_agent_identity_jwt_reads_claims() { let jwt = jwt_with_payload(serde_json::json!({ @@ -704,7 +853,98 @@ J1bwkqKZTB5dHolX9A58e/xXnfZ5P8f3Z83+Izap3FwqQulk7b1WO1MQcHuVg2NN } #[test] - fn agent_identity_jwks_url_uses_backend_api_base_url() { + fn chatgpt_environment_maps_known_urls_to_authapi() -> anyhow::Result<()> { + assert_eq!( + ChatGptEnvironment::from_chatgpt_base_url("https://chatgpt.com/backend-api/codex")?, + ChatGptEnvironment::Production + ); + assert_eq!( + ChatGptEnvironment::Production.agent_identity_authapi_base_url(), + "https://auth.openai.com/api/accounts" + ); + assert_eq!( + ChatGptEnvironment::from_chatgpt_base_url("https://chatgpt-staging.com/backend-api")?, + ChatGptEnvironment::Staging + ); + assert_eq!( + ChatGptEnvironment::Staging.agent_identity_authapi_base_url(), + "https://auth.api.openai.org/api/accounts" + ); + Ok(()) + } + + #[test] + fn chatgpt_environment_rejects_custom_urls() { + assert!(ChatGptEnvironment::from_chatgpt_base_url("http://localhost:8080").is_err(),); + } + + #[test] + fn agent_registration_url_appends_to_authapi_base_url() { + assert_eq!( + agent_registration_url("https://auth.openai.com/api/accounts"), + "https://auth.openai.com/api/accounts/v1/agent/register" + ); + assert_eq!( + agent_registration_url("http://localhost:8080"), + "http://localhost:8080/v1/agent/register" + ); + assert_eq!( + agent_registration_url("http://localhost:8080/backend-api"), + "http://localhost:8080/backend-api/v1/agent/register" + ); + } + + #[test] + fn agent_task_registration_url_appends_to_authapi_base_url() { + assert_eq!( + agent_task_registration_url("https://auth.openai.com/api/accounts", "agent-runtime-id"), + "https://auth.openai.com/api/accounts/v1/agent/agent-runtime-id/task/register" + ); + assert_eq!( + agent_task_registration_url( + "https://auth.openai.com/api/accounts/", + "agent-runtime-id" + ), + "https://auth.openai.com/api/accounts/v1/agent/agent-runtime-id/task/register" + ); + assert_eq!( + agent_task_registration_url("http://localhost:8080", "agent-runtime-id"), + "http://localhost:8080/v1/agent/agent-runtime-id/task/register" + ); + } + + #[test] + fn retryable_registration_error_accepts_429_and_5xx() { + let too_many_requests = anyhow::Error::new(AgentIdentityRegistrationHttpError::new( + "agent registration", + reqwest::StatusCode::TOO_MANY_REQUESTS, + "rate limited".to_string(), + )); + let unavailable = anyhow::Error::new(AgentIdentityRegistrationHttpError::new( + "agent registration", + reqwest::StatusCode::SERVICE_UNAVAILABLE, + "try later".to_string(), + )); + + assert!(is_retryable_registration_error(&too_many_requests)); + assert!(is_retryable_registration_error(&unavailable)); + } + + #[test] + fn retryable_registration_error_rejects_hard_failures() { + let forbidden = anyhow::Error::new(AgentIdentityRegistrationHttpError::new( + "agent registration", + reqwest::StatusCode::FORBIDDEN, + "not allowed".to_string(), + )); + let malformed = anyhow::anyhow!("failed to sign registration request"); + + assert!(!is_retryable_registration_error(&forbidden)); + assert!(!is_retryable_registration_error(&malformed)); + } + + #[test] + fn agent_identity_jwks_url_uses_agent_identity_jwt_route() { assert_eq!( agent_identity_jwks_url("https://chatgpt.com/backend-api"), "https://chatgpt.com/backend-api/wham/agent-identities/jwks" @@ -716,7 +956,7 @@ J1bwkqKZTB5dHolX9A58e/xXnfZ5P8f3Z83+Izap3FwqQulk7b1WO1MQcHuVg2NN } #[test] - fn agent_identity_jwks_url_uses_codex_api_base_url() { + fn agent_identity_jwks_url_uses_jwt_issuer_base_url() { assert_eq!( agent_identity_jwks_url("http://localhost:8080/api/codex"), "http://localhost:8080/api/codex/agent-identities/jwks" diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 1d06ee7872..f6c5fbfb98 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1731,9 +1731,12 @@ async fn load_exec_server_remote_auth_provider( let agent_identity_jwt = read_codex_access_token_from_env().ok_or_else(|| { anyhow::anyhow!("CODEX_ACCESS_TOKEN is required when --use-agent-identity-auth is set") })?; - let auth = - CodexAuth::from_agent_identity_jwt(&agent_identity_jwt, Some(&config.chatgpt_base_url)) - .await?; + let auth = CodexAuth::from_agent_identity_jwt( + &agent_identity_jwt, + Some(&config.chatgpt_base_url), + /*agent_identity_authapi_base_url_override*/ None, + ) + .await?; return Ok(codex_model_provider::auth_provider_from_auth(&auth)); } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 482785c5cf..05bd5cc1df 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -475,6 +475,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result forced_login_method: config.forced_login_method, forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), chatgpt_base_url: Some(config.chatgpt_base_url.clone()), + agent_identity_authapi_base_url: None, }) .await { diff --git a/codex-rs/login/src/auth/agent_identity.rs b/codex-rs/login/src/auth/agent_identity.rs index 3644713328..d4c4f3e513 100644 --- a/codex-rs/login/src/auth/agent_identity.rs +++ b/codex-rs/login/src/auth/agent_identity.rs @@ -1,75 +1,84 @@ +use std::sync::Arc; + use codex_agent_identity::AgentIdentityKey; use codex_agent_identity::register_agent_task; use codex_protocol::account::PlanType as AccountPlanType; -use std::env; use crate::default_client::build_reqwest_client; use super::storage::AgentIdentityAuthRecord; -const PROD_AGENT_IDENTITY_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts"; -const CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL_ENV_VAR: &str = "CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL"; - #[derive(Clone, Debug)] pub struct AgentIdentityAuth { + inner: Arc, +} + +#[derive(Debug)] +struct AgentIdentityAuthInner { record: AgentIdentityAuthRecord, - process_task_id: String, + run_task_id: String, } impl AgentIdentityAuth { - pub async fn load(record: AgentIdentityAuthRecord) -> std::io::Result { - let agent_identity_authapi_base_url = agent_identity_authapi_base_url(); - let process_task_id = register_agent_task( + pub async fn load( + record: AgentIdentityAuthRecord, + agent_identity_authapi_base_url: &str, + ) -> std::io::Result { + let run_task_id = register_agent_task( &build_reqwest_client(), - &agent_identity_authapi_base_url, - key(&record), + agent_identity_authapi_base_url, + key_for_record(&record), ) .await .map_err(std::io::Error::other)?; Ok(Self { - record, - process_task_id, + inner: Arc::new(AgentIdentityAuthInner { + record, + run_task_id, + }), }) } - pub fn record(&self) -> &AgentIdentityAuthRecord { - &self.record + #[cfg(test)] + fn from_initialized_record(record: AgentIdentityAuthRecord, run_task_id: String) -> Self { + Self { + inner: Arc::new(AgentIdentityAuthInner { + record, + run_task_id, + }), + } } - pub fn process_task_id(&self) -> &str { - &self.process_task_id + pub fn record(&self) -> &AgentIdentityAuthRecord { + &self.inner.record + } + + pub fn run_task_id(&self) -> &str { + &self.inner.run_task_id } pub fn account_id(&self) -> &str { - &self.record.account_id + &self.inner.record.account_id } pub fn chatgpt_user_id(&self) -> &str { - &self.record.chatgpt_user_id + &self.inner.record.chatgpt_user_id } pub fn email(&self) -> &str { - &self.record.email + &self.inner.record.email } pub fn plan_type(&self) -> AccountPlanType { - self.record.plan_type + self.inner.record.plan_type } pub fn is_fedramp_account(&self) -> bool { - self.record.chatgpt_account_is_fedramp + self.inner.record.chatgpt_account_is_fedramp } } -fn agent_identity_authapi_base_url() -> String { - env::var(CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL_ENV_VAR) - .ok() - .map(|base_url| base_url.trim().trim_end_matches('/').to_string()) - .filter(|base_url| !base_url.is_empty()) - .unwrap_or_else(|| PROD_AGENT_IDENTITY_AUTHAPI_BASE_URL.to_string()) -} - -fn key(record: &AgentIdentityAuthRecord) -> AgentIdentityKey<'_> { +fn key_for_record(record: &AgentIdentityAuthRecord) -> AgentIdentityKey<'_> { AgentIdentityKey { agent_runtime_id: &record.agent_runtime_id, private_key_pkcs8_base64: &record.agent_private_key, @@ -78,63 +87,110 @@ fn key(record: &AgentIdentityAuthRecord) -> AgentIdentityKey<'_> { #[cfg(test)] mod tests { + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + use codex_agent_identity::generate_agent_key_material; + use pretty_assertions::assert_eq; + use serde_json::json; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + use super::*; - use serial_test::serial; - #[test] - #[serial(codex_auth_env)] - fn agent_identity_authapi_base_url_prefers_env_value() { - let _guard = EnvVarGuard::set( - CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL_ENV_VAR, - "https://authapi.example.test/api/accounts/", - ); - assert_eq!( - agent_identity_authapi_base_url(), - "https://authapi.example.test/api/accounts" - ); - } - - #[test] - #[serial(codex_auth_env)] - fn agent_identity_authapi_base_url_uses_prod_authapi_by_default() { - let _guard = EnvVarGuard::remove(CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL_ENV_VAR); - assert_eq!( - agent_identity_authapi_base_url(), - PROD_AGENT_IDENTITY_AUTHAPI_BASE_URL - ); - } - - struct EnvVarGuard { - key: &'static str, - original: Option, - } - - impl EnvVarGuard { - fn set(key: &'static str, value: &str) -> Self { - let original = env::var_os(key); - unsafe { - env::set_var(key, value); - } - Self { key, original } - } - - fn remove(key: &'static str) -> Self { - let original = env::var_os(key); - unsafe { - env::remove_var(key); - } - Self { key, original } + fn agent_identity_record(private_key: String) -> AgentIdentityAuthRecord { + AgentIdentityAuthRecord { + agent_runtime_id: "agent-runtime-1".to_string(), + agent_private_key: private_key, + account_id: "account-1".to_string(), + chatgpt_user_id: "user-1".to_string(), + email: "agent@example.com".to_string(), + plan_type: AccountPlanType::Plus, + chatgpt_account_is_fedramp: false, } } - impl Drop for EnvVarGuard { - fn drop(&mut self) { - unsafe { - match &self.original { - Some(value) => env::set_var(self.key, value), - None => env::remove_var(self.key), + fn agent_identity_record_with_generated_key() -> AgentIdentityAuthRecord { + let key_material = generate_agent_key_material().expect("generate key material"); + agent_identity_record(key_material.private_key_pkcs8_base64) + } + + #[tokio::test] + async fn load_registers_run_task() -> anyhow::Result<()> { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/agent/agent-runtime-1/task/register")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "task_id": "task-run-1", + }))) + .expect(1) + .mount(&server) + .await; + + let auth = + AgentIdentityAuth::load(agent_identity_record_with_generated_key(), &server.uri()) + .await?; + + assert_eq!(auth.run_task_id(), "task-run-1"); + let requests = server + .received_requests() + .await + .expect("failed to fetch task registration request"); + let request_body = requests[0] + .body_json::() + .expect("task registration request should be JSON"); + let request_body = request_body + .as_object() + .expect("request body should be object"); + assert!(request_body.get("timestamp").is_some()); + assert!(request_body.get("signature").is_some()); + assert_eq!(request_body.len(), 2); + Ok(()) + } + + #[test] + fn run_task_is_shared_across_clones() { + let auth = AgentIdentityAuth::from_initialized_record( + agent_identity_record_with_generated_key(), + "task-run-1".to_string(), + ); + let cloned = auth.clone(); + + assert!(Arc::ptr_eq(&auth.inner, &cloned.inner)); + assert_eq!(cloned.run_task_id(), "task-run-1"); + } + + #[tokio::test] + async fn failed_run_task_registration_can_be_retried_on_next_call() -> anyhow::Result<()> { + let server = MockServer::start().await; + let request_count = Arc::new(AtomicUsize::new(0)); + let response_count = Arc::clone(&request_count); + Mock::given(method("POST")) + .and(path("/v1/agent/agent-runtime-1/task/register")) + .respond_with(move |_request: &wiremock::Request| { + if response_count.fetch_add(1, Ordering::SeqCst) == 0 { + ResponseTemplate::new(500) + } else { + ResponseTemplate::new(200).set_body_json(json!({ + "task_id": "task-run-1", + })) } - } - } + }) + .expect(2) + .mount(&server) + .await; + let record = agent_identity_record_with_generated_key(); + AgentIdentityAuth::load(record.clone(), &server.uri()) + .await + .expect_err("first registration should fail"); + let auth = AgentIdentityAuth::load(record, &server.uri()).await?; + + assert_eq!(request_count.load(Ordering::SeqCst), 2); + assert_eq!(auth.run_task_id(), "task-run-1"); + Ok(()) } } diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 465dd62175..aa1dcd9c86 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -348,6 +348,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, ) .await .unwrap() @@ -408,6 +409,7 @@ async fn loads_api_key_from_auth_json() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, ) .await .unwrap() @@ -500,6 +502,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, ) .await .expect("load auth") @@ -519,6 +522,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, ) .await .expect("updated auth should parse"); @@ -824,6 +828,7 @@ async fn build_config( forced_login_method, forced_chatgpt_workspace_id, chatgpt_base_url: None, + agent_identity_authapi_base_url: None, } } @@ -886,7 +891,7 @@ async fn load_auth_reads_access_token_from_env() { .mount(&server) .await; Mock::given(method("POST")) - .and(path("/backend-api/v1/agent/agent-runtime-id/task/register")) + .and(path("/v1/agent/agent-runtime-id/task/register")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "task_id": "task-123", }))) @@ -895,9 +900,8 @@ async fn load_auth_reads_access_token_from_env() { .await; let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, &agent_identity); - let chatgpt_base_url = format!("{}/backend-api", server.uri()); - let _authapi_guard = - EnvVarGuard::set("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &chatgpt_base_url); + let authapi_base_url = server.uri(); + let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); let auth = super::load_auth( codex_home.path(), /*enable_codex_api_key_env*/ false, @@ -905,6 +909,7 @@ async fn load_auth_reads_access_token_from_env() { /*forced_chatgpt_workspace_id*/ None, Some(&chatgpt_base_url), AuthKeyringBackendKind::Direct, + Some(&authapi_base_url), ) .await .expect("env auth should load") @@ -914,7 +919,7 @@ async fn load_auth_reads_access_token_from_env() { panic!("env auth should load as agent identity"); }; assert_eq!(agent_identity.record(), &expected_record); - assert_eq!(agent_identity.process_task_id(), "task-123"); + assert_eq!(agent_identity.run_task_id(), "task-123"); assert!( !get_auth_file(codex_home.path()).exists(), "env auth should not write auth.json" @@ -951,6 +956,7 @@ async fn load_auth_reads_personal_access_token_from_env() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*agent_identity_authapi_base_url*/ None, ) .await .expect("env auth should load") @@ -1119,6 +1125,7 @@ async fn load_auth_keeps_codex_api_key_env_precedence() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, ) .await .expect("env auth should load") @@ -1226,6 +1233,7 @@ async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace forced_login_method: None, forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), chatgpt_base_url: None, + agent_identity_authapi_base_url: None, }; let err = super::enforce_login_restrictions(&config) @@ -1317,16 +1325,15 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat .mount(&server) .await; Mock::given(method("POST")) - .and(path("/backend-api/v1/agent/agent-runtime-id/task/register")) + .and(path("/v1/agent/agent-runtime-id/task/register")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "task_id": "task-123", }))) .expect(1) .mount(&server) .await; - let chatgpt_base_url = format!("{}/backend-api", server.uri()); - let _authapi_guard = - EnvVarGuard::set("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &chatgpt_base_url); + let authapi_base_url = server.uri(); + let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); save_auth( codex_home.path(), &AuthDotJson { @@ -1350,6 +1357,7 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat forced_login_method: None, forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), chatgpt_base_url: Some(chatgpt_base_url), + agent_identity_authapi_base_url: Some(authapi_base_url), }; let err = super::enforce_login_restrictions(&config) @@ -1563,19 +1571,19 @@ async fn assert_agent_identity_plan_alias( .mount(&server) .await; Mock::given(method("POST")) - .and(path("/backend-api/v1/agent/agent-runtime-id/task/register")) + .and(path("/v1/agent/agent-runtime-id/task/register")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "task_id": "task-123", }))) .expect(1) .mount(&server) .await; - let chatgpt_base_url = format!("{}/backend-api", server.uri()); - let _authapi_guard = - EnvVarGuard::set("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &chatgpt_base_url); - let auth = CodexAuth::from_agent_identity_jwt(&jwt, Some(&chatgpt_base_url)) - .await - .expect("agent identity auth"); + let authapi_base_url = server.uri(); + let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); + let auth = + CodexAuth::from_agent_identity_jwt(&jwt, Some(&chatgpt_base_url), Some(&authapi_base_url)) + .await + .expect("agent identity auth"); pretty_assertions::assert_eq!(auth.account_plan_type(), Some(expected_plan_type)); server.verify().await; @@ -1603,6 +1611,7 @@ async fn plan_type_maps_known_plan() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, ) .await .expect("load auth") @@ -1633,6 +1642,7 @@ async fn plan_type_maps_self_serve_business_usage_based_plan() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, ) .await .expect("load auth") @@ -1666,6 +1676,7 @@ async fn plan_type_maps_enterprise_cbp_usage_based_plan() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, ) .await .expect("load auth") @@ -1699,6 +1710,7 @@ async fn plan_type_maps_unknown_to_unknown() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, ) .await .expect("load auth") @@ -1729,6 +1741,7 @@ async fn missing_plan_type_maps_to_unknown() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, ) .await .expect("load auth") diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index b7d61153fd..f4ed2a9369 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -18,6 +18,7 @@ use std::sync::atomic::Ordering; use tokio::sync::Semaphore; use tokio::sync::watch; +use codex_agent_identity::ChatGptEnvironment; use codex_agent_identity::decode_agent_identity_jwt; use codex_agent_identity::fetch_agent_identity_jwks; use codex_app_server_protocol::AuthMode; @@ -104,7 +105,6 @@ const REFRESH_TOKEN_INVALIDATED_MESSAGE: &str = "Your access token could not be const REFRESH_TOKEN_UNKNOWN_MESSAGE: &str = "Your access token could not be refreshed. Please log out and sign in again."; const REFRESH_TOKEN_ACCOUNT_MISMATCH_MESSAGE: &str = "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again."; -const DEFAULT_CHATGPT_BACKEND_BASE_URL: &str = "https://chatgpt.com/backend-api"; const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; pub(super) const REVOKE_TOKEN_URL: &str = "https://auth.openai.com/oauth/revoke"; pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE"; @@ -218,6 +218,7 @@ impl CodexAuth { auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, + agent_identity_authapi_base_url: Option<&str>, ) -> std::io::Result { let auth_mode = auth_dot_json.resolved_mode(); let client = create_client(); @@ -233,7 +234,12 @@ impl CodexAuth { "agent identity auth is missing an agent identity token.", )); }; - return Self::from_agent_identity_jwt(&agent_identity, chatgpt_base_url).await; + return Self::from_agent_identity_jwt( + &agent_identity, + chatgpt_base_url, + agent_identity_authapi_base_url, + ) + .await; } if auth_mode == ApiAuthMode::PersonalAccessToken { let Some(personal_access_token) = auth_dot_json.personal_access_token.as_deref() else { @@ -292,6 +298,7 @@ impl CodexAuth { /*forced_chatgpt_workspace_id*/ None, chatgpt_base_url, keyring_backend_kind, + /*agent_identity_authapi_base_url*/ None, ) .await } @@ -299,13 +306,19 @@ impl CodexAuth { pub async fn from_agent_identity_jwt( jwt: &str, chatgpt_base_url: Option<&str>, + agent_identity_authapi_base_url_override: Option<&str>, ) -> std::io::Result { let base_url = chatgpt_base_url - .unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL) + .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url()) .trim_end_matches('/') .to_string(); + let resolved_authapi_base_url = resolve_agent_identity_authapi_base_url( + Some(&base_url), + agent_identity_authapi_base_url_override, + )?; let record = verified_agent_identity_record(jwt, &base_url).await?; - Ok(Self::AgentIdentity(AgentIdentityAuth::load(record).await?)) + let auth = AgentIdentityAuth::load(record, &resolved_authapi_base_url).await?; + Ok(Self::AgentIdentity(auth)) } pub async fn from_personal_access_token(access_token: &str) -> std::io::Result { @@ -580,6 +593,22 @@ async fn verified_agent_identity_record( Ok(claims.into()) } +fn resolve_agent_identity_authapi_base_url( + chatgpt_base_url: Option<&str>, + agent_identity_authapi_base_url_override: Option<&str>, +) -> std::io::Result { + if let Some(base_url) = agent_identity_authapi_base_url_override { + return Ok(base_url.trim_end_matches('/').to_string()); + } + + let environment = match chatgpt_base_url { + Some(chatgpt_base_url) => ChatGptEnvironment::from_chatgpt_base_url(chatgpt_base_url) + .map_err(std::io::Error::other)?, + None => ChatGptEnvironment::default(), + }; + Ok(environment.agent_identity_authapi_base_url().to_string()) +} + /// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)` /// if a file was removed, `Ok(false)` if no auth file was present. pub fn logout( @@ -672,7 +701,7 @@ pub async fn login_with_access_token( } CodexAccessToken::AgentIdentityJwt(jwt) => { let base_url = chatgpt_base_url - .unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL) + .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url()) .trim_end_matches('/') .to_string(); verified_agent_identity_record(jwt, &base_url).await?; @@ -763,6 +792,7 @@ pub struct AuthConfig { pub keyring_backend_kind: AuthKeyringBackendKind, pub forced_login_method: Option, pub chatgpt_base_url: Option, + pub agent_identity_authapi_base_url: Option, pub forced_chatgpt_workspace_id: Option>, } @@ -774,6 +804,7 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< /*forced_chatgpt_workspace_id*/ None, config.chatgpt_base_url.as_deref(), config.keyring_backend_kind, + config.agent_identity_authapi_base_url.as_deref(), ) .await? else { @@ -916,6 +947,7 @@ async fn load_auth( forced_chatgpt_workspace_id: Option<&[String]>, chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, + agent_identity_authapi_base_url: Option<&str>, ) -> std::io::Result> { // API key via env var takes precedence over any other auth method. if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() { @@ -936,6 +968,7 @@ async fn load_auth( AuthCredentialsStoreMode::Ephemeral, chatgpt_base_url, keyring_backend_kind, + agent_identity_authapi_base_url, ) .await?; if let CodexAuth::PersonalAccessToken(auth) = &auth { @@ -951,11 +984,13 @@ async fn load_auth( ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?; Ok(Some(CodexAuth::PersonalAccessToken(auth))) } - CodexAccessToken::AgentIdentityJwt(jwt) => { - CodexAuth::from_agent_identity_jwt(jwt, chatgpt_base_url) - .await - .map(Some) - } + CodexAccessToken::AgentIdentityJwt(jwt) => CodexAuth::from_agent_identity_jwt( + jwt, + chatgpt_base_url, + agent_identity_authapi_base_url, + ) + .await + .map(Some), }; } @@ -981,6 +1016,7 @@ async fn load_auth( auth_credentials_store_mode, chatgpt_base_url, keyring_backend_kind, + agent_identity_authapi_base_url, ) .await?; if let CodexAuth::PersonalAccessToken(auth) = &auth { @@ -1573,6 +1609,7 @@ impl AuthManager { forced_chatgpt_workspace_id.as_deref(), chatgpt_base_url.as_deref(), keyring_backend_kind, + /*agent_identity_authapi_base_url*/ None, ) .await .ok() @@ -1799,6 +1836,7 @@ impl AuthManager { forced_chatgpt_workspace_id.as_deref(), self.chatgpt_base_url.as_deref(), self.keyring_backend_kind, + /*agent_identity_authapi_base_url*/ None, ) .await .ok() diff --git a/codex-rs/model-provider/src/auth.rs b/codex-rs/model-provider/src/auth.rs index 8f5e02b175..5553120274 100644 --- a/codex-rs/model-provider/src/auth.rs +++ b/codex-rs/model-provider/src/auth.rs @@ -1,7 +1,6 @@ use std::sync::Arc; use codex_agent_identity::AgentIdentityKey; -use codex_agent_identity::AgentTaskAuthorizationTarget; use codex_agent_identity::authorization_header_for_agent_task; use codex_api::AuthProvider; use codex_api::SharedAuthProvider; @@ -30,10 +29,7 @@ impl AuthProvider for AgentIdentityAuthProvider { agent_runtime_id: &record.agent_runtime_id, private_key_pkcs8_base64: &record.agent_private_key, }, - AgentTaskAuthorizationTarget { - agent_runtime_id: &record.agent_runtime_id, - task_id: self.auth.process_task_id(), - }, + self.auth.run_task_id(), ) .map_err(std::io::Error::other); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 002e647263..b5540f878b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1161,6 +1161,7 @@ pub async fn run_main( forced_login_method: config.forced_login_method, forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), chatgpt_base_url: Some(config.chatgpt_base_url.clone()), + agent_identity_authapi_base_url: None, }) .await { From 7dc7096ae116df18c917d5d2f7c75243d98a0419 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:52:18 -0700 Subject: [PATCH 36/70] [codex] Restore thread recency with compatible migration history (#28671) ## Summary - Revert #28655, restoring the thread `recencyAt` behavior introduced by #27910. - Move `threads_recency_at` to migration 0039 so it no longer collides with `external_agent_config_imports` at version 0038. - Repair databases that already applied the recency migration as version 38 by moving the matching migration-history row to version 39 before SQLx validation. The current version-38 migration can then apply normally. ## Validation - `just test -p codex-state migrations::tests::repairs_recency_migration_that_was_applied_as_version_38` - `just test -p codex-state -p codex-rollout -p codex-thread-store -p codex-app-server-protocol -p codex-tui`: 3,439 passed; six TUI tests could not open the machine's existing read-only incident database at `~/.codex/sqlite/state_5.sqlite`. - `just fix -p codex-state` - `just fmt` - Verified that state migration versions are unique. --- .../analytics/src/analytics_client_tests.rs | 1 + codex-rs/analytics/src/client_tests.rs | 1 + .../schema/json/ClientRequest.json | 3 +- .../schema/json/ServerNotification.json | 8 + .../codex_app_server_protocol.schemas.json | 11 +- .../codex_app_server_protocol.v2.schemas.json | 11 +- .../schema/json/v2/ThreadForkResponse.json | 8 + .../schema/json/v2/ThreadListParams.json | 3 +- .../schema/json/v2/ThreadListResponse.json | 8 + .../json/v2/ThreadMetadataUpdateResponse.json | 8 + .../schema/json/v2/ThreadReadResponse.json | 8 + .../schema/json/v2/ThreadResumeResponse.json | 8 + .../json/v2/ThreadRollbackResponse.json | 8 + .../schema/json/v2/ThreadStartResponse.json | 8 + .../json/v2/ThreadStartedNotification.json | 8 + .../json/v2/ThreadUnarchiveResponse.json | 8 + .../schema/typescript/v2/Thread.ts | 4 + .../schema/typescript/v2/ThreadSortKey.ts | 2 +- .../src/protocol/common.rs | 2 + .../src/protocol/v2/tests.rs | 10 + .../src/protocol/v2/thread.rs | 1 + .../src/protocol/v2/thread_data.rs | 3 + codex-rs/app-server/README.md | 7 +- .../app-server/src/bespoke_event_handling.rs | 1 + .../request_processors/thread_processor.rs | 5 + .../thread_processor_tests.rs | 1 + .../thread_resume_redaction.rs | 1 + .../src/request_processors/thread_summary.rs | 1 + codex-rs/app-server/src/thread_status.rs | 1 + .../app-server/tests/suite/v2/thread_list.rs | 83 +++++ .../tests/suite/v2/thread_resume.rs | 26 +- codex-rs/core/src/realtime_context_tests.rs | 4 + codex-rs/exec/src/lib_tests.rs | 2 + codex-rs/rollout/src/list.rs | 71 ++-- codex-rs/rollout/src/metadata.rs | 1 + codex-rs/rollout/src/metadata_tests.rs | 1 + codex-rs/rollout/src/recorder.rs | 67 +++- codex-rs/rollout/src/recorder_tests.rs | 3 + codex-rs/rollout/src/state_db.rs | 7 +- codex-rs/rollout/src/state_db_tests.rs | 16 + codex-rs/rollout/src/tests.rs | 11 + .../migrations/0039_threads_recency_at.sql | 28 ++ codex-rs/state/src/extract.rs | 1 + codex-rs/state/src/migrations.rs | 47 +++ codex-rs/state/src/migrations_tests.rs | 198 ++++++++++ codex-rs/state/src/model/thread_metadata.rs | 26 +- codex-rs/state/src/runtime.rs | 53 ++- codex-rs/state/src/runtime/memories.rs | 2 + codex-rs/state/src/runtime/test_support.rs | 1 + codex-rs/state/src/runtime/threads.rs | 345 ++++++++++++++++-- codex-rs/thread-store/src/in_memory.rs | 3 + .../thread-store/src/local/archive_thread.rs | 1 + codex-rs/thread-store/src/local/helpers.rs | 2 + .../thread-store/src/local/list_threads.rs | 1 + codex-rs/thread-store/src/local/mod.rs | 92 +++++ .../thread-store/src/local/read_thread.rs | 9 + .../thread-store/src/local/search_threads.rs | 16 +- .../src/local/search_threads_tests.rs | 29 ++ .../src/local/unarchive_thread.rs | 1 + .../src/local/update_thread_metadata.rs | 18 + .../thread-store/src/thread_metadata_sync.rs | 31 +- codex-rs/thread-store/src/types.rs | 10 + codex-rs/tui/src/app/loaded_threads.rs | 1 + codex-rs/tui/src/app/tests.rs | 4 + codex-rs/tui/src/app/thread_session_state.rs | 1 + codex-rs/tui/src/app_server_session.rs | 1 + codex-rs/tui/src/resume_picker.rs | 12 +- 67 files changed, 1274 insertions(+), 100 deletions(-) create mode 100644 codex-rs/state/migrations/0039_threads_recency_at.sql create mode 100644 codex-rs/state/src/migrations_tests.rs create mode 100644 codex-rs/thread-store/src/local/search_threads_tests.rs diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 4aae796562..d7be7f1df9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -178,6 +178,7 @@ fn sample_thread_with_metadata( model_provider: "openai".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: AppServerThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 1835932e8e..18bb8c2159 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -285,6 +285,7 @@ fn sample_thread(thread_id: &str) -> Thread { model_provider: "openai".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: AppServerThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index cb1d594ff6..4f04698e37 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -4164,7 +4164,8 @@ "ThreadSortKey": { "enum": [ "created_at", - "updated_at" + "updated_at", + "recency_at" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index d64befa0a0..a0d49cc548 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3582,6 +3582,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 6251f7dc7a..5cbf96f24c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -16645,6 +16645,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -18904,7 +18912,8 @@ "ThreadSortKey": { "enum": [ "created_at", - "updated_at" + "updated_at", + "recency_at" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 9a121fa87b..e5a922ea78 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -14445,6 +14445,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -16704,7 +16712,8 @@ "ThreadSortKey": { "enum": [ "created_at", - "updated_at" + "updated_at", + "recency_at" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 4a666672b6..2b7caeca7e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1055,6 +1055,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json index 789d9b61fc..11f7da47d3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json @@ -24,7 +24,8 @@ "ThreadSortKey": { "enum": [ "created_at", - "updated_at" + "updated_at", + "recency_at" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index c1d1b6759a..0caa0daf5d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -870,6 +870,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 801d1cf131..a98d3586ec 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -870,6 +870,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 01b5ed907e..51e56f14b8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -870,6 +870,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 032d1dd74e..e8c309507e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1055,6 +1055,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index c1ad534756..52cba55edb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -870,6 +870,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 33373b9f72..245cc946d4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1055,6 +1055,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 577df57e05..bf497ecfa2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -870,6 +870,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index fcc42e6261..14f25cfcdc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -870,6 +870,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 5fa30b64e3..1c288ccbd4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -41,6 +41,10 @@ createdAt: number, * Unix timestamp (in seconds) when the thread was last updated. */ updatedAt: number, +/** + * Unix timestamp (in seconds) used for thread recency ordering. + */ +recencyAt: number | null, /** * Current runtime status for the thread. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts index dbf1b6c40f..d93f1c47bf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ThreadSortKey = "created_at" | "updated_at"; +export type ThreadSortKey = "created_at" | "updated_at" | "recency_at"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 801e9b46b8..f22cab25f1 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2501,6 +2501,7 @@ mod tests { model_provider: "openai".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(3), status: v2::ThreadStatus::Idle, path: None, cwd: cwd.clone(), @@ -2544,6 +2545,7 @@ mod tests { "modelProvider": "openai", "createdAt": 1, "updatedAt": 2, + "recencyAt": 3, "status": { "type": "idle" }, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index de138d5c2e..39bc52717a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -174,6 +174,7 @@ fn thread_resume_response_round_trips_initial_turns_page() { model_provider: "openai".to_string(), created_at: 1, updated_at: 1, + recency_at: Some(1), status: ThreadStatus::Idle, path: None, cwd: absolute_path("tmp"), @@ -3600,6 +3601,7 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { assert_eq!(start.instruction_sources, Vec::::new()); assert_eq!(start.thread.parent_thread_id, None); + assert_eq!(start.thread.recency_at, None); assert_eq!(resume.instruction_sources, Vec::::new()); assert_eq!(fork.instruction_sources, Vec::::new()); assert_eq!(start.active_permission_profile, None); @@ -3608,6 +3610,14 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { assert_eq!(fork.active_permission_profile, None); } +#[test] +fn thread_recency_sort_key_serializes_as_snake_case() { + assert_eq!( + serde_json::to_value(ThreadSortKey::RecencyAt).expect("sort key should serialize"), + json!("recency_at") + ); +} + #[test] fn turn_start_params_preserve_explicit_null_service_tier() { let params: TurnStartParams = serde_json::from_value(json!({ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 0252c4b048..b5809f09b8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -1086,6 +1086,7 @@ pub enum ThreadSourceKind { pub enum ThreadSortKey { CreatedAt, UpdatedAt, + RecencyAt, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs index fc0762361f..52a8d7017a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -152,6 +152,9 @@ pub struct Thread { /// Unix timestamp (in seconds) when the thread was last updated. #[ts(type = "number")] pub updated_at: i64, + /// Unix timestamp (in seconds) used for thread recency ordering. + #[ts(type = "number | null")] + pub recency_at: Option, /// Current runtime status for the thread. pub status: ThreadStatus, /// [UNSTABLE] Path to the thread on disk. diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 62c40555e6..ab001cc682 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -364,7 +364,8 @@ Like `thread/resume`, experimental clients can pass `excludeTurns: true` to `thr - `cursor` — opaque string from a prior response; omit for the first page. - `limit` — server defaults to a reasonable page size if unset. -- `sortKey` — `created_at` (default) or `updated_at`. +- `sortKey` — `created_at` (default), `updated_at`, or `recency_at`. +- `recencyAt` is initialized when the thread is created and advances when a turn starts. Unlike `updatedAt`, background output and other persisted mutations do not advance it. - `sortDirection` — `desc` (default) or `asc`. - `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers. - `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`). @@ -386,8 +387,8 @@ Example: } } { "id": 20, "result": { "data": [ - { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "status": { "type": "notLoaded" }, "agentNickname": "Atlas", "agentRole": "explorer" }, - { "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000, "status": { "type": "notLoaded" } } + { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "recencyAt": 1730831111, "status": { "type": "notLoaded" }, "agentNickname": "Atlas", "agentRole": "explorer" }, + { "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000, "recencyAt": 1730750000, "status": { "type": "notLoaded" } } ], "nextCursor": "opaque-token-or-null", "backwardsCursor": "opaque-token-or-null" diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index a78876a343..ed135271a9 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2261,6 +2261,7 @@ mod tests { reasoning_effort: None, created_at, updated_at: created_at, + recency_at: created_at, archived_at: None, cwd: test_path_buf("/tmp").abs().into(), cli_version: "0.0.0".to_string(), diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index a5299f2c72..907b05a03b 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1897,6 +1897,7 @@ impl ThreadRequestProcessor { let store_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) { ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt, + ThreadSortKey::RecencyAt => StoreThreadSortKey::RecencyAt, }; let sort_direction = sort_direction.unwrap_or(SortDirection::Desc); let (stored_threads, next_cursor) = self @@ -1978,6 +1979,7 @@ impl ThreadRequestProcessor { let store_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) { ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt, + ThreadSortKey::RecencyAt => StoreThreadSortKey::RecencyAt, }; let store_sort_direction = sort_direction.unwrap_or(SortDirection::Desc); let (allowed_sources, source_kind_filter) = compute_source_filters(source_kinds); @@ -3695,6 +3697,7 @@ fn thread_backwards_cursor_for_sort_key( let timestamp = match sort_key { StoreThreadSortKey::CreatedAt => thread.created_at, StoreThreadSortKey::UpdatedAt => thread.updated_at, + StoreThreadSortKey::RecencyAt => thread.recency_at, }; // The state DB stores unique millisecond timestamps. Offset the reverse cursor by one // millisecond so the opposite-direction query includes the page anchor. @@ -4139,6 +4142,7 @@ pub(crate) fn thread_from_stored_thread( }, created_at: thread.created_at.timestamp(), updated_at: thread.updated_at.timestamp(), + recency_at: Some(thread.recency_at.timestamp()), status: ThreadStatus::NotLoaded, path, cwd, @@ -4344,6 +4348,7 @@ fn build_thread_from_snapshot( model_provider: config_snapshot.model_provider_id.clone(), created_at: now, updated_at: now, + recency_at: Some(now), status: ThreadStatus::NotLoaded, path, cwd: config_snapshot.cwd().clone(), diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 42711e8570..1f333f1eb1 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -478,6 +478,7 @@ mod thread_processor_behavior_tests { reasoning_effort: None, created_at: created_at.with_timezone(&Utc), updated_at: updated_at.with_timezone(&Utc), + recency_at: updated_at.with_timezone(&Utc), archived_at: None, cwd: PathBuf::from("/tmp"), cli_version: "0.0.0".to_string(), diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index e970a88a9f..110d6d5cbf 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -178,6 +178,7 @@ mod tests { model_provider: "mock_provider".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index 0fb1320055..67d2d9eabc 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -320,6 +320,7 @@ pub(crate) fn summary_to_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), + recency_at: updated_at.map(|dt| dt.timestamp()), status: ThreadStatus::NotLoaded, path: (!path.as_os_str().is_empty()).then_some(path), cwd, diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index 6d66a32d6a..a5997a58f5 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -897,6 +897,7 @@ mod tests { model_provider: "mock-provider".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: ThreadStatus::NotLoaded, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 3f6adc167e..9bdff3bb8b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -1689,6 +1689,89 @@ async fn thread_list_sort_updated_at_orders_by_mtime() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_list_sort_recency_at_uses_state_db_order_with_provider_filter() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let id_old = create_fake_rollout( + codex_home.path(), + "2025-01-01T10-00-00", + "2025-01-01T10:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + let id_new = create_fake_rollout( + codex_home.path(), + "2025-01-01T11-00-00", + "2025-01-01T11:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-01-01T10-00-00", &id_old).as_path(), + "2025-01-03T00:00:00Z", + )?; + + let state_db = + codex_state::StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()) + .await?; + state_db + .mark_backfill_complete(/*last_watermark*/ None) + .await?; + let rollout_config = codex_rollout::RolloutConfig { + codex_home: codex_home.path().to_path_buf(), + sqlite_home: codex_home.path().to_path_buf(), + cwd: codex_home.path().to_path_buf(), + model_provider_id: "mock_provider".to_string(), + generate_memories: false, + }; + codex_core::RolloutRecorder::list_threads( + Some(state_db.clone()), + &rollout_config, + /*page_size*/ 10, + /*cursor*/ None, + codex_core::ThreadSortKey::CreatedAt, + codex_core::SortDirection::Desc, + codex_core::INTERACTIVE_SESSION_SOURCES.as_slice(), + /*model_providers*/ None, + /*cwd_filters*/ None, + "mock_provider", + /*search_term*/ None, + ) + .await?; + state_db + .touch_thread_recency_at( + ThreadId::from_string(&id_new)?, + DateTime::::from_timestamp(1_800_000_000, 0).expect("timestamp"), + ) + .await?; + + let mut mcp = init_mcp(codex_home.path()).await?; + let ThreadListResponse { data, .. } = list_threads_with_sort( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["mock_provider".to_string()]), + /*source_kinds*/ None, + Some(ThreadSortKey::RecencyAt), + /*archived*/ None, + ) + .await?; + + assert_eq!( + data.iter() + .map(|thread| thread.id.as_str()) + .collect::>(), + vec![id_new.as_str(), id_old.as_str()] + ); + assert!(data.iter().all(|thread| thread.recency_at.is_some())); + + Ok(()) +} + #[tokio::test] async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> { let codex_home = TempDir::new()?; 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 e67184bf01..03e30e373e 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -2200,6 +2200,7 @@ async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; assert_eq!(thread.updated_at, before_resume.updated_at); + assert_eq!(thread.recency_at, before_resume.recency_at); assert_eq!(thread.status, ThreadStatus::Idle); let after_modified = std::fs::metadata(&rollout.rollout_file_path)?.modified()?; @@ -2234,7 +2235,7 @@ async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { - thread_id, + thread_id: thread_id.clone(), input: vec![UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -2247,6 +2248,29 @@ async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), ) .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread_id.clone(), + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { + thread: after_turn_start, + .. + } = to_response::(read_resp)?; + assert!(after_turn_start.recency_at > before_resume.recency_at); + timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index f04282a65f..352d6ae8e4 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -47,6 +47,10 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre .timestamp_opt(1_709_251_200, 0) .single() .expect("valid timestamp"), + recency_at: Utc + .timestamp_opt(1_709_251_200, 0) + .single() + .expect("valid timestamp"), archived_at: None, cwd: PathBuf::from(cwd), cli_version: "test".to_string(), diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 6b27d01116..526c5fc487 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -342,6 +342,7 @@ fn turn_items_for_thread_returns_matching_turn_items() { model_provider: "openai".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/project").abs(), @@ -760,6 +761,7 @@ fn sample_thread_start_response() -> ThreadStartResponse { model_provider: "openai".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: codex_app_server_protocol::ThreadStatus::Idle, path: Some(PathBuf::from("/tmp/rollout.jsonl")), cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/rollout/src/list.rs b/codex-rs/rollout/src/list.rs index 845145aaef..f5013e7aae 100644 --- a/codex-rs/rollout/src/list.rs +++ b/codex-rs/rollout/src/list.rs @@ -77,6 +77,8 @@ pub struct ThreadItem { pub created_at: Option, /// RFC3339 timestamp string for the most recent update (from file mtime). pub updated_at: Option, + /// RFC3339 timestamp string used for product recency ordering. + pub recency_at: Option, } #[allow(dead_code)] @@ -115,6 +117,7 @@ const USER_EVENT_SCAN_LIMIT: usize = 200; pub enum ThreadSortKey { CreatedAt, UpdatedAt, + RecencyAt, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -137,20 +140,29 @@ pub struct ThreadListConfig<'a> { pub layout: ThreadListLayout, } -/// Pagination cursor identifying the timestamp of the last item in a page. +/// Pagination cursor identifying the last item in a page. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Cursor { ts: OffsetDateTime, + id: Option, } impl Cursor { - fn new(ts: OffsetDateTime) -> Self { - Self { ts } + pub(crate) fn new(ts: OffsetDateTime) -> Self { + Self { ts, id: None } + } + + pub(crate) fn with_thread_id(ts: OffsetDateTime, id: ThreadId) -> Self { + Self { ts, id: Some(id) } } pub(crate) fn timestamp(&self) -> OffsetDateTime { self.ts } + + pub(crate) fn thread_id(&self) -> Option { + self.id + } } /// Keeps track of where a paginated listing left off. As the file scan goes newest -> oldest, @@ -287,7 +299,10 @@ impl serde::Serialize for Cursor { .ts .format(&Rfc3339) .map_err(|e| serde::ser::Error::custom(format!("format error: {e}")))?; - serializer.serialize_str(&ts_str) + match self.id { + Some(id) => serializer.serialize_str(&format!("{ts_str}|{id}")), + None => serializer.serialize_str(&ts_str), + } } } @@ -308,7 +323,7 @@ impl From for Cursor { .timestamp_nanos_opt() .and_then(|nanos| OffsetDateTime::from_unix_timestamp_nanos(nanos as i128).ok()) .unwrap_or(OffsetDateTime::UNIX_EPOCH); - Self::new(ts) + Self { ts, id: anchor.id } } } @@ -419,7 +434,7 @@ async fn traverse_directories_for_paths( ) .await } - ThreadSortKey::UpdatedAt => { + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => { traverse_directories_for_paths_updated( root, page_size, @@ -454,7 +469,7 @@ async fn traverse_flat_paths( ) .await } - ThreadSortKey::UpdatedAt => { + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => { traverse_flat_paths_updated( root, page_size, @@ -702,35 +717,48 @@ async fn traverse_flat_paths_updated( }) } -/// Pagination cursor token format: an RFC3339 timestamp. +/// Pagination cursor token format: an RFC3339 timestamp with an optional thread ID tie-breaker. pub fn parse_cursor(token: &str) -> Option { - if token.contains('|') { - return None; - } + let (timestamp, id) = match token.rsplit_once('|') { + Some((timestamp, id)) => (timestamp, Some(ThreadId::from_string(id).ok()?)), + None => (token, None), + }; - let ts = OffsetDateTime::parse(token, &Rfc3339).ok().or_else(|| { - let format: &[FormatItem] = - format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); - PrimitiveDateTime::parse(token, format) - .ok() - .map(PrimitiveDateTime::assume_utc) - })?; + let ts = OffsetDateTime::parse(timestamp, &Rfc3339) + .ok() + .or_else(|| { + let format: &[FormatItem] = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); + PrimitiveDateTime::parse(timestamp, format) + .ok() + .map(PrimitiveDateTime::assume_utc) + })?; - Some(Cursor::new(ts)) + Some(Cursor { ts, id }) } fn build_next_cursor(items: &[ThreadItem], sort_key: ThreadSortKey) -> Option { let last = items.last()?; let file_name = last.path.file_name()?.to_string_lossy(); - let (created_ts, _id) = parse_timestamp_uuid_from_filename(&file_name)?; + let (created_ts, id) = parse_timestamp_uuid_from_filename(&file_name)?; let ts = match sort_key { ThreadSortKey::CreatedAt => created_ts, ThreadSortKey::UpdatedAt => { let updated_at = last.updated_at.as_deref()?; OffsetDateTime::parse(updated_at, &Rfc3339).ok()? } + ThreadSortKey::RecencyAt => { + let recency_at = last.recency_at.as_deref().or(last.updated_at.as_deref())?; + OffsetDateTime::parse(recency_at, &Rfc3339).ok()? + } }; - Some(Cursor::new(ts)) + match sort_key { + ThreadSortKey::RecencyAt => Some(Cursor::with_thread_id( + ts, + ThreadId::from_string(&id.to_string()).ok()?, + )), + ThreadSortKey::CreatedAt | ThreadSortKey::UpdatedAt => Some(Cursor::new(ts)), + } } async fn build_thread_item( @@ -806,6 +834,7 @@ async fn build_thread_item( model_provider, cli_version, created_at, + recency_at: summary_updated_at.clone(), updated_at: summary_updated_at, }); } diff --git a/codex-rs/rollout/src/metadata.rs b/codex-rs/rollout/src/metadata.rs index 19e9a9320c..dcb38b6d2d 100644 --- a/codex-rs/rollout/src/metadata.rs +++ b/codex-rs/rollout/src/metadata.rs @@ -115,6 +115,7 @@ pub async fn extract_metadata_from_rollout( } if let Some(updated_at) = file_modified_time_utc(rollout_path).await { metadata.updated_at = updated_at; + metadata.recency_at = updated_at; } Ok(ExtractionOutcome { metadata, diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index 03d251e1b3..01e56f569d 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -71,6 +71,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() { let mut expected = builder.build("openai"); apply_rollout_item(&mut expected, &rollout_line.item, "openai"); expected.updated_at = file_modified_time_utc(&path).await.expect("mtime"); + expected.recency_at = expected.updated_at; assert_eq!(outcome.metadata, expected); assert_eq!(outcome.memory_mode, None); diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index f12593f009..7ba111e944 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -543,6 +543,27 @@ impl RolloutRecorder { ) .await; } + if sort_key == ThreadSortKey::RecencyAt { + if let Some(repaired_db_page) = state_db::list_threads_db( + state_db_ctx.as_deref(), + codex_home, + page_size, + cursor, + sort_key, + sort_direction, + allowed_sources, + model_providers, + cwd_filters, + /*parent_thread_id*/ None, + archived, + search_term, + ) + .await + { + return Ok(repaired_db_page.into()); + } + return Ok(db_page.into()); + } codex_state::record_fallback( "list_threads", "metadata_filter", @@ -985,6 +1006,11 @@ fn truncate_fs_page( let cursor_token = match sort_key { ThreadSortKey::CreatedAt => created_at.format(&Rfc3339).ok()?, ThreadSortKey::UpdatedAt => item.updated_at.as_deref()?.to_string(), + ThreadSortKey::RecencyAt => item + .recency_at + .as_deref() + .or(item.updated_at.as_deref())? + .to_string(), }; parse_cursor(cursor_token.as_str()) }); @@ -1049,6 +1075,7 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt cli_version, created_at, updated_at, + recency_at, } = state_item; if item.first_user_message.is_none() { @@ -1093,6 +1120,9 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt if item.updated_at.is_none() { item.updated_at = updated_at; } + if recency_at.is_some() { + item.recency_at = recency_at; + } } #[allow(clippy::too_many_arguments)] @@ -1269,9 +1299,20 @@ async fn list_threads_from_files_asc( .collect::>(); if let Some(cursor) = cursor { - let anchor = cursor.timestamp(); - all_items - .retain(|item| thread_item_sort_key(item, sort_key).is_some_and(|key| key.0 > anchor)); + let anchor = ( + cursor.timestamp(), + cursor + .thread_id() + .and_then(|id| uuid::Uuid::parse_str(&id.to_string()).ok()), + ); + all_items.retain(|item| { + thread_item_sort_key(item, sort_key).is_some_and(|key| match anchor.1 { + Some(anchor_id) if sort_key == ThreadSortKey::RecencyAt => { + key > (anchor.0, anchor_id) + } + _ => key.0 > anchor.0, + }) + }); } let more_matches_available = all_items.len() > page_size || reached_scan_cap; @@ -1329,14 +1370,27 @@ fn thread_item_sort_key( let updated_at = item.updated_at.as_deref().or(item.created_at.as_deref())?; OffsetDateTime::parse(updated_at, &Rfc3339).ok()? } + ThreadSortKey::RecencyAt => { + let recency_at = item + .recency_at + .as_deref() + .or(item.updated_at.as_deref()) + .or(item.created_at.as_deref())?; + OffsetDateTime::parse(recency_at, &Rfc3339).ok()? + } }; Some((timestamp, id)) } fn cursor_from_thread_item(item: &ThreadItem, sort_key: ThreadSortKey) -> Option { - let (timestamp, _id) = thread_item_sort_key(item, sort_key)?; - let cursor_token = timestamp.format(&Rfc3339).ok()?; - parse_cursor(cursor_token.as_str()) + let (timestamp, id) = thread_item_sort_key(item, sort_key)?; + match sort_key { + ThreadSortKey::RecencyAt => Some(Cursor::with_thread_id( + timestamp, + ThreadId::from_string(&id.to_string()).ok()?, + )), + ThreadSortKey::CreatedAt | ThreadSortKey::UpdatedAt => Some(Cursor::new(timestamp)), + } } struct LogFileInfo { @@ -1725,6 +1779,7 @@ fn thread_item_from_state_metadata(item: codex_state::ThreadMetadata) -> ThreadI cli_version: Some(item.cli_version), created_at: Some(item.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)), updated_at: Some(item.updated_at.to_rfc3339_opts(SecondsFormat::Millis, true)), + recency_at: Some(item.recency_at.to_rfc3339_opts(SecondsFormat::Millis, true)), } } diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index c49a477944..2558750f15 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -984,6 +984,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi model_provider: None, cli_version: None, created_at: None, + recency_at: Some("2025-01-03T15:59:00.000Z".to_string()), updated_at: None, }; let state_item = ThreadItem { @@ -1002,6 +1003,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi model_provider: Some("state-provider".to_string()), cli_version: Some("state-version".to_string()), created_at: Some("2025-01-03T16:00:00Z".to_string()), + recency_at: Some("2025-01-03T16:00:30.001Z".to_string()), updated_at: Some("2025-01-03T16:01:02.003Z".to_string()), }; @@ -1027,6 +1029,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi assert_eq!(item.model_provider.as_deref(), Some("state-provider")); assert_eq!(item.cli_version.as_deref(), Some("state-version")); assert_eq!(item.created_at.as_deref(), Some("2025-01-03T16:00:00Z")); + assert_eq!(item.recency_at.as_deref(), Some("2025-01-03T16:00:30.001Z")); assert_eq!(item.updated_at.as_deref(), Some("2025-01-03T16:01:02.003Z")); } diff --git a/codex-rs/rollout/src/state_db.rs b/codex-rs/rollout/src/state_db.rs index ab93305fa6..96fbf7af1c 100644 --- a/codex-rs/rollout/src/state_db.rs +++ b/codex-rs/rollout/src/state_db.rs @@ -290,7 +290,10 @@ fn cursor_to_anchor(cursor: Option<&Cursor>) -> Option { let millis = cursor.timestamp().unix_timestamp_nanos() / 1_000_000; let millis = i64::try_from(millis).ok()?; let ts = chrono::DateTime::::from_timestamp_millis(millis)?; - Some(codex_state::Anchor { ts }) + Some(codex_state::Anchor { + ts, + id: cursor.thread_id(), + }) } pub fn normalize_cwd_for_state_db(cwd: &Path) -> PathBuf { @@ -336,6 +339,7 @@ pub async fn list_thread_ids_db( match sort_key { ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt, + ThreadSortKey::RecencyAt => codex_state::SortKey::RecencyAt, }, allowed_sources.as_slice(), model_providers.as_deref(), @@ -401,6 +405,7 @@ pub async fn list_threads_db( sort_key: match sort_key { ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt, + ThreadSortKey::RecencyAt => codex_state::SortKey::RecencyAt, }, sort_direction: match sort_direction { SortDirection::Asc => codex_state::SortDirection::Asc, diff --git a/codex-rs/rollout/src/state_db_tests.rs b/codex-rs/rollout/src/state_db_tests.rs index a84ede199b..ba92ca27f1 100644 --- a/codex-rs/rollout/src/state_db_tests.rs +++ b/codex-rs/rollout/src/state_db_tests.rs @@ -28,6 +28,22 @@ fn cursor_to_anchor_normalizes_timestamp_format() { .expect("nanosecond"); assert_eq!(anchor.ts, expected_ts); + assert_eq!(anchor.id, None); +} + +#[test] +fn cursor_to_anchor_preserves_recency_tie_breaker() { + let id = ThreadId::from_string("00000000-0000-0000-0000-000000000123") + .expect("thread id should parse"); + let token = format!("2026-01-27T12:34:56Z|{id}"); + let cursor = parse_cursor(&token).expect("cursor should parse"); + let anchor = cursor_to_anchor(Some(&cursor)).expect("anchor should parse"); + + assert_eq!(anchor.id, Some(id)); + assert_eq!( + serde_json::to_string(&cursor).expect("cursor should serialize"), + format!("\"{token}\"") + ); } #[tokio::test] diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index 3c1fc8bb57..0e3ae912dd 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -612,6 +612,7 @@ async fn test_list_conversations_latest_first() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-01-03T12-00-00".into()), + recency_at: updated_times.first().cloned().flatten(), updated_at: updated_times.first().cloned().flatten(), }, ThreadItem { @@ -630,6 +631,7 @@ async fn test_list_conversations_latest_first() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-01-02T12-00-00".into()), + recency_at: updated_times.get(1).cloned().flatten(), updated_at: updated_times.get(1).cloned().flatten(), }, ThreadItem { @@ -648,6 +650,7 @@ async fn test_list_conversations_latest_first() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-01-01T12-00-00".into()), + recency_at: updated_times.get(2).cloned().flatten(), updated_at: updated_times.get(2).cloned().flatten(), }, ], @@ -759,6 +762,7 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-05T09-00-00".into()), + recency_at: updated_page1.first().cloned().flatten(), updated_at: updated_page1.first().cloned().flatten(), }, ThreadItem { @@ -777,6 +781,7 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-04T09-00-00".into()), + recency_at: updated_page1.get(1).cloned().flatten(), updated_at: updated_page1.get(1).cloned().flatten(), }, ], @@ -831,6 +836,7 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-03T09-00-00".into()), + recency_at: updated_page2.first().cloned().flatten(), updated_at: updated_page2.first().cloned().flatten(), }, ThreadItem { @@ -849,6 +855,7 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-02T09-00-00".into()), + recency_at: updated_page2.get(1).cloned().flatten(), updated_at: updated_page2.get(1).cloned().flatten(), }, ], @@ -895,6 +902,7 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-01T09-00-00".into()), + recency_at: updated_page3.first().cloned().flatten(), updated_at: updated_page3.first().cloned().flatten(), }], next_cursor: None, @@ -1066,6 +1074,7 @@ async fn test_get_thread_contents() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.into()), + recency_at: page.items[0].updated_at.clone(), updated_at: page.items[0].updated_at.clone(), }], next_cursor: None, @@ -1421,6 +1430,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.to_string()), + recency_at: updated_page1.first().cloned().flatten(), updated_at: updated_page1.first().cloned().flatten(), }, ThreadItem { @@ -1439,6 +1449,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.to_string()), + recency_at: updated_page1.get(1).cloned().flatten(), updated_at: updated_page1.get(1).cloned().flatten(), }, ], diff --git a/codex-rs/state/migrations/0039_threads_recency_at.sql b/codex-rs/state/migrations/0039_threads_recency_at.sql new file mode 100644 index 0000000000..ccbf79f05f --- /dev/null +++ b/codex-rs/state/migrations/0039_threads_recency_at.sql @@ -0,0 +1,28 @@ +ALTER TABLE threads ADD COLUMN recency_at INTEGER NOT NULL DEFAULT 0; +ALTER TABLE threads ADD COLUMN recency_at_ms INTEGER NOT NULL DEFAULT 0; + +UPDATE threads +SET recency_at = updated_at, + recency_at_ms = updated_at_ms; + +-- Older binaries can open databases migrated by newer binaries. Seed recency +-- when one of those binaries inserts a thread without the new columns. +CREATE TRIGGER threads_recency_at_after_insert +AFTER INSERT ON threads +WHEN NEW.recency_at_ms = 0 +BEGIN + UPDATE threads + SET recency_at = NEW.updated_at, + recency_at_ms = COALESCE(NEW.updated_at_ms, NEW.updated_at * 1000) + WHERE id = NEW.id; +END; + +CREATE INDEX idx_threads_recency_at_ms + ON threads(recency_at_ms DESC, id DESC); + +CREATE INDEX idx_threads_archived_cwd_recency_at_ms + ON threads(archived, cwd, recency_at_ms DESC, id DESC); + +CREATE INDEX idx_threads_visible_recency_at_ms + ON threads(archived, recency_at_ms DESC, id DESC) + WHERE preview <> ''; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 6f5c87b51f..4d25965c44 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -544,6 +544,7 @@ mod tests { rollout_path: PathBuf::from("/tmp/a.jsonl"), created_at, updated_at: created_at, + recency_at: created_at, source: "cli".to_string(), thread_source: None, agent_path: None, diff --git a/codex-rs/state/src/migrations.rs b/codex-rs/state/src/migrations.rs index 0e12f17c45..187e1fb33d 100644 --- a/codex-rs/state/src/migrations.rs +++ b/codex-rs/state/src/migrations.rs @@ -1,5 +1,6 @@ use std::borrow::Cow; +use sqlx::SqlitePool; use sqlx::migrate::Migrator; pub(crate) static STATE_MIGRATOR: Migrator = sqlx::migrate!("./migrations"); @@ -39,3 +40,49 @@ pub(crate) fn runtime_goals_migrator() -> Migrator { pub(crate) fn runtime_memories_migrator() -> Migrator { runtime_migrator(&MEMORIES_MIGRATOR) } + +pub(crate) async fn repair_legacy_recency_migration_version( + pool: &SqlitePool, + migrator: &Migrator, +) -> anyhow::Result<()> { + let Some(recency_migration) = migrator + .migrations + .iter() + .find(|migration| migration.version == 39) + else { + return Ok(()); + }; + let migrations_table_exists = sqlx::query_scalar::<_, i64>( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_sqlx_migrations'", + ) + .fetch_optional(pool) + .await? + .is_some(); + if !migrations_table_exists { + return Ok(()); + } + + sqlx::query( + r#" +UPDATE _sqlx_migrations +SET version = ?, description = ? +WHERE version = ? + AND checksum = ? + AND NOT EXISTS ( + SELECT 1 FROM _sqlx_migrations WHERE version = ? + ) + "#, + ) + .bind(recency_migration.version) + .bind(recency_migration.description.as_ref()) + .bind(38_i64) + .bind(recency_migration.checksum.as_ref()) + .bind(recency_migration.version) + .execute(pool) + .await?; + Ok(()) +} + +#[cfg(test)] +#[path = "migrations_tests.rs"] +mod tests; diff --git a/codex-rs/state/src/migrations_tests.rs b/codex-rs/state/src/migrations_tests.rs new file mode 100644 index 0000000000..d11cc13ea1 --- /dev/null +++ b/codex-rs/state/src/migrations_tests.rs @@ -0,0 +1,198 @@ +use std::borrow::Cow; + +use sqlx::Row; +use sqlx::migrate::Migration; +use sqlx::migrate::Migrator; +use sqlx::sqlite::SqlitePoolOptions; + +use super::STATE_MIGRATOR; +use super::repair_legacy_recency_migration_version; + +fn migrator_through(version: i64) -> Migrator { + Migrator { + migrations: Cow::Owned( + STATE_MIGRATOR + .migrations + .iter() + .filter(|migration| migration.version <= version) + .cloned() + .collect(), + ), + ignore_missing: STATE_MIGRATOR.ignore_missing, + locking: STATE_MIGRATOR.locking, + table_name: STATE_MIGRATOR.table_name.clone(), + create_schemas: STATE_MIGRATOR.create_schemas.clone(), + no_tx: STATE_MIGRATOR.no_tx, + } +} + +#[tokio::test] +async fn recency_migration_backfills_and_seeds_old_binary_inserts() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("in-memory database should open"); + migrator_through(/*version*/ 37) + .run(&pool) + .await + .expect("pre-recency migrations should apply"); + + sqlx::query( + r#" +INSERT INTO threads ( + id, + rollout_path, + created_at, + updated_at, + created_at_ms, + updated_at_ms, + source, + model_provider, + cwd, + title, + sandbox_policy, + approval_mode +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind("00000000-0000-0000-0000-000000000001") + .bind("/tmp/first.jsonl") + .bind(1_700_000_000_i64) + .bind(1_700_000_100_i64) + .bind(1_700_000_000_123_i64) + .bind(1_700_000_100_456_i64) + .bind("cli") + .bind("openai") + .bind("/tmp") + .bind("") + .bind("read-only") + .bind("on-request") + .execute(&pool) + .await + .expect("legacy row should insert"); + + STATE_MIGRATOR + .run(&pool) + .await + .expect("recency migration should apply"); + + let backfilled = sqlx::query( + "SELECT updated_at, updated_at_ms, recency_at, recency_at_ms FROM threads WHERE id = ?", + ) + .bind("00000000-0000-0000-0000-000000000001") + .fetch_one(&pool) + .await + .expect("backfilled row should load"); + assert_eq!(backfilled.get::("recency_at"), 1_700_000_100); + assert_eq!(backfilled.get::("recency_at_ms"), 1_700_000_100_456); + + sqlx::query( + r#" +INSERT INTO threads ( + id, + rollout_path, + created_at, + updated_at, + created_at_ms, + updated_at_ms, + source, + model_provider, + cwd, + title, + sandbox_policy, + approval_mode +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind("00000000-0000-0000-0000-000000000002") + .bind("/tmp/second.jsonl") + .bind(1_700_000_200_i64) + .bind(1_700_000_300_i64) + .bind(1_700_000_200_123_i64) + .bind(1_700_000_300_456_i64) + .bind("cli") + .bind("openai") + .bind("/tmp") + .bind("") + .bind("read-only") + .bind("on-request") + .execute(&pool) + .await + .expect("old-binary row should insert"); + + let seeded = sqlx::query("SELECT recency_at, recency_at_ms FROM threads WHERE id = ?") + .bind("00000000-0000-0000-0000-000000000002") + .fetch_one(&pool) + .await + .expect("old-binary row should load"); + assert_eq!(seeded.get::("recency_at"), 1_700_000_300); + assert_eq!(seeded.get::("recency_at_ms"), 1_700_000_300_456); +} + +#[tokio::test] +async fn repairs_recency_migration_that_was_applied_as_version_38() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("in-memory database should open"); + migrator_through(/*version*/ 37) + .run(&pool) + .await + .expect("pre-recency migrations should apply"); + + let recency_migration = STATE_MIGRATOR + .migrations + .iter() + .find(|migration| migration.version == 39) + .expect("recency migration should exist"); + let mut legacy_migrations = STATE_MIGRATOR + .migrations + .iter() + .filter(|migration| migration.version <= 37) + .cloned() + .collect::>(); + legacy_migrations.push(Migration::new( + 38, + recency_migration.description.clone(), + recency_migration.migration_type, + recency_migration.sql.clone(), + recency_migration.no_tx, + )); + let legacy_recency_migrator = Migrator::with_migrations(legacy_migrations); + legacy_recency_migrator + .run(&pool) + .await + .expect("legacy recency migration should apply as version 38"); + + repair_legacy_recency_migration_version(&pool, &STATE_MIGRATOR) + .await + .expect("legacy migration history should be repaired"); + STATE_MIGRATOR + .run(&pool) + .await + .expect("current migrations should apply after repair"); + + let applied = sqlx::query( + "SELECT version, checksum FROM _sqlx_migrations WHERE version >= 38 ORDER BY version", + ) + .fetch_all(&pool) + .await + .expect("applied migrations should load") + .into_iter() + .map(|row| { + ( + row.get::("version"), + row.get::, _>("checksum"), + ) + }) + .collect::>(); + let expected = STATE_MIGRATOR + .migrations + .iter() + .filter(|migration| migration.version >= 38) + .map(|migration| (migration.version, migration.checksum.to_vec())) + .collect::>(); + assert_eq!(applied, expected); +} diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index a30f7e479b..c25c458986 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -18,6 +18,8 @@ pub enum SortKey { CreatedAt, /// Sort by the thread's last update timestamp. UpdatedAt, + /// Sort by the thread's product recency timestamp. + RecencyAt, } /// Sort direction to use when listing threads. @@ -32,6 +34,8 @@ pub enum SortDirection { pub struct Anchor { /// The timestamp component of the anchor. pub ts: DateTime, + /// The thread ID component used to disambiguate equal recency timestamps. + pub id: Option, } /// A single page of thread metadata results. @@ -67,6 +71,8 @@ pub struct ThreadMetadata { pub created_at: DateTime, /// The last update timestamp. pub updated_at: DateTime, + /// The product recency timestamp. + pub recency_at: DateTime, /// The session source (stringified enum). pub source: String, /// Optional analytics source classification for this thread. @@ -120,6 +126,8 @@ pub struct ThreadMetadataBuilder { pub created_at: DateTime, /// The last update timestamp, if known. pub updated_at: Option>, + /// The product recency timestamp, if known. + pub recency_at: Option>, /// The session source. pub source: SessionSource, /// Optional analytics source classification for this thread. @@ -163,6 +171,7 @@ impl ThreadMetadataBuilder { rollout_path, created_at, updated_at: None, + recency_at: None, source, thread_source: None, agent_nickname: None, @@ -190,11 +199,16 @@ impl ThreadMetadataBuilder { .updated_at .map(canonicalize_datetime) .unwrap_or(created_at); + let recency_at = self + .recency_at + .map(canonicalize_datetime) + .unwrap_or(updated_at); ThreadMetadata { id: self.id, rollout_path: self.rollout_path.clone(), created_at, updated_at, + recency_at, source, thread_source: self.thread_source.clone(), agent_nickname: self.agent_nickname.clone(), @@ -340,6 +354,7 @@ pub(crate) struct ThreadRow { rollout_path: String, created_at: i64, updated_at: i64, + recency_at: i64, source: String, thread_source: Option, agent_nickname: Option, @@ -369,6 +384,7 @@ impl ThreadRow { rollout_path: row.try_get("rollout_path")?, created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, + recency_at: row.try_get("recency_at")?, source: row.try_get("source")?, thread_source: row.try_get("thread_source")?, agent_nickname: row.try_get("agent_nickname")?, @@ -402,6 +418,7 @@ impl TryFrom for ThreadMetadata { rollout_path, created_at, updated_at, + recency_at, source, thread_source, agent_nickname, @@ -432,6 +449,7 @@ impl TryFrom for ThreadMetadata { rollout_path: PathBuf::from(rollout_path), created_at: epoch_millis_to_datetime(created_at)?, updated_at: epoch_millis_to_datetime(updated_at)?, + recency_at: epoch_millis_to_datetime(recency_at)?, source, thread_source, agent_nickname, @@ -461,8 +479,12 @@ pub(crate) fn anchor_from_item(item: &ThreadMetadata, sort_key: SortKey) -> Opti let ts = match sort_key { SortKey::CreatedAt => item.created_at, SortKey::UpdatedAt => item.updated_at, + SortKey::RecencyAt => item.recency_at, }; - Some(Anchor { ts }) + Some(Anchor { + ts, + id: (sort_key == SortKey::RecencyAt).then_some(item.id), + }) } pub(crate) fn datetime_to_epoch_millis(dt: DateTime) -> i64 { @@ -519,6 +541,7 @@ mod tests { rollout_path: "/tmp/rollout-123.jsonl".to_string(), created_at: 1_700_000_000, updated_at: 1_700_000_100, + recency_at: 1_700_000_100, source: "cli".to_string(), thread_source: None, agent_nickname: None, @@ -549,6 +572,7 @@ mod tests { rollout_path: PathBuf::from("/tmp/rollout-123.jsonl"), created_at: DateTime::::from_timestamp(1_700_000_000, 0).expect("timestamp"), updated_at: DateTime::::from_timestamp(1_700_000_100, 0).expect("timestamp"), + recency_at: DateTime::::from_timestamp(1_700_000_100, 0).expect("timestamp"), source: "cli".to_string(), thread_source: None, agent_nickname: None, diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 0e5dc85586..4b1d264d0b 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -17,6 +17,7 @@ use crate::ThreadMetadata; use crate::ThreadMetadataBuilder; use crate::ThreadsPage; use crate::apply_rollout_item; +use crate::migrations::repair_legacy_recency_migration_version; use crate::migrations::runtime_goals_migrator; use crate::migrations::runtime_logs_migrator; use crate::migrations::runtime_memories_migrator; @@ -160,6 +161,7 @@ pub struct StateRuntime { thread_goals: GoalStore, memories: MemoryStore, thread_updated_at_millis: Arc, + thread_recency_at_millis: Arc, } impl StateRuntime { @@ -262,32 +264,36 @@ impl StateRuntime { return Err(err); } let started = Instant::now(); - let thread_updated_at_millis_result: anyhow::Result> = - sqlx::query_scalar("SELECT MAX(threads.updated_at_ms) FROM threads") - .fetch_one(pool.as_ref()) - .await - .map_err(anyhow::Error::from); + let thread_timestamp_millis_result: anyhow::Result<(Option, Option)> = + sqlx::query_as( + "SELECT MAX(threads.updated_at_ms), MAX(threads.recency_at_ms) FROM threads", + ) + .fetch_one(pool.as_ref()) + .await + .map_err(anyhow::Error::from); crate::telemetry::record_init_result( telemetry_override, DbKind::State, "post_init_query", started.elapsed(), - &thread_updated_at_millis_result, + &thread_timestamp_millis_result, ); - let thread_updated_at_millis = match thread_updated_at_millis_result { - Ok(value) => value, - Err(err) => { - close_sqlite_pools(&[ - pool.as_ref(), - logs_pool.as_ref(), - goals_pool.as_ref(), - memories_pool.as_ref(), - ]) - .await; - return Err(err); - } - }; + let (thread_updated_at_millis, thread_recency_at_millis) = + match thread_timestamp_millis_result { + Ok(value) => value, + Err(err) => { + close_sqlite_pools(&[ + pool.as_ref(), + logs_pool.as_ref(), + goals_pool.as_ref(), + memories_pool.as_ref(), + ]) + .await; + return Err(err); + } + }; let thread_updated_at_millis = thread_updated_at_millis.unwrap_or(0); + let thread_recency_at_millis = thread_recency_at_millis.unwrap_or(0); let runtime = Arc::new(Self { thread_goals: GoalStore::new(Arc::clone(&goals_pool)), memories: MemoryStore::new(Arc::clone(&memories_pool), Arc::clone(&pool)), @@ -296,6 +302,7 @@ impl StateRuntime { codex_home, default_provider, thread_updated_at_millis: Arc::new(AtomicI64::new(thread_updated_at_millis)), + thread_recency_at_millis: Arc::new(AtomicI64::new(thread_recency_at_millis)), }); if let Err(err) = runtime.run_logs_startup_maintenance().await { warn!( @@ -420,7 +427,13 @@ async fn open_sqlite( let pool = pool_result .map_err(|source| recovery::RuntimeDbInitError::new(spec.label, "open", path, source))?; let started = Instant::now(); - let migrate_result = migrator.run(&pool).await.map_err(anyhow::Error::from); + let migrate_result = async { + if matches!(spec.kind, DbKind::State) { + repair_legacy_recency_migration_version(&pool, migrator).await?; + } + migrator.run(&pool).await.map_err(anyhow::Error::from) + } + .await; crate::telemetry::record_init_result( telemetry_override, spec.kind, diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index 895c254350..9b2c337f5f 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -175,6 +175,7 @@ SELECT threads.rollout_path, threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, + threads.recency_at_ms AS recency_at, threads.source, threads.thread_source, threads.agent_path, @@ -545,6 +546,7 @@ SELECT threads.rollout_path, threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, + threads.recency_at_ms AS recency_at, threads.source, threads.thread_source, threads.agent_nickname, diff --git a/codex-rs/state/src/runtime/test_support.rs b/codex-rs/state/src/runtime/test_support.rs index 848a193335..c029a0ee2d 100644 --- a/codex-rs/state/src/runtime/test_support.rs +++ b/codex-rs/state/src/runtime/test_support.rs @@ -47,6 +47,7 @@ pub(super) fn test_thread_metadata( rollout_path: codex_home.join(format!("rollout-{thread_id}.jsonl")), created_at: now, updated_at: now, + recency_at: now, source: "cli".to_string(), thread_source: None, agent_nickname: None, diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 6cebf27e87..576f760264 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -1,6 +1,7 @@ use super::*; use crate::SortDirection; use codex_protocol::protocol::SessionSource; +use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; impl StateRuntime { @@ -12,6 +13,7 @@ SELECT threads.rollout_path, threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, + threads.recency_at_ms AS recency_at, threads.source, threads.thread_source, threads.agent_nickname, @@ -498,6 +500,7 @@ ON CONFLICT(child_thread_id) DO NOTHING metadata: &crate::ThreadMetadata, ) -> anyhow::Result { let updated_at = self.allocate_thread_updated_at(metadata.updated_at)?; + let recency_at = self.allocate_thread_recency_at(metadata.recency_at)?; let preview = metadata_preview(metadata); let result = sqlx::query( r#" @@ -506,8 +509,10 @@ INSERT INTO threads ( rollout_path, created_at, updated_at, + recency_at, created_at_ms, updated_at_ms, + recency_at_ms, source, thread_source, agent_nickname, @@ -530,7 +535,7 @@ INSERT INTO threads ( git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING "#, ) @@ -538,8 +543,10 @@ ON CONFLICT(id) DO NOTHING .bind(metadata.rollout_path.display().to_string()) .bind(datetime_to_epoch_seconds(metadata.created_at)) .bind(datetime_to_epoch_seconds(updated_at)) + .bind(datetime_to_epoch_seconds(recency_at)) .bind(datetime_to_epoch_millis(metadata.created_at)) .bind(datetime_to_epoch_millis(updated_at)) + .bind(datetime_to_epoch_millis(recency_at)) .bind(metadata.source.as_str()) .bind( metadata @@ -621,6 +628,32 @@ ON CONFLICT(id) DO NOTHING Ok(result.rows_affected() > 0) } + pub async fn touch_thread_recency_at( + &self, + thread_id: ThreadId, + recency_at: DateTime, + ) -> anyhow::Result { + let recency_at = self.allocate_thread_recency_at(recency_at)?; + let recency_at_seconds = datetime_to_epoch_seconds(recency_at); + let recency_at_millis = datetime_to_epoch_millis(recency_at); + let result = sqlx::query( + r#" +UPDATE threads +SET + recency_at = MAX(?, MAX(?, recency_at_ms + 1) / 1000), + recency_at_ms = MAX(?, recency_at_ms + 1) +WHERE id = ? + "#, + ) + .bind(recency_at_seconds) + .bind(recency_at_millis) + .bind(recency_at_millis) + .bind(thread_id.to_string()) + .execute(self.pool.as_ref()) + .await?; + Ok(result.rows_affected() > 0) + } + /// Allocate a persisted `updated_at` value for thread-list cursor ordering. /// /// We keep a process-local high-water mark so hot rollout writes can get unique, @@ -631,42 +664,56 @@ ON CONFLICT(id) DO NOTHING &self, updated_at: DateTime, ) -> anyhow::Result> { - let candidate = datetime_to_epoch_millis(updated_at); - let allocated = loop { - let current = self.thread_updated_at_millis.load(Ordering::Relaxed); - - // New wall-clock time: advance the process-local high-water mark and use it as-is. - if candidate > current { - if self - .thread_updated_at_millis - .compare_exchange(current, candidate, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - break candidate; - } - continue; - } - - // Older timestamps come from backfill/repair paths that preserve rollout mtimes. - // Do not drag historical rows forward just because this process has seen newer writes. - if candidate.saturating_add(1000) <= current { - break candidate; - } - - // Same hot one-second bucket as the current high-water mark. Allocate the next - // millisecond so updated_at remains unique and cursor-orderable inside the process. - let bumped = current.saturating_add(1); - if self - .thread_updated_at_millis - .compare_exchange(current, bumped, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - break bumped; - } - }; - epoch_millis_to_datetime(allocated) + allocate_thread_timestamp(self.thread_updated_at_millis.as_ref(), updated_at) } + fn allocate_thread_recency_at( + &self, + recency_at: DateTime, + ) -> anyhow::Result> { + allocate_thread_timestamp(self.thread_recency_at_millis.as_ref(), recency_at) + } +} + +fn allocate_thread_timestamp( + high_water_mark: &AtomicI64, + timestamp: DateTime, +) -> anyhow::Result> { + let candidate = datetime_to_epoch_millis(timestamp); + let allocated = loop { + let current = high_water_mark.load(Ordering::Relaxed); + + // New wall-clock time: advance the process-local high-water mark and use it as-is. + if candidate > current { + if high_water_mark + .compare_exchange(current, candidate, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + break candidate; + } + continue; + } + + // Older timestamps come from backfill/repair paths that preserve rollout mtimes. + // Do not drag historical rows forward just because this process has seen newer writes. + if candidate.saturating_add(1000) <= current { + break candidate; + } + + // Same hot one-second bucket as the current high-water mark. Allocate the next + // millisecond so the timestamp remains unique and cursor-orderable inside the process. + let bumped = current.saturating_add(1); + if high_water_mark + .compare_exchange(current, bumped, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + break bumped; + } + }; + epoch_millis_to_datetime(allocated) +} + +impl StateRuntime { pub async fn update_thread_git_info( &self, thread_id: ThreadId, @@ -702,6 +749,7 @@ WHERE id = ? creation_memory_mode: Option<&str>, ) -> anyhow::Result<()> { let updated_at = self.allocate_thread_updated_at(metadata.updated_at)?; + let insert_recency_at = self.allocate_thread_recency_at(metadata.recency_at)?; let preview = metadata_preview(metadata); // Backfill/reconcile callers merge existing git info before upserting, but that // read/modify/write is not atomic. Preserve non-null SQLite git fields here so @@ -713,8 +761,10 @@ INSERT INTO threads ( rollout_path, created_at, updated_at, + recency_at, created_at_ms, updated_at_ms, + recency_at_ms, source, thread_source, agent_nickname, @@ -737,13 +787,15 @@ INSERT INTO threads ( git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET rollout_path = excluded.rollout_path, created_at = excluded.created_at, updated_at = excluded.updated_at, + recency_at = threads.recency_at, created_at_ms = excluded.created_at_ms, updated_at_ms = excluded.updated_at_ms, + recency_at_ms = threads.recency_at_ms, source = excluded.source, thread_source = excluded.thread_source, agent_nickname = excluded.agent_nickname, @@ -771,8 +823,10 @@ ON CONFLICT(id) DO UPDATE SET .bind(metadata.rollout_path.display().to_string()) .bind(datetime_to_epoch_seconds(metadata.created_at)) .bind(datetime_to_epoch_seconds(updated_at)) + .bind(datetime_to_epoch_seconds(insert_recency_at)) .bind(datetime_to_epoch_millis(metadata.created_at)) .bind(datetime_to_epoch_millis(updated_at)) + .bind(datetime_to_epoch_millis(insert_recency_at)) .bind(metadata.source.as_str()) .bind( metadata @@ -1079,6 +1133,7 @@ SELECT threads.rollout_path, threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, + threads.recency_at_ms AS recency_at, threads.source, threads.thread_source, threads.agent_nickname, @@ -1197,6 +1252,7 @@ pub(super) fn push_thread_filters<'a>( let column = match sort_key { SortKey::CreatedAt => "threads.created_at_ms", SortKey::UpdatedAt => "threads.updated_at_ms", + SortKey::RecencyAt => "threads.recency_at_ms", }; let operator = match sort_direction { SortDirection::Asc => ">", @@ -1208,6 +1264,19 @@ pub(super) fn push_thread_filters<'a>( builder.push(operator); builder.push(" "); builder.push_bind(anchor_ts); + if sort_key == SortKey::RecencyAt + && let Some(anchor_id) = anchor.id + { + builder.push(" OR ("); + builder.push(column); + builder.push(" = "); + builder.push_bind(anchor_ts); + builder.push(" AND threads.id "); + builder.push(operator); + builder.push(" "); + builder.push_bind(anchor_id.to_string()); + builder.push(")"); + } builder.push(")"); } } @@ -1232,6 +1301,7 @@ pub(super) fn push_thread_order_and_limit( let order_column = match sort_key { SortKey::CreatedAt => "threads.created_at_ms", SortKey::UpdatedAt => "threads.updated_at_ms", + SortKey::RecencyAt => "threads.recency_at_ms", }; let order_direction = match sort_direction { SortDirection::Asc => "ASC", @@ -1247,6 +1317,10 @@ pub(super) fn push_thread_order_and_limit( builder.push(order_column); builder.push(" "); builder.push(order_direction); + if sort_key == SortKey::RecencyAt { + builder.push(", threads.id "); + builder.push(order_direction); + } builder.push(" LIMIT "); builder.push_bind(limit as i64); } @@ -1521,6 +1595,7 @@ mod tests { let anchor = Anchor { ts: older_updated_at, + id: None, }; let model_providers = ["test-provider".to_string()]; let page = runtime @@ -1547,6 +1622,7 @@ mod tests { Some(Anchor { ts: DateTime::::from_timestamp_millis(1_700_000_200_000) .expect("valid timestamp"), + id: None, }) ); @@ -1631,6 +1707,7 @@ mod tests { Some(Anchor { ts: DateTime::::from_timestamp_millis(1_700_000_300_000) .expect("valid timestamp"), + id: None, }) ); @@ -1693,6 +1770,7 @@ mod tests { ]; let anchor = Anchor { ts: DateTime::::from_timestamp(1_700_000_000, 0).expect("valid timestamp"), + id: None, }; for (sort_key, visible_index, cwd_index) in [ ( @@ -1705,6 +1783,11 @@ mod tests { "idx_threads_visible_updated_at_ms", "idx_threads_archived_cwd_updated_at_ms", ), + ( + SortKey::RecencyAt, + "idx_threads_visible_recency_at_ms", + "idx_threads_archived_cwd_recency_at_ms", + ), ] { for (cwd_filters, anchor, expected_index, expect_temp_sort) in [ (None, None, visible_index, false), @@ -2279,6 +2362,186 @@ mod tests { assert_eq!(persisted.preview.as_deref(), Some("first-user-message")); } + #[tokio::test] + async fn touch_thread_recency_at_is_monotonic_and_survives_stale_upsert() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("state db should initialize"); + let thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000792").expect("valid thread id"); + let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + let original_recency_at = metadata.recency_at; + runtime + .upsert_thread(&metadata) + .await + .expect("initial upsert should succeed"); + + let touched_at = + DateTime::::from_timestamp_millis(1_700_001_111_123).expect("timestamp"); + assert!( + runtime + .touch_thread_recency_at(thread_id, touched_at) + .await + .expect("touch should succeed") + ); + + metadata.updated_at = + DateTime::::from_timestamp_millis(1_700_001_222_456).expect("timestamp"); + metadata.title = "updated metadata".to_string(); + assert_eq!(metadata.recency_at, original_recency_at); + runtime + .upsert_thread(&metadata) + .await + .expect("stale metadata upsert should succeed"); + + let persisted = runtime + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist"); + assert_eq!(persisted.recency_at, touched_at); + assert_eq!(persisted.updated_at, metadata.updated_at); + assert_eq!(persisted.title, "updated metadata"); + + assert!( + runtime + .touch_thread_recency_at(thread_id, original_recency_at) + .await + .expect("older touch should succeed") + ); + let persisted = runtime + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist"); + assert_eq!( + datetime_to_epoch_millis(persisted.recency_at), + datetime_to_epoch_millis(touched_at) + 1 + ); + } + + #[tokio::test] + async fn list_threads_orders_and_pages_by_recency_at() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("state db should initialize"); + let first_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000793").expect("valid thread id"); + let second_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000794").expect("valid thread id"); + let third_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000795").expect("valid thread id"); + let recency_at = + DateTime::::from_timestamp_millis(1_700_002_000_456).expect("timestamp"); + + for thread_id in [first_id, second_id, third_id] { + let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + metadata.recency_at = recency_at; + runtime + .upsert_thread(&metadata) + .await + .expect("thread insert should succeed"); + } + sqlx::query("UPDATE threads SET recency_at = ?, recency_at_ms = ?") + .bind(datetime_to_epoch_seconds(recency_at)) + .bind(datetime_to_epoch_millis(recency_at)) + .execute(runtime.pool.as_ref()) + .await + .expect("recency timestamps should match"); + + let first_page = runtime + .list_threads( + /*page_size*/ 1, + ThreadFilterOptions { + archived_only: false, + allowed_sources: &[], + model_providers: None, + cwd_filters: None, + anchor: None, + sort_key: SortKey::RecencyAt, + sort_direction: SortDirection::Desc, + search_term: None, + }, + ) + .await + .expect("list should succeed"); + assert_eq!( + first_page + .items + .iter() + .map(|item| item.id) + .collect::>(), + vec![third_id] + ); + assert_eq!( + first_page.next_anchor, + Some(Anchor { + ts: recency_at, + id: Some(third_id), + }) + ); + + let second_page = runtime + .list_threads( + /*page_size*/ 1, + ThreadFilterOptions { + archived_only: false, + allowed_sources: &[], + model_providers: None, + cwd_filters: None, + anchor: first_page.next_anchor.as_ref(), + sort_key: SortKey::RecencyAt, + sort_direction: SortDirection::Desc, + search_term: None, + }, + ) + .await + .expect("second list should succeed"); + assert_eq!( + second_page + .items + .iter() + .map(|item| item.id) + .collect::>(), + vec![second_id] + ); + assert_eq!( + second_page.next_anchor, + Some(Anchor { + ts: recency_at, + id: Some(second_id), + }) + ); + + let third_page = runtime + .list_threads( + /*page_size*/ 1, + ThreadFilterOptions { + archived_only: false, + allowed_sources: &[], + model_providers: None, + cwd_filters: None, + anchor: second_page.next_anchor.as_ref(), + sort_key: SortKey::RecencyAt, + sort_direction: SortDirection::Desc, + search_term: None, + }, + ) + .await + .expect("third list should succeed"); + assert_eq!( + third_page + .items + .iter() + .map(|item| item.id) + .collect::>(), + vec![first_id] + ); + assert_eq!(third_page.next_anchor, None); + } + #[tokio::test] async fn thread_updated_at_uses_unique_epoch_millis_and_reads_legacy_seconds() { let codex_home = unique_temp_dir(); @@ -2295,8 +2558,10 @@ mod tests { DateTime::::from_timestamp_millis(1_700_001_111_123).expect("timestamp millis"); let mut first = test_thread_metadata(&codex_home, first_id, codex_home.clone()); first.updated_at = updated_at; + first.recency_at = updated_at; let mut second = test_thread_metadata(&codex_home, second_id, codex_home.clone()); second.updated_at = updated_at; + second.recency_at = updated_at; runtime .upsert_thread(&first) @@ -2325,6 +2590,14 @@ mod tests { datetime_to_epoch_millis(second.updated_at), 1_700_001_111_124 ); + assert_eq!( + datetime_to_epoch_millis(first.recency_at), + 1_700_001_111_123 + ); + assert_eq!( + datetime_to_epoch_millis(second.recency_at), + 1_700_001_111_124 + ); let second_row: (i64, i64, Option, Option) = sqlx::query_as( "SELECT created_at, updated_at, created_at_ms, updated_at_ms FROM threads WHERE id = ?", ) diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index d02b78d237..7eda54a9c0 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -535,6 +535,9 @@ fn stored_thread_from_state( updated_at: metadata .and_then(|metadata| metadata.updated_at) .unwrap_or_else(Utc::now), + recency_at: metadata + .and_then(|metadata| metadata.advance_recency_at.or(metadata.updated_at)) + .unwrap_or_else(Utc::now), archived_at: None, cwd: metadata .and_then(|metadata| metadata.cwd.clone()) diff --git a/codex-rs/thread-store/src/local/archive_thread.rs b/codex-rs/thread-store/src/local/archive_thread.rs index bbe5c3de51..eb4a48b680 100644 --- a/codex-rs/thread-store/src/local/archive_thread.rs +++ b/codex-rs/thread-store/src/local/archive_thread.rs @@ -174,5 +174,6 @@ mod tests { .expect("thread metadata should exist"); assert_eq!(updated.rollout_path, archived_path); assert!(updated.archived_at.is_some()); + assert_eq!(updated.recency_at, metadata.recency_at); } } diff --git a/codex-rs/thread-store/src/local/helpers.rs b/codex-rs/thread-store/src/local/helpers.rs index 5acdcf09b6..f48a0136c0 100644 --- a/codex-rs/thread-store/src/local/helpers.rs +++ b/codex-rs/thread-store/src/local/helpers.rs @@ -106,6 +106,7 @@ pub(super) fn stored_thread_from_rollout_item( .or_else(|| thread_id_from_rollout_path(item.path.as_path()))?; let created_at = parse_rfc3339(item.created_at.as_deref()).unwrap_or_else(Utc::now); let updated_at = parse_rfc3339(item.updated_at.as_deref()).unwrap_or(created_at); + let recency_at = parse_rfc3339(item.recency_at.as_deref()).unwrap_or(updated_at); let archived_at = archived.then_some(updated_at); let git_info = git_info_from_parts( item.git_sha.clone(), @@ -136,6 +137,7 @@ pub(super) fn stored_thread_from_rollout_item( reasoning_effort: None, created_at, updated_at, + recency_at, archived_at, cwd: item.cwd.unwrap_or_default(), cli_version: item.cli_version.unwrap_or_default(), diff --git a/codex-rs/thread-store/src/local/list_threads.rs b/codex-rs/thread-store/src/local/list_threads.rs index fc3b5034ad..03ae163afe 100644 --- a/codex-rs/thread-store/src/local/list_threads.rs +++ b/codex-rs/thread-store/src/local/list_threads.rs @@ -34,6 +34,7 @@ pub(super) async fn list_threads( let sort_key = match params.sort_key { ThreadSortKey::CreatedAt => codex_rollout::ThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_rollout::ThreadSortKey::UpdatedAt, + ThreadSortKey::RecencyAt => codex_rollout::ThreadSortKey::RecencyAt, }; let sort_direction = match params.sort_direction { SortDirection::Asc => codex_rollout::SortDirection::Asc, diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index dd49fd2a59..550e7d56bb 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -314,10 +314,16 @@ mod tests { use codex_protocol::ThreadId; use codex_protocol::models::BaseInstructions; + use codex_protocol::models::FunctionCallOutputPayload; + use codex_protocol::models::MessagePhase; + use codex_protocol::models::ResponseItem; + use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadMemoryMode; + use codex_protocol::protocol::TurnCompleteEvent; + use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use tempfile::TempDir; @@ -449,6 +455,92 @@ mod tests { assert_eq!(metadata.title, "observed append"); } + #[tokio::test] + async fn live_thread_output_advances_updated_at_but_not_recency_at() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone()))); + let thread_id = ThreadId::default(); + let live_thread = LiveThread::create(store, create_thread_params(thread_id)) + .await + .expect("create live thread"); + + live_thread + .append_items(&[user_message_item("start thread")]) + .await + .expect("append initial user message"); + live_thread.flush().await.expect("flush thread"); + let before_turn_start = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata"); + + live_thread + .append_items(&[RolloutItem::EventMsg(EventMsg::TurnStarted( + TurnStartedEvent { + turn_id: "turn-1".to_string(), + trace_id: None, + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }, + ))]) + .await + .expect("append turn start"); + live_thread.flush().await.expect("flush thread"); + let after_turn_start = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata"); + assert!(after_turn_start.recency_at > before_turn_start.recency_at); + + live_thread + .append_items(&[ + RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { + message: "commentary".to_string(), + phase: Some(MessagePhase::Commentary), + memory_citation: None, + })), + RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput { + call_id: "call-1".to_string(), + output: FunctionCallOutputPayload::from_text("tool output".to_string()), + metadata: None, + }), + RolloutItem::EventMsg(EventMsg::TokenCount( + codex_protocol::protocol::TokenCountEvent { + info: None, + rate_limits: None, + }, + )), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + })), + ]) + .await + .expect("append post-start items"); + live_thread.flush().await.expect("flush thread"); + let completed = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata"); + + assert!(completed.updated_at > after_turn_start.updated_at); + assert_eq!(completed.recency_at, after_turn_start.recency_at); + } + #[tokio::test] async fn live_thread_shutdown_does_not_materialize_empty_thread_metadata() { let home = TempDir::new().expect("temp dir"); diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index 4778dd69d9..a1e1c59190 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -55,6 +55,7 @@ pub(super) async fn read_thread( && (params.include_archived || rollout_thread.archived_at.is_none()) && !rollout_thread.preview.is_empty() { + rollout_thread.recency_at = thread.recency_at; if thread.name.is_some() { rollout_thread.name = thread.name; } @@ -115,6 +116,7 @@ pub(super) async fn read_thread_by_rollout_path( }); } if let Some(metadata) = read_sqlite_metadata(store, thread.thread_id).await { + thread.recency_at = metadata.recency_at; let existing_git_info = thread.git_info.take(); let (fallback_sha, fallback_branch, fallback_origin_url) = match existing_git_info { Some(info) => ( @@ -340,6 +342,7 @@ async fn stored_thread_from_sqlite_metadata( reasoning_effort: metadata.reasoning_effort, created_at: metadata.created_at, updated_at: metadata.updated_at, + recency_at: metadata.recency_at, archived_at: metadata.archived_at, cwd: metadata.cwd, cli_version: metadata.cli_version, @@ -406,6 +409,7 @@ fn stored_thread_from_meta_line( reasoning_effort: None, created_at, updated_at, + recency_at: updated_at, archived_at: archived.then_some(updated_at), cwd: meta_line.meta.cwd, cli_version: meta_line.meta.cli_version, @@ -547,6 +551,10 @@ mod tests { ); builder.model_provider = Some(config.default_model_provider_id.clone()); builder.git_branch = Some("sqlite-branch".to_string()); + let recency_at = chrono::DateTime::parse_from_rfc3339("2026-01-03T12:00:00Z") + .expect("timestamp should parse") + .with_timezone(&Utc); + builder.recency_at = Some(recency_at); runtime .upsert_thread(&builder.build(config.default_model_provider_id.as_str())) .await @@ -562,6 +570,7 @@ mod tests { .expect("read thread by rollout path"); let git_info = thread.git_info.expect("git info should be present"); + assert_eq!(thread.recency_at, recency_at); assert_eq!(git_info.branch.as_deref(), Some("sqlite-branch")); assert_eq!( git_info.commit_hash.as_ref().map(|sha| sha.0.as_str()), diff --git a/codex-rs/thread-store/src/local/search_threads.rs b/codex-rs/thread-store/src/local/search_threads.rs index 1b7f18a32f..fc2ddd6e71 100644 --- a/codex-rs/thread-store/src/local/search_threads.rs +++ b/codex-rs/thread-store/src/local/search_threads.rs @@ -23,6 +23,10 @@ use crate::ThreadSortKey; use crate::ThreadStoreError; use crate::ThreadStoreResult; +#[cfg(test)] +#[path = "search_threads_tests.rs"] +mod tests; + struct ThreadSearchItem { item: codex_rollout::ThreadItem, snippet: String, @@ -50,6 +54,7 @@ pub(super) async fn search_threads( let sort_key = match params.sort_key { ThreadSortKey::CreatedAt => codex_rollout::ThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_rollout::ThreadSortKey::UpdatedAt, + ThreadSortKey::RecencyAt => codex_rollout::ThreadSortKey::RecencyAt, }; let sort_direction = match params.sort_direction { SortDirection::Asc => codex_rollout::SortDirection::Asc, @@ -179,8 +184,17 @@ fn cursor_from_thread_search_item( .updated_at .as_deref() .or(item.item.created_at.as_deref())?, + ThreadSortKey::RecencyAt => item + .item + .recency_at + .as_deref() + .or(item.item.updated_at.as_deref()) + .or(item.item.created_at.as_deref())?, }; - parse_cursor(timestamp) + match sort_key { + ThreadSortKey::RecencyAt => parse_cursor(&format!("{timestamp}|{}", item.item.thread_id?)), + ThreadSortKey::CreatedAt | ThreadSortKey::UpdatedAt => parse_cursor(timestamp), + } } async fn set_thread_search_result_names( diff --git a/codex-rs/thread-store/src/local/search_threads_tests.rs b/codex-rs/thread-store/src/local/search_threads_tests.rs new file mode 100644 index 0000000000..bfcf768276 --- /dev/null +++ b/codex-rs/thread-store/src/local/search_threads_tests.rs @@ -0,0 +1,29 @@ +use codex_protocol::ThreadId; +use codex_rollout::ThreadItem; +use pretty_assertions::assert_eq; + +use super::ThreadSearchItem; +use super::cursor_from_thread_search_item; +use crate::ThreadSortKey; + +#[test] +fn recency_cursor_includes_thread_id_tie_breaker() { + let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000123") + .expect("thread ID should parse"); + let item = ThreadSearchItem { + item: ThreadItem { + thread_id: Some(thread_id), + recency_at: Some("2026-01-27T12:34:56Z".to_string()), + ..Default::default() + }, + snippet: String::new(), + }; + + let cursor = cursor_from_thread_search_item(&item, ThreadSortKey::RecencyAt) + .expect("cursor should build"); + + assert_eq!( + serde_json::to_string(&cursor).expect("cursor should serialize"), + format!("\"2026-01-27T12:34:56Z|{thread_id}\"") + ); +} diff --git a/codex-rs/thread-store/src/local/unarchive_thread.rs b/codex-rs/thread-store/src/local/unarchive_thread.rs index ad41db69ac..ee25b43424 100644 --- a/codex-rs/thread-store/src/local/unarchive_thread.rs +++ b/codex-rs/thread-store/src/local/unarchive_thread.rs @@ -196,5 +196,6 @@ mod tests { .expect("thread metadata should exist"); assert_eq!(updated.rollout_path, restored_path); assert_eq!(updated.archived_at, None); + assert_eq!(updated.recency_at, metadata.recency_at); } } diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index 820cb68918..d1c76ad327 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -204,6 +204,7 @@ async fn apply_metadata_update( .map_err(|err| ThreadStoreError::Internal { message: format!("failed to read thread metadata for {thread_id}: {err}"), })?; + let advance_recency_at = patch.advance_recency_at; if existing.is_none() && rollout_path.is_none() { let resolved = resolve_rollout_path(store, thread_id, include_archived).await?; rollout_path_archived = resolved.archived; @@ -260,6 +261,11 @@ async fn apply_metadata_update( if let Some(updated_at) = patch.updated_at { metadata.updated_at = updated_at; } + if existing.is_none() + && let Some(recency_at) = advance_recency_at + { + metadata.recency_at = recency_at; + } if let Some(source) = patch.source { metadata.source = enum_to_string(&source); } @@ -310,6 +316,18 @@ async fn apply_metadata_update( .map_err(|err| ThreadStoreError::Internal { message: format!("failed to update thread metadata for {thread_id}: {err}"), })?; + if existing.is_some() + && let Some(recency_at) = advance_recency_at + { + state_db + .touch_thread_recency_at(thread_id, recency_at) + .await + .map_err(|err| ThreadStoreError::Internal { + message: format!( + "failed to advance thread recency_at for {thread_id}: {err}" + ), + })?; + } if let Some(memory_mode) = patch.memory_mode { state_db .set_thread_memory_mode(thread_id, memory_mode_as_str(memory_mode)) diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index a595ba0919..b2953d7ef9 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -154,11 +154,17 @@ impl ThreadMetadataSync { let affects_metadata = items .iter() .any(codex_state::rollout_item_affects_thread_metadata); - let update = if affects_metadata { + let advances_recency = items + .iter() + .any(|item| matches!(item, RolloutItem::EventMsg(EventMsg::TurnStarted(_)))); + let mut update = if affects_metadata { self.observe_items(items)? } else { thread_updated_at_touch() }; + if advances_recency { + update.advance_recency_at = Some(Utc::now()); + } self.merge_pending_update(Some(update)); if !affects_metadata && !self @@ -347,6 +353,7 @@ fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool { || update.model.is_some() || update.reasoning_effort.is_some() || update.created_at.is_some() + || update.advance_recency_at.is_some() || update.source.is_some() || update.thread_source.is_some() || update.agent_nickname.is_some() @@ -379,6 +386,7 @@ mod tests { use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadGoalUpdatedEvent; + use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use pretty_assertions::assert_eq; @@ -495,6 +503,27 @@ mod tests { ); } + #[test] + fn turn_start_advances_recency_at_without_changing_updated_at_behavior() { + let thread_id = ThreadId::new(); + let mut sync = ThreadMetadataSync::for_resume(&resume_params(thread_id, Vec::new())); + + let update = sync + .observe_appended_items(&[RolloutItem::EventMsg(EventMsg::TurnStarted( + TurnStartedEvent { + turn_id: "turn-1".to_string(), + trace_id: None, + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }, + ))]) + .expect("turn start metadata update"); + + assert!(update.patch.updated_at.is_some()); + assert!(update.patch.advance_recency_at.is_some()); + } + #[test] fn resume_history_waits_for_append_before_flushing_metadata() { let thread_id = ThreadId::new(); diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index bb8bf2f6dc..b969c6b957 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -160,6 +160,8 @@ pub enum ThreadSortKey { CreatedAt, /// Sort by the thread last-update timestamp. UpdatedAt, + /// Sort by the thread's product recency timestamp. + RecencyAt, } /// The direction to use when listing stored threads. @@ -397,6 +399,8 @@ pub struct StoredThread { pub created_at: DateTime, /// Thread last-update timestamp. pub updated_at: DateTime, + /// Thread product-recency timestamp. + pub recency_at: DateTime, /// Thread archive timestamp, if archived. pub archived_at: Option>, /// Working directory captured for the thread. @@ -504,6 +508,8 @@ pub struct ThreadMetadataPatch { pub created_at: Option>, /// Last update timestamp for this metadata observation. pub updated_at: Option>, + /// Advance product recency to at least this timestamp. + pub advance_recency_at: Option>, /// Session source. pub source: Option, /// Optional analytics source classification. @@ -586,6 +592,9 @@ impl ThreadMetadataPatch { if next.updated_at.is_some() { self.updated_at = next.updated_at; } + if next.advance_recency_at.is_some() { + self.advance_recency_at = next.advance_recency_at; + } if next.source.is_some() { self.source = next.source; } @@ -639,6 +648,7 @@ impl ThreadMetadataPatch { && self.reasoning_effort.is_none() && self.created_at.is_none() && self.updated_at.is_none() + && self.advance_recency_at.is_none() && self.source.is_none() && self.thread_source.is_none() && self.agent_nickname.is_none() diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index aba08f08d9..6bd260c0b6 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -136,6 +136,7 @@ mod tests { model_provider: "openai".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 6a88c4ccf0..0d4ef9ee3d 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2881,6 +2881,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re model_provider: "agent-provider".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: Some(rollout_path.clone()), cwd: test_path_buf("/tmp/agent").abs(), @@ -2973,6 +2974,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ model_provider: "agent-provider".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/agent").abs(), @@ -3032,6 +3034,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { model_provider: "read-provider".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/read").abs(), @@ -5644,6 +5647,7 @@ async fn thread_rollback_response_discards_queued_active_thread_events() { model_provider: "openai".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/project").abs(), diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index bcc172ef93..ceafb71db9 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -417,6 +417,7 @@ mod tests { model_provider: "read-provider".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/read").abs(), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 5e36fb1c9d..4049ca14a6 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -2319,6 +2319,7 @@ mod tests { model_provider: "openai".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/project").abs(), diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 18313bcca6..08191eaf2b 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -608,7 +608,7 @@ fn spawn_app_server_page_loader( fn sort_key_label(sort_key: ThreadSortKey) -> &'static str { match sort_key { ThreadSortKey::CreatedAt => "Created", - ThreadSortKey::UpdatedAt => "Updated", + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => "Updated", } } @@ -1614,7 +1614,7 @@ impl PickerState { fn toggle_sort_key(&mut self) { self.sort_key = match self.sort_key { ThreadSortKey::CreatedAt => ThreadSortKey::UpdatedAt, - ThreadSortKey::UpdatedAt => ThreadSortKey::CreatedAt, + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => ThreadSortKey::CreatedAt, }; self.start_initial_load(); } @@ -2613,7 +2613,7 @@ fn render_dense_session_lines( let updated = format_relative_time(reference, row.updated_at.or(row.created_at)); let date = match state.sort_key { ThreadSortKey::CreatedAt => created, - ThreadSortKey::UpdatedAt => updated, + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => updated, }; let mut lines = vec![dense_summary_line(DenseSummaryInput { marker, @@ -2742,7 +2742,7 @@ fn render_footer_lines( ) -> Vec> { let date = match sort_key { ThreadSortKey::CreatedAt => created, - ThreadSortKey::UpdatedAt => updated, + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => updated, }; let mut parts = vec![FooterPart::Date(date.to_string())]; if show_cwd { @@ -5728,6 +5728,7 @@ session_picker_view = "dense" model_provider: String::from("openai"), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), @@ -5763,6 +5764,7 @@ session_picker_view = "dense" model_provider: String::from("openai"), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), @@ -5832,6 +5834,7 @@ session_picker_view = "dense" model_provider: String::from("openai"), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), @@ -5890,6 +5893,7 @@ session_picker_view = "dense" model_provider: String::from("openai"), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), From b668abb4888c209538df355f3deff7bc0201dbda Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Wed, 17 Jun 2026 11:57:19 -0700 Subject: [PATCH 37/70] Extract TUI plugin catalog rendering (#28768) This mechanically extracts the existing TUI plugin catalog and detail popup rendering from `chatwidget/plugins.rs` into a new `chatwidget/plugin_catalog.rs` module. `plugins.rs` now keeps the stateful plugin workflow and orchestration, while `plugin_catalog.rs` owns the presentation-heavy catalog/detail popup construction and its pure helpers. The goal is to keep `plugins.rs` focused before later plugin sharing work adds more catalog behavior. - Moves existing catalog/detail popup builders and related pure helpers into `plugin_catalog.rs` - Leaves plugin fetch/state/key handling in `plugins.rs` - Adds only minimal sibling-module visibility/import wiring - Intentionally makes no product behavior or UI changes beyond the code move --- codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/plugin_catalog.rs | 1425 +++++++++++++++++ codex-rs/tui/src/chatwidget/plugins.rs | 1418 +--------------- 3 files changed, 1438 insertions(+), 1406 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/plugin_catalog.rs diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 60973c13d2..ab6d493d49 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -365,6 +365,7 @@ use self::skills::collect_tool_mentions; use self::skills::find_app_mentions; use self::skills::find_skill_mentions_with_tool_mentions; use self::skills::is_app_mentionable; +mod plugin_catalog; mod plugins; use self::plugins::PluginInstallAuthFlowState; use self::plugins::PluginListFetchState; diff --git a/codex-rs/tui/src/chatwidget/plugin_catalog.rs b/codex-rs/tui/src/chatwidget/plugin_catalog.rs new file mode 100644 index 0000000000..c51e2e8ea9 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/plugin_catalog.rs @@ -0,0 +1,1425 @@ +use std::path::Path; +use std::time::Duration; +use std::time::Instant; + +use super::ChatWidget; +use super::plugins::ADD_MARKETPLACE_TAB_ID; +use super::plugins::ALL_PLUGINS_TAB_ID; +use super::plugins::PLUGINS_SELECTION_VIEW_ID; +use super::plugins::PluginsCacheState; +use crate::app_event::AppEvent; +use crate::app_event::PluginLocation; +use crate::bottom_pane::ColumnWidthMode; +use crate::bottom_pane::SelectionAction; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionRowDisplay; +use crate::bottom_pane::SelectionTab; +use crate::bottom_pane::SelectionToggle; +use crate::bottom_pane::SelectionViewParams; +use crate::key_hint; +use crate::legacy_core::config::Config; +use crate::motion::MotionMode; +use crate::motion::shimmer_text; +use crate::onboarding::mark_url_hyperlink; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::tui::FrameRequester; +use codex_app_server_protocol::PluginAvailability; +use codex_app_server_protocol::PluginDetail; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginMarketplaceEntry; +use codex_app_server_protocol::PluginSource; +use codex_app_server_protocol::PluginSummary; +use codex_core_plugins::is_openai_curated_marketplace_name; +use codex_core_plugins::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME; +use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME; +use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME; +use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME; +use crossterm::event::KeyCode; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::prelude::Widget; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; +use unicode_width::UnicodeWidthStr; + +const INSTALLED_PLUGINS_TAB_ID: &str = "installed-plugins"; +const MARKETPLACE_TAB_ID_PREFIX: &str = "marketplace:"; +const OPENAI_CURATED_TAB_ID: &str = "marketplace:openai-curated"; +const PLUGIN_ROW_PREFIX_WIDTH: usize = 6; +const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); +const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); +const APPS_HELP_ARTICLE_URL: &str = "https://help.openai.com/en/articles/11487775-apps-in-chatgpt"; + +struct DelayedLoadingHeader { + started_at: Instant, + frame_requester: FrameRequester, + animations_enabled: bool, + loading_text: String, + note: Option, +} + +impl DelayedLoadingHeader { + fn new( + frame_requester: FrameRequester, + animations_enabled: bool, + loading_text: String, + note: Option, + ) -> Self { + Self { + started_at: Instant::now(), + frame_requester, + animations_enabled, + loading_text, + note, + } + } +} + +impl Renderable for DelayedLoadingHeader { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + + let mut lines = Vec::with_capacity(3); + lines.push(Line::from("Plugins".bold())); + + let now = Instant::now(); + let elapsed = now.saturating_duration_since(self.started_at); + if elapsed < LOADING_ANIMATION_DELAY { + self.frame_requester + .schedule_frame_in(LOADING_ANIMATION_DELAY - elapsed); + lines.push(Line::from(self.loading_text.as_str().dim())); + } else if self.animations_enabled { + self.frame_requester + .schedule_frame_in(LOADING_ANIMATION_INTERVAL); + lines.push(Line::from(shimmer_text( + self.loading_text.as_str(), + MotionMode::Animated, + ))); + } else { + lines.push(Line::from(self.loading_text.as_str().dim())); + } + + if let Some(note) = &self.note { + lines.push(Line::from(note.as_str().dim())); + } + + Paragraph::new(lines).render_ref(area, buf); + } + + fn desired_height(&self, _width: u16) -> u16 { + 2 + u16::from(self.note.is_some()) + } +} + +struct PluginDisclosureLine { + line: Line<'static>, +} + +impl Renderable for PluginDisclosureLine { + fn render(&self, area: Rect, buf: &mut Buffer) { + Paragraph::new(self.line.clone()) + .wrap(Wrap { trim: false }) + .render(area, buf); + mark_url_hyperlink(buf, area, APPS_HELP_ARTICLE_URL); + } + + fn desired_height(&self, width: u16) -> u16 { + Paragraph::new(self.line.clone()) + .wrap(Wrap { trim: false }) + .line_count(width) + .try_into() + .unwrap_or(u16::MAX) + } +} + +impl ChatWidget { + pub(super) fn plugins_loading_popup_params(&self) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + "Loading available plugins...".to_string(), + Some("This updates when the marketplace list is ready.".to_string()), + )), + items: vec![SelectionItem { + name: "Loading plugins...".to_string(), + description: Some("This updates when the marketplace list is ready.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn marketplace_add_loading_popup_params(&self) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + "Adding marketplace...".to_string(), + /*note*/ None, + )), + items: vec![SelectionItem { + name: "Adding marketplace...".to_string(), + description: Some( + "This updates when marketplace installation completes.".to_string(), + ), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn marketplace_remove_confirmation_popup_params( + &self, + plugins_response: &PluginListResponse, + marketplace_name: String, + marketplace_display_name: String, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Remove {marketplace_display_name} marketplace?").dim(), + )); + header.push(Line::from( + "This removes the configured marketplace from Codex.".dim(), + )); + + let cwd_for_remove = self.config.cwd.to_path_buf(); + let cwd_for_cancel = self.config.cwd.to_path_buf(); + let cwd_for_on_cancel = self.config.cwd.to_path_buf(); + let plugins_response_for_cancel = plugins_response.clone(); + let plugins_response_for_on_cancel = plugins_response.clone(); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(Line::from(vec![ + Span::from(key_hint::plain(KeyCode::Enter)), + " select".dim(), + " · ".into(), + "esc close".dim(), + ])), + items: vec![ + SelectionItem { + name: "Remove marketplace".to_string(), + description: Some( + "Remove this marketplace from the available plugin list.".to_string(), + ), + selected_description: Some( + "Remove this marketplace from the available plugin list.".to_string(), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenMarketplaceRemoveLoading { + marketplace_display_name: marketplace_display_name.clone(), + }); + tx.send(AppEvent::FetchMarketplaceRemove { + cwd: cwd_for_remove.clone(), + marketplace_name: marketplace_name.clone(), + marketplace_display_name: marketplace_display_name.clone(), + }); + })], + ..Default::default() + }, + SelectionItem { + name: "Back to plugins".to_string(), + description: Some("Keep this marketplace installed.".to_string()), + selected_description: Some("Keep this marketplace installed.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::PluginsLoaded { + cwd: cwd_for_cancel.clone(), + result: Ok(plugins_response_for_cancel.clone()), + }); + })], + ..Default::default() + }, + ], + on_cancel: Some(Box::new(move |tx| { + tx.send(AppEvent::PluginsLoaded { + cwd: cwd_for_on_cancel.clone(), + result: Ok(plugins_response_for_on_cancel.clone()), + }); + })), + ..Default::default() + } + } + + pub(super) fn marketplace_remove_loading_popup_params( + &self, + marketplace_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Removing {marketplace_display_name}...").dim(), + )); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Removing marketplace...".to_string(), + description: Some("This updates when marketplace removal completes.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn marketplace_upgrade_loading_popup_params( + &self, + marketplace_name: Option<&str>, + ) -> SelectionViewParams { + let loading_text = marketplace_name + .map(|name| format!("Upgrading {name} marketplace...")) + .unwrap_or_else(|| "Upgrading marketplaces...".to_string()); + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + loading_text.clone(), + /*note*/ None, + )), + items: vec![SelectionItem { + name: loading_text, + description: Some("This updates when marketplace upgrade completes.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn plugin_detail_loading_popup_params( + &self, + plugin_display_name: &str, + ) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + format!("Loading details for {plugin_display_name}..."), + /*note*/ None, + )), + items: vec![SelectionItem { + name: "Loading plugin details...".to_string(), + description: Some("This updates when plugin details load.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn plugin_install_loading_popup_params( + &self, + plugin_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Installing {plugin_display_name}...").dim(), + )); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Installing plugin...".to_string(), + description: Some("This updates when plugin installation completes.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn plugin_uninstall_loading_popup_params( + &self, + plugin_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Uninstalling {plugin_display_name}...").dim(), + )); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Uninstalling plugin...".to_string(), + description: Some("This updates when the plugin removal completes.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn plugins_error_popup_params(&self, err: &str) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from("Failed to load plugins.".dim())); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Plugin marketplace unavailable".to_string(), + description: Some(err.to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn marketplace_add_error_popup_params(&self) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from("Failed to add marketplace.".dim())); + + let mut items = vec![ + SelectionItem { + name: "Marketplace add failed".to_string(), + description: Some( + "Failed to add marketplace from the provided source.".to_string(), + ), + is_disabled: true, + ..Default::default() + }, + SelectionItem { + name: "Try again".to_string(), + description: Some("Enter a marketplace source.".to_string()), + selected_description: Some("Enter a marketplace source.".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenMarketplaceAddPrompt); + })], + ..Default::default() + }, + ]; + + if let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd() { + let cwd = self.config.cwd.to_path_buf(); + items.push(SelectionItem { + name: "Back to plugins".to_string(), + description: Some("Return to the plugin list.".to_string()), + selected_description: Some("Return to the plugin list.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::PluginsLoaded { + cwd: cwd.clone(), + result: Ok(plugins_response.clone()), + }); + })], + ..Default::default() + }); + } + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(plugin_detail_hint_line()), + items, + ..Default::default() + } + } + + pub(super) fn marketplace_remove_error_popup_params( + &self, + marketplace_name: &str, + marketplace_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from("Failed to remove marketplace.".dim())); + + let marketplace_name = marketplace_name.to_string(); + let marketplace_display_name = marketplace_display_name.to_string(); + let mut items = vec![ + SelectionItem { + name: "Marketplace removal failed".to_string(), + description: Some("Failed to remove the selected marketplace.".to_string()), + is_disabled: true, + ..Default::default() + }, + SelectionItem { + name: "Try again".to_string(), + description: Some("Review the confirmation prompt again.".to_string()), + selected_description: Some("Review the confirmation prompt again.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenMarketplaceRemoveConfirm { + marketplace_name: marketplace_name.clone(), + marketplace_display_name: marketplace_display_name.clone(), + }); + })], + ..Default::default() + }, + ]; + + if let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd() { + let cwd = self.config.cwd.to_path_buf(); + items.push(SelectionItem { + name: "Back to plugins".to_string(), + description: Some("Return to the plugin list.".to_string()), + selected_description: Some("Return to the plugin list.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::PluginsLoaded { + cwd: cwd.clone(), + result: Ok(plugins_response.clone()), + }); + })], + ..Default::default() + }); + } + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(plugin_detail_hint_line()), + items, + ..Default::default() + } + } + + pub(super) fn plugin_detail_error_popup_params( + &self, + err: &str, + plugins_response: Option<&PluginListResponse>, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from("Failed to load plugin details.".dim())); + + let mut items = vec![SelectionItem { + name: "Plugin detail unavailable".to_string(), + description: Some(err.to_string()), + is_disabled: true, + ..Default::default() + }]; + if let Some(plugins_response) = plugins_response.cloned() { + let cwd = self.config.cwd.to_path_buf(); + items.push(SelectionItem { + name: "Back to plugins".to_string(), + description: Some("Return to the plugin list.".to_string()), + selected_description: Some("Return to the plugin list.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::PluginsLoaded { + cwd: cwd.clone(), + result: Ok(plugins_response.clone()), + }); + })], + ..Default::default() + }); + } + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(plugin_detail_hint_line()), + items, + ..Default::default() + } + } + + pub(super) fn plugins_popup_params( + &self, + response: &PluginListResponse, + active_tab_id: Option, + initial_selected_idx: Option, + ) -> SelectionViewParams { + let marketplaces: Vec<&PluginMarketplaceEntry> = response.marketplaces.iter().collect(); + + let total: usize = marketplaces + .iter() + .map(|marketplace| marketplace.plugins.len()) + .sum(); + let installed = marketplaces + .iter() + .flat_map(|marketplace| marketplace.plugins.iter()) + .filter(|plugin| plugin.installed) + .count(); + + let all_entries = plugin_entries_for_marketplaces(marketplaces.iter().copied()); + let name_column_width = all_entries + .iter() + .map(|(_, _, display_name)| { + PLUGIN_ROW_PREFIX_WIDTH + UnicodeWidthStr::width(display_name.as_str()) + }) + .chain([UnicodeWidthStr::width("Add marketplace")]) + .max(); + let installed_entries = all_entries + .iter() + .filter(|(_, plugin, _)| plugin.installed) + .cloned() + .collect(); + + let mut tabs = Vec::new(); + let mut tab_footer_hints = Vec::new(); + tabs.push(SelectionTab { + id: ALL_PLUGINS_TAB_ID.to_string(), + label: "All Plugins".to_string(), + header: plugins_header( + "Browse plugins from available marketplaces.".to_string(), + format!("Installed {installed} of {total} available plugins."), + ), + items: self.plugin_selection_items( + all_entries, + /*include_marketplace_names*/ true, + "No marketplace plugins available", + "No plugins are available in the discovered marketplaces.", + ), + }); + + tabs.push(SelectionTab { + id: INSTALLED_PLUGINS_TAB_ID.to_string(), + label: format!("Installed ({installed})"), + header: plugins_header( + "Installed plugins.".to_string(), + format!("Showing {installed} installed plugins."), + ), + items: self.plugin_selection_items( + installed_entries, + /*include_marketplace_names*/ true, + "No installed plugins", + "No installed plugins.", + ), + }); + + let curated_marketplace = marketplaces + .iter() + .find(|marketplace| is_openai_curated_marketplace_name(&marketplace.name)) + .copied(); + let curated_entries = curated_marketplace + .map(|marketplace| plugin_entries_for_marketplaces([marketplace])) + .unwrap_or_default(); + let curated_total = curated_entries.len(); + let curated_installed = curated_entries + .iter() + .filter(|(_, plugin, _)| plugin.installed) + .count(); + tabs.push(SelectionTab { + id: OPENAI_CURATED_TAB_ID.to_string(), + label: "OpenAI Curated".to_string(), + header: plugins_header( + "OpenAI Curated marketplace.".to_string(), + format!("Installed {curated_installed} of {curated_total} OpenAI Curated plugins."), + ), + items: self.plugin_selection_items( + curated_entries, + /*include_marketplace_names*/ false, + "No OpenAI Curated plugins available", + "No OpenAI Curated plugins available.", + ), + }); + + let mut additional_marketplaces: Vec<&PluginMarketplaceEntry> = marketplaces + .iter() + .copied() + .filter(|marketplace| !is_openai_curated_marketplace_name(&marketplace.name)) + .collect(); + additional_marketplaces.sort_by(|left, right| { + marketplace_display_name(left) + .to_ascii_lowercase() + .cmp(&marketplace_display_name(right).to_ascii_lowercase()) + .then_with(|| marketplace_display_name(left).cmp(&marketplace_display_name(right))) + .then_with(|| left.name.cmp(&right.name)) + }); + + let labels = disambiguate_duplicate_tab_labels( + additional_marketplaces + .iter() + .map(|marketplace| marketplace_display_name(marketplace)) + .collect(), + ); + for (marketplace, label) in additional_marketplaces.into_iter().zip(labels) { + let entries = plugin_entries_for_marketplaces([marketplace]); + let marketplace_total = entries.len(); + let marketplace_installed = entries + .iter() + .filter(|(_, plugin, _)| plugin.installed) + .count(); + let tab_id = marketplace_tab_id(marketplace); + let can_remove_marketplace = + marketplace_is_user_configured(&self.config, &marketplace.name); + let can_upgrade_marketplace = marketplace.path.is_some() + && marketplace_is_user_configured_git(&self.config, &marketplace.name); + if can_remove_marketplace || can_upgrade_marketplace { + tab_footer_hints.push(( + tab_id.clone(), + plugins_popup_hint_line( + /*can_remove_marketplace*/ can_remove_marketplace, + /*can_upgrade_marketplace*/ can_upgrade_marketplace, + ), + )); + } + let header = if self.newly_installed_marketplace_tab_id.as_deref() == Some(&tab_id) { + plugins_header( + format!("{label} installed successfully."), + "Select the plugins you want to use and press Enter to install or view details." + .to_string(), + ) + } else { + plugins_header( + format!("{label}."), + format!( + "Installed {marketplace_installed} of {marketplace_total} {label} plugins." + ), + ) + }; + tabs.push(SelectionTab { + id: tab_id, + label: label.clone(), + header, + items: self.plugin_selection_items( + entries, + /*include_marketplace_names*/ false, + "No plugins available in this marketplace", + "No plugins available in this marketplace.", + ), + }); + } + + tabs.push(self.marketplace_add_tab()); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(()), + footer_hint: Some(plugins_popup_hint_line( + /*can_remove_marketplace*/ false, /*can_upgrade_marketplace*/ false, + )), + tab_footer_hints, + tabs, + initial_tab_id: active_tab_id, + is_searchable: true, + search_placeholder: Some("Type to search plugins".to_string()), + col_width_mode: ColumnWidthMode::AutoAllRows, + row_display: SelectionRowDisplay::SingleLine, + name_column_width, + initial_selected_idx, + ..Default::default() + } + } + + fn marketplace_add_tab(&self) -> SelectionTab { + SelectionTab { + id: ADD_MARKETPLACE_TAB_ID.to_string(), + label: "Add Marketplace".to_string(), + header: plugins_header( + "Add a marketplace from a Git repo or local root.".to_string(), + "Enter a source to make its plugins available in this menu.".to_string(), + ), + items: vec![SelectionItem { + name: "Add marketplace".to_string(), + description: Some( + "Enter owner/repo, a Git URL, or a local marketplace path.".to_string(), + ), + selected_description: Some( + "Press Enter to enter a marketplace source.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenMarketplaceAddPrompt); + })], + ..Default::default() + }], + } + } + + pub(super) fn plugin_detail_popup_params( + &self, + plugins_response: &PluginListResponse, + plugin: &PluginDetail, + ) -> SelectionViewParams { + let marketplace_label = plugin.marketplace_name.clone(); + let display_name = plugin_display_name(&plugin.summary); + let detail_status_label = + if plugin.summary.availability == PluginAvailability::DisabledByAdmin { + "Disabled by admin" + } else if plugin.summary.installed { + if plugin.summary.enabled { + "Installed" + } else { + "Disabled" + } + } else { + match plugin.summary.install_policy { + PluginInstallPolicy::NotAvailable => "Not installable", + PluginInstallPolicy::Available => "Can be installed", + PluginInstallPolicy::InstalledByDefault => "Available by default", + } + }; + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("{display_name} · {detail_status_label} · {marketplace_label}").bold(), + )); + if !plugin.summary.installed { + header.push(PluginDisclosureLine { + line: Line::from(vec![ + "Data shared with this app is subject to the app's ".into(), + "terms of service".bold(), + " and ".into(), + "privacy policy".bold(), + ". ".into(), + "Learn more".cyan().underlined(), + ".".into(), + ]), + }); + } + if let Some(description) = plugin_detail_description(plugin) { + header.push(Line::from(description.dim())); + } + + let cwd = self.config.cwd.to_path_buf(); + let plugins_response = plugins_response.clone(); + let mut items = vec![SelectionItem { + name: "Back to plugins".to_string(), + description: Some("Return to the plugin list.".to_string()), + selected_description: Some("Return to the plugin list.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::PluginsLoaded { + cwd: cwd.clone(), + result: Ok(plugins_response.clone()), + }); + })], + ..Default::default() + }]; + + if plugin.summary.installed { + if let Some(plugin_id) = plugin_uninstall_id(&plugin.summary) { + let uninstall_cwd = self.config.cwd.to_path_buf(); + let plugin_display_name = display_name; + items.push(SelectionItem { + name: "Uninstall plugin".to_string(), + description: Some("Remove this plugin now.".to_string()), + selected_description: Some("Remove this plugin now.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenPluginUninstallLoading { + plugin_display_name: plugin_display_name.clone(), + }); + tx.send(AppEvent::FetchPluginUninstall { + cwd: uninstall_cwd.clone(), + plugin_id: plugin_id.clone(), + plugin_display_name: plugin_display_name.clone(), + }); + })], + ..Default::default() + }); + } else { + items.push(SelectionItem { + name: "Uninstall plugin".to_string(), + description: Some( + "This remote plugin did not provide an uninstall identity.".to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } + } else if plugin.summary.availability == PluginAvailability::DisabledByAdmin { + items.push(SelectionItem { + name: "Install plugin".to_string(), + description: Some("This plugin is disabled by your workspace admin.".to_string()), + is_disabled: true, + ..Default::default() + }); + } else if plugin.summary.install_policy == PluginInstallPolicy::NotAvailable { + items.push(SelectionItem { + name: "Install plugin".to_string(), + description: Some( + "This plugin is not installable from this marketplace.".to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } else if let Some(location) = plugin_detail_location(plugin) { + let install_cwd = self.config.cwd.to_path_buf(); + let plugin_name = plugin_request_name(&plugin.summary); + let plugin_display_name = display_name; + items.push(SelectionItem { + name: "Install plugin".to_string(), + description: Some("Install this plugin now.".to_string()), + selected_description: Some("Install this plugin now.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenPluginInstallLoading { + plugin_display_name: plugin_display_name.clone(), + }); + tx.send(AppEvent::FetchPluginInstall { + cwd: install_cwd.clone(), + location: location.clone(), + plugin_name: plugin_name.clone(), + plugin_display_name: plugin_display_name.clone(), + }); + })], + ..Default::default() + }); + } else { + items.push(SelectionItem { + name: "Install plugin".to_string(), + description: Some("This plugin did not provide an install location.".to_string()), + is_disabled: true, + ..Default::default() + }); + } + + items.push(SelectionItem { + name: "Skills".to_string(), + description: Some(plugin_skill_summary(plugin)), + is_disabled: true, + ..Default::default() + }); + items.push(SelectionItem { + name: "Hooks".to_string(), + description: Some(plugin_hook_summary(plugin)), + is_disabled: true, + ..Default::default() + }); + items.push(SelectionItem { + name: "Apps".to_string(), + description: Some(plugin_app_summary(plugin)), + is_disabled: true, + ..Default::default() + }); + items.push(SelectionItem { + name: "MCP Servers".to_string(), + description: Some(plugin_mcp_summary(plugin)), + is_disabled: true, + ..Default::default() + }); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(plugin_detail_hint_line()), + items, + col_width_mode: ColumnWidthMode::AutoAllRows, + ..Default::default() + } + } + + fn plugin_selection_items<'a>( + &self, + mut plugin_entries: Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)>, + include_marketplace_names: bool, + empty_name: &str, + empty_description: &str, + ) -> Vec { + sort_plugin_entries(&mut plugin_entries); + let status_label_width = plugin_entries + .iter() + .map(|(_, plugin, _)| plugin_status_label(plugin).chars().count()) + .max() + .unwrap_or(0); + + let mut items: Vec = Vec::new(); + for (marketplace, plugin, display_name) in plugin_entries { + let marketplace_label = marketplace_display_name(marketplace); + let status_label = plugin_status_label(plugin); + let description = if include_marketplace_names { + plugin_brief_description(plugin, &marketplace_label, status_label_width) + } else { + plugin_brief_description_without_marketplace(plugin, status_label_width) + }; + let plugin_detail_request = plugin_detail_request_for_entry(marketplace, plugin); + let can_view_details = plugin_detail_request.is_some(); + let disabled_by_admin = plugin.availability == PluginAvailability::DisabledByAdmin; + let can_toggle_plugin = plugin.installed && !disabled_by_admin; + let selected_status_label = format!("{status_label: = + if let Some((location, plugin_name)) = plugin_detail_request { + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenPluginDetailLoading { + plugin_display_name: plugin_display_name.clone(), + }); + let (marketplace_path, remote_marketplace_name) = + location.clone().into_request_params(); + tx.send(AppEvent::FetchPluginDetail { + cwd: cwd.clone(), + params: codex_app_server_protocol::PluginReadParams { + marketplace_path, + remote_marketplace_name, + plugin_name: plugin_name.clone(), + }, + }); + })] + } else { + Vec::new() + }; + let is_disabled = !can_view_details && !plugin.installed; + let disabled_reason = is_disabled.then(|| "plugin details are unavailable".to_string()); + + items.push(SelectionItem { + name: display_name, + toggle, + toggle_placeholder: (!can_toggle_plugin).then_some("[-] "), + description: Some(description), + selected_description: Some(selected_description), + search_value: Some(search_value), + actions, + is_disabled, + disabled_reason, + ..Default::default() + }); + } + + if items.is_empty() { + items.push(SelectionItem { + name: empty_name.to_string(), + description: Some(empty_description.to_string()), + is_disabled: true, + ..Default::default() + }); + } + items + } +} + +fn plugins_popup_hint_line( + can_remove_marketplace: bool, + can_upgrade_marketplace: bool, +) -> Line<'static> { + match (can_remove_marketplace, can_upgrade_marketplace) { + (true, true) => Line::from( + "ctrl + u upgrade · ctrl + r remove · space toggle · ←/→ tabs · enter details · esc close", + ), + (true, false) => { + Line::from("ctrl + r remove · space toggle · ←/→ tabs · enter details · esc close") + } + (false, true) => { + Line::from("ctrl + u upgrade · space toggle · ←/→ tabs · enter details · esc close") + } + (false, false) => Line::from( + "space enable/disable · ←/→ select marketplace · enter view details · esc close", + ), + } +} + +pub(super) fn plugin_detail_hint_line() -> Line<'static> { + Line::from("Press esc to close.") +} + +fn plugins_header(subtitle: String, count_line: String) -> Box { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from(subtitle.dim())); + header.push(Line::from(count_line.dim())); + Box::new(header) +} + +fn plugin_entries_for_marketplaces<'a>( + marketplaces: impl IntoIterator, +) -> Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)> { + marketplaces + .into_iter() + .flat_map(|marketplace| { + marketplace + .plugins + .iter() + .map(move |plugin| (marketplace, plugin, plugin_display_name(plugin))) + }) + .collect() +} + +fn sort_plugin_entries(entries: &mut [(&PluginMarketplaceEntry, &PluginSummary, String)]) { + entries.sort_by(|left, right| { + right + .1 + .installed + .cmp(&left.1.installed) + .then_with(|| { + left.2 + .to_ascii_lowercase() + .cmp(&right.2.to_ascii_lowercase()) + }) + .then_with(|| left.2.cmp(&right.2)) + .then_with(|| left.1.name.cmp(&right.1.name)) + .then_with(|| left.1.id.cmp(&right.1.id)) + }); +} + +pub(super) fn marketplace_tab_id(marketplace: &PluginMarketplaceEntry) -> String { + match marketplace.path.as_ref() { + Some(path) => marketplace_tab_id_from_path(path.as_path()), + None => format!("marketplace:{}", marketplace.name), + } +} + +pub(super) fn marketplace_tab_id_from_path(path: &Path) -> String { + format!("{MARKETPLACE_TAB_ID_PREFIX}{}", path.display()) +} + +pub(super) fn marketplace_tab_id_matching_saved_id( + saved_tab_id: &str, + marketplaces: &[PluginMarketplaceEntry], +) -> Option { + if let Some(tab_id) = marketplaces.iter().find_map(|marketplace| { + let tab_id = marketplace_tab_id(marketplace); + (tab_id == saved_tab_id).then_some(tab_id) + }) { + return Some(tab_id); + } + + let root = saved_tab_id.strip_prefix(MARKETPLACE_TAB_ID_PREFIX)?; + if root.is_empty() { + return None; + } + let root = Path::new(root); + marketplaces.iter().find_map(|marketplace| { + marketplace + .path + .as_ref() + .is_some_and(|path| path.as_path().starts_with(root)) + .then(|| marketplace_tab_id(marketplace)) + }) +} + +pub(super) fn merge_remote_marketplaces( + response: &mut PluginListResponse, + remote_marketplaces: Vec, +) { + let remote_names = remote_marketplaces + .iter() + .map(|marketplace| marketplace.name.clone()) + .collect::>(); + response.marketplaces.retain(|marketplace| { + marketplace.path.is_some() + || !remote_marketplace_is_remote_section(marketplace) + && !remote_names.contains(marketplace.name.as_str()) + }); + response.marketplaces.extend(remote_marketplaces); +} + +fn remote_marketplace_is_remote_section(marketplace: &PluginMarketplaceEntry) -> bool { + matches!( + marketplace.name.as_str(), + REMOTE_WORKSPACE_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME + ) +} + +fn disambiguate_duplicate_tab_labels(labels: Vec) -> Vec { + let mut counts: Vec<(String, usize)> = Vec::new(); + for label in &labels { + if let Some((_, count)) = counts.iter_mut().find(|(existing, _)| existing == label) { + *count += 1; + } else { + counts.push((label.clone(), 1)); + } + } + + let mut seen: Vec<(String, usize)> = Vec::new(); + labels + .into_iter() + .map(|label| { + let total = counts + .iter() + .find(|(existing, _)| existing == &label) + .map(|(_, count)| *count) + .unwrap_or(1); + if total == 1 { + return label; + } + + let current = if let Some((_, seen_count)) = + seen.iter_mut().find(|(existing, _)| existing == &label) + { + *seen_count += 1; + *seen_count + } else { + seen.push((label.clone(), 1)); + 1 + }; + format!("{label} ({current}/{total})") + }) + .collect() +} + +pub(super) fn marketplace_display_name(marketplace: &PluginMarketplaceEntry) -> String { + marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()) + .map(str::trim) + .filter(|display_name| !display_name.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| marketplace.name.clone()) +} + +pub(super) fn marketplace_is_user_configured(config: &Config, marketplace_name: &str) -> bool { + let Some(user_config) = config.config_layer_stack.effective_user_config() else { + return false; + }; + user_config + .get("marketplaces") + .and_then(toml::Value::as_table) + .is_some_and(|marketplaces| marketplaces.contains_key(marketplace_name)) +} + +pub(super) fn marketplace_is_user_configured_git(config: &Config, marketplace_name: &str) -> bool { + config + .config_layer_stack + .get_active_user_layer() + .and_then(|user_layer| user_layer.config.get("marketplaces")) + .and_then(toml::Value::as_table) + .and_then(|marketplaces| marketplaces.get(marketplace_name)) + .and_then(toml::Value::as_table) + .and_then(|marketplace| marketplace.get("source_type")) + .and_then(toml::Value::as_str) + .is_some_and(|source_type| source_type == "git") +} + +fn plugin_display_name(plugin: &PluginSummary) -> String { + plugin + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()) + .map(str::trim) + .filter(|display_name| !display_name.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| plugin.name.clone()) +} + +fn plugin_brief_description( + plugin: &PluginSummary, + marketplace_label: &str, + status_label_width: usize, +) -> String { + let status_label = plugin_status_label(plugin); + let status_label = format!("{status_label: format!("{status_label} · {marketplace_label} · {description}"), + None => format!("{status_label} · {marketplace_label}"), + } +} + +fn plugin_brief_description_without_marketplace( + plugin: &PluginSummary, + status_label_width: usize, +) -> String { + let status_label = plugin_status_label(plugin); + let status_label = format!("{status_label: format!("{status_label} · {description}"), + None => status_label, + } +} + +fn plugin_status_label(plugin: &PluginSummary) -> &'static str { + if plugin.availability == PluginAvailability::DisabledByAdmin { + return "Disabled by admin"; + } + if plugin.installed { + if plugin.enabled { + "Installed" + } else { + "Disabled" + } + } else { + match plugin.install_policy { + PluginInstallPolicy::NotAvailable => "Not installable", + PluginInstallPolicy::Available => "Available", + PluginInstallPolicy::InstalledByDefault => "Available", + } + } +} + +fn plugin_location_for_marketplace( + marketplace: &PluginMarketplaceEntry, + plugin: &PluginSummary, +) -> Option { + if let Some(marketplace_path) = marketplace.path.clone() { + return Some(PluginLocation::Local { marketplace_path }); + } + plugin_remote_identity(plugin).map(|_| PluginLocation::Remote { + marketplace_name: marketplace.name.clone(), + }) +} + +fn plugin_detail_location(plugin: &PluginDetail) -> Option { + if let Some(marketplace_path) = plugin.marketplace_path.clone() { + return Some(PluginLocation::Local { marketplace_path }); + } + plugin_remote_identity(&plugin.summary).map(|_| PluginLocation::Remote { + marketplace_name: plugin.marketplace_name.clone(), + }) +} + +fn plugin_detail_request_for_entry( + marketplace: &PluginMarketplaceEntry, + plugin: &PluginSummary, +) -> Option<(PluginLocation, String)> { + plugin_location_for_marketplace(marketplace, plugin) + .map(|location| (location, plugin_request_name(plugin))) +} + +fn plugin_request_name(plugin: &PluginSummary) -> String { + if matches!(&plugin.source, PluginSource::Remote) + && let Some(remote_plugin_id) = plugin_remote_identity(plugin) + { + return remote_plugin_id; + } + plugin.name.clone() +} + +fn plugin_remote_identity(plugin: &PluginSummary) -> Option { + plugin + .share_context + .as_ref() + .map(|context| context.remote_plugin_id.clone()) + .or_else(|| plugin.remote_plugin_id.clone()) +} + +fn plugin_uninstall_id(plugin: &PluginSummary) -> Option { + if matches!(&plugin.source, PluginSource::Remote) { + return plugin_remote_identity(plugin); + } + Some(plugin.id.clone()) +} + +fn plugin_description(plugin: &PluginSummary) -> Option { + plugin + .interface + .as_ref() + .and_then(|interface| { + interface + .short_description + .as_deref() + .or(interface.long_description.as_deref()) + }) + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(str::to_string) +} + +fn plugin_detail_description(plugin: &PluginDetail) -> Option { + plugin + .description + .as_deref() + .or_else(|| { + plugin + .summary + .interface + .as_ref() + .and_then(|interface| interface.long_description.as_deref()) + }) + .or_else(|| { + plugin + .summary + .interface + .as_ref() + .and_then(|interface| interface.short_description.as_deref()) + }) + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(str::to_string) +} + +fn plugin_skill_summary(plugin: &PluginDetail) -> String { + if plugin.skills.is_empty() { + "No plugin skills.".to_string() + } else { + plugin + .skills + .iter() + .map(|skill| skill.name.as_str()) + .collect::>() + .join(", ") + } +} + +fn plugin_app_summary(plugin: &PluginDetail) -> String { + if plugin.apps.is_empty() { + "No plugin apps.".to_string() + } else { + plugin + .apps + .iter() + .map(|app| app.name.as_str()) + .collect::>() + .join(", ") + } +} + +fn plugin_hook_summary(plugin: &PluginDetail) -> String { + if plugin.hooks.is_empty() { + "No plugin hooks.".to_string() + } else { + let mut event_counts = Vec::<(codex_app_server_protocol::HookEventName, usize)>::new(); + for hook in &plugin.hooks { + if let Some((_, handler_count)) = event_counts + .iter_mut() + .find(|(event_name, _)| *event_name == hook.event_name) + { + *handler_count += 1; + } else { + event_counts.push((hook.event_name, 1)); + } + } + event_counts + .into_iter() + .map(|(event_name, handler_count)| format!("{event_name:?} ({handler_count})")) + .collect::>() + .join(", ") + } +} + +fn plugin_mcp_summary(plugin: &PluginDetail) -> String { + if plugin.mcp_servers.is_empty() { + "No plugin MCP servers.".to_string() + } else { + plugin.mcp_servers.join(", ") + } +} diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 5e091b48a3..8c259856fb 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -1,71 +1,42 @@ -use std::path::Path; use std::path::PathBuf; -use std::time::Duration; -use std::time::Instant; use super::ChatWidget; +use super::plugin_catalog::marketplace_display_name; +use super::plugin_catalog::marketplace_is_user_configured; +use super::plugin_catalog::marketplace_is_user_configured_git; +use super::plugin_catalog::marketplace_tab_id; +use super::plugin_catalog::marketplace_tab_id_from_path; +use super::plugin_catalog::marketplace_tab_id_matching_saved_id; +use super::plugin_catalog::merge_remote_marketplaces; +use super::plugin_catalog::plugin_detail_hint_line; use crate::app_event::AppEvent; use crate::app_event::PluginLocation; use crate::app_event::PluginRemoteSectionError; use crate::bottom_pane::ColumnWidthMode; -use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; -use crate::bottom_pane::SelectionRowDisplay; -use crate::bottom_pane::SelectionTab; -use crate::bottom_pane::SelectionToggle; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::history_cell; use crate::key_hint; -use crate::legacy_core::config::Config; -use crate::motion::MotionMode; -use crate::motion::shimmer_text; -use crate::onboarding::mark_url_hyperlink; use crate::render::renderable::ColumnRenderable; -use crate::render::renderable::Renderable; -use crate::tui::FrameRequester; use codex_app_server_protocol::MarketplaceAddResponse; use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::MarketplaceUpgradeResponse; -use codex_app_server_protocol::PluginAvailability; -use codex_app_server_protocol::PluginDetail; -use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadResponse; -use codex_app_server_protocol::PluginSource; -use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::PluginUninstallResponse; -use codex_core_plugins::is_openai_curated_marketplace_name; -use codex_core_plugins::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME; -use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME; -use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME; -use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME; use codex_features::Feature; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::prelude::Widget; use ratatui::style::Stylize; use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::widgets::Paragraph; -use ratatui::widgets::WidgetRef; -use ratatui::widgets::Wrap; -use unicode_width::UnicodeWidthStr; -const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection"; -const ALL_PLUGINS_TAB_ID: &str = "all-plugins"; -const INSTALLED_PLUGINS_TAB_ID: &str = "installed-plugins"; -const MARKETPLACE_TAB_ID_PREFIX: &str = "marketplace:"; -const OPENAI_CURATED_TAB_ID: &str = "marketplace:openai-curated"; -const ADD_MARKETPLACE_TAB_ID: &str = "add-marketplace"; -const PLUGIN_ROW_PREFIX_WIDTH: usize = 6; -const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); -const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); +pub(super) const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection"; +pub(super) const ALL_PLUGINS_TAB_ID: &str = "all-plugins"; +pub(super) const ADD_MARKETPLACE_TAB_ID: &str = "add-marketplace"; #[derive(Debug, Clone, Default)] pub(super) struct PluginListFetchState { @@ -79,92 +50,6 @@ pub(super) struct PluginInstallAuthFlowState { next_app_index: usize, } -struct DelayedLoadingHeader { - started_at: Instant, - frame_requester: FrameRequester, - animations_enabled: bool, - loading_text: String, - note: Option, -} - -impl DelayedLoadingHeader { - fn new( - frame_requester: FrameRequester, - animations_enabled: bool, - loading_text: String, - note: Option, - ) -> Self { - Self { - started_at: Instant::now(), - frame_requester, - animations_enabled, - loading_text, - note, - } - } -} - -impl Renderable for DelayedLoadingHeader { - fn render(&self, area: Rect, buf: &mut Buffer) { - if area.is_empty() { - return; - } - - let mut lines = Vec::with_capacity(3); - lines.push(Line::from("Plugins".bold())); - - let now = Instant::now(); - let elapsed = now.saturating_duration_since(self.started_at); - if elapsed < LOADING_ANIMATION_DELAY { - self.frame_requester - .schedule_frame_in(LOADING_ANIMATION_DELAY - elapsed); - lines.push(Line::from(self.loading_text.as_str().dim())); - } else if self.animations_enabled { - self.frame_requester - .schedule_frame_in(LOADING_ANIMATION_INTERVAL); - lines.push(Line::from(shimmer_text( - self.loading_text.as_str(), - MotionMode::Animated, - ))); - } else { - lines.push(Line::from(self.loading_text.as_str().dim())); - } - - if let Some(note) = &self.note { - lines.push(Line::from(note.as_str().dim())); - } - - Paragraph::new(lines).render_ref(area, buf); - } - - fn desired_height(&self, _width: u16) -> u16 { - 2 + u16::from(self.note.is_some()) - } -} - -const APPS_HELP_ARTICLE_URL: &str = "https://help.openai.com/en/articles/11487775-apps-in-chatgpt"; - -struct PluginDisclosureLine { - line: Line<'static>, -} - -impl Renderable for PluginDisclosureLine { - fn render(&self, area: Rect, buf: &mut Buffer) { - Paragraph::new(self.line.clone()) - .wrap(Wrap { trim: false }) - .render(area, buf); - mark_url_hyperlink(buf, area, APPS_HELP_ARTICLE_URL); - } - - fn desired_height(&self, width: u16) -> u16 { - Paragraph::new(self.line.clone()) - .wrap(Wrap { trim: false }) - .line_count(width) - .try_into() - .unwrap_or(u16::MAX) - } -} - #[derive(Debug, Clone, Default)] pub(super) enum PluginsCacheState { #[default] @@ -332,7 +217,7 @@ impl ChatWidget { } } - fn plugins_cache_for_current_cwd(&self) -> PluginsCacheState { + pub(super) fn plugins_cache_for_current_cwd(&self) -> PluginsCacheState { if self.plugins_fetch_state.cache_cwd.as_deref() == Some(self.config.cwd.as_path()) { self.plugins_cache.clone() } else { @@ -1095,1283 +980,4 @@ impl ChatWidget { self.plugins_popup_params(response, active_tab_id, selected_idx), ); } - - fn plugins_loading_popup_params(&self) -> SelectionViewParams { - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(DelayedLoadingHeader::new( - self.frame_requester.clone(), - self.config.animations, - "Loading available plugins...".to_string(), - Some("This updates when the marketplace list is ready.".to_string()), - )), - items: vec![SelectionItem { - name: "Loading plugins...".to_string(), - description: Some("This updates when the marketplace list is ready.".to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn marketplace_add_loading_popup_params(&self) -> SelectionViewParams { - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(DelayedLoadingHeader::new( - self.frame_requester.clone(), - self.config.animations, - "Adding marketplace...".to_string(), - /*note*/ None, - )), - items: vec![SelectionItem { - name: "Adding marketplace...".to_string(), - description: Some( - "This updates when marketplace installation completes.".to_string(), - ), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn marketplace_remove_confirmation_popup_params( - &self, - plugins_response: &PluginListResponse, - marketplace_name: String, - marketplace_display_name: String, - ) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from( - format!("Remove {marketplace_display_name} marketplace?").dim(), - )); - header.push(Line::from( - "This removes the configured marketplace from Codex.".dim(), - )); - - let cwd_for_remove = self.config.cwd.to_path_buf(); - let cwd_for_cancel = self.config.cwd.to_path_buf(); - let cwd_for_on_cancel = self.config.cwd.to_path_buf(); - let plugins_response_for_cancel = plugins_response.clone(); - let plugins_response_for_on_cancel = plugins_response.clone(); - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - footer_hint: Some(Line::from(vec![ - Span::from(key_hint::plain(KeyCode::Enter)), - " select".dim(), - " · ".into(), - "esc close".dim(), - ])), - items: vec![ - SelectionItem { - name: "Remove marketplace".to_string(), - description: Some( - "Remove this marketplace from the available plugin list.".to_string(), - ), - selected_description: Some( - "Remove this marketplace from the available plugin list.".to_string(), - ), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenMarketplaceRemoveLoading { - marketplace_display_name: marketplace_display_name.clone(), - }); - tx.send(AppEvent::FetchMarketplaceRemove { - cwd: cwd_for_remove.clone(), - marketplace_name: marketplace_name.clone(), - marketplace_display_name: marketplace_display_name.clone(), - }); - })], - ..Default::default() - }, - SelectionItem { - name: "Back to plugins".to_string(), - description: Some("Keep this marketplace installed.".to_string()), - selected_description: Some("Keep this marketplace installed.".to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { - cwd: cwd_for_cancel.clone(), - result: Ok(plugins_response_for_cancel.clone()), - }); - })], - ..Default::default() - }, - ], - on_cancel: Some(Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { - cwd: cwd_for_on_cancel.clone(), - result: Ok(plugins_response_for_on_cancel.clone()), - }); - })), - ..Default::default() - } - } - - fn marketplace_remove_loading_popup_params( - &self, - marketplace_display_name: &str, - ) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from( - format!("Removing {marketplace_display_name}...").dim(), - )); - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - items: vec![SelectionItem { - name: "Removing marketplace...".to_string(), - description: Some("This updates when marketplace removal completes.".to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn marketplace_upgrade_loading_popup_params( - &self, - marketplace_name: Option<&str>, - ) -> SelectionViewParams { - let loading_text = marketplace_name - .map(|name| format!("Upgrading {name} marketplace...")) - .unwrap_or_else(|| "Upgrading marketplaces...".to_string()); - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(DelayedLoadingHeader::new( - self.frame_requester.clone(), - self.config.animations, - loading_text.clone(), - /*note*/ None, - )), - items: vec![SelectionItem { - name: loading_text, - description: Some("This updates when marketplace upgrade completes.".to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn plugin_detail_loading_popup_params(&self, plugin_display_name: &str) -> SelectionViewParams { - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(DelayedLoadingHeader::new( - self.frame_requester.clone(), - self.config.animations, - format!("Loading details for {plugin_display_name}..."), - /*note*/ None, - )), - items: vec![SelectionItem { - name: "Loading plugin details...".to_string(), - description: Some("This updates when plugin details load.".to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn plugin_install_loading_popup_params( - &self, - plugin_display_name: &str, - ) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from( - format!("Installing {plugin_display_name}...").dim(), - )); - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - items: vec![SelectionItem { - name: "Installing plugin...".to_string(), - description: Some("This updates when plugin installation completes.".to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn plugin_uninstall_loading_popup_params( - &self, - plugin_display_name: &str, - ) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from( - format!("Uninstalling {plugin_display_name}...").dim(), - )); - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - items: vec![SelectionItem { - name: "Uninstalling plugin...".to_string(), - description: Some("This updates when the plugin removal completes.".to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn plugins_error_popup_params(&self, err: &str) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from("Failed to load plugins.".dim())); - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - items: vec![SelectionItem { - name: "Plugin marketplace unavailable".to_string(), - description: Some(err.to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn marketplace_add_error_popup_params(&self) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from("Failed to add marketplace.".dim())); - - let mut items = vec![ - SelectionItem { - name: "Marketplace add failed".to_string(), - description: Some( - "Failed to add marketplace from the provided source.".to_string(), - ), - is_disabled: true, - ..Default::default() - }, - SelectionItem { - name: "Try again".to_string(), - description: Some("Enter a marketplace source.".to_string()), - selected_description: Some("Enter a marketplace source.".to_string()), - actions: vec![Box::new(|tx| { - tx.send(AppEvent::OpenMarketplaceAddPrompt); - })], - ..Default::default() - }, - ]; - - if let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd() { - let cwd = self.config.cwd.to_path_buf(); - items.push(SelectionItem { - name: "Back to plugins".to_string(), - description: Some("Return to the plugin list.".to_string()), - selected_description: Some("Return to the plugin list.".to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { - cwd: cwd.clone(), - result: Ok(plugins_response.clone()), - }); - })], - ..Default::default() - }); - } - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - footer_hint: Some(plugin_detail_hint_line()), - items, - ..Default::default() - } - } - - fn marketplace_remove_error_popup_params( - &self, - marketplace_name: &str, - marketplace_display_name: &str, - ) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from("Failed to remove marketplace.".dim())); - - let marketplace_name = marketplace_name.to_string(); - let marketplace_display_name = marketplace_display_name.to_string(); - let mut items = vec![ - SelectionItem { - name: "Marketplace removal failed".to_string(), - description: Some("Failed to remove the selected marketplace.".to_string()), - is_disabled: true, - ..Default::default() - }, - SelectionItem { - name: "Try again".to_string(), - description: Some("Review the confirmation prompt again.".to_string()), - selected_description: Some("Review the confirmation prompt again.".to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenMarketplaceRemoveConfirm { - marketplace_name: marketplace_name.clone(), - marketplace_display_name: marketplace_display_name.clone(), - }); - })], - ..Default::default() - }, - ]; - - if let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd() { - let cwd = self.config.cwd.to_path_buf(); - items.push(SelectionItem { - name: "Back to plugins".to_string(), - description: Some("Return to the plugin list.".to_string()), - selected_description: Some("Return to the plugin list.".to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { - cwd: cwd.clone(), - result: Ok(plugins_response.clone()), - }); - })], - ..Default::default() - }); - } - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - footer_hint: Some(plugin_detail_hint_line()), - items, - ..Default::default() - } - } - - fn plugin_detail_error_popup_params( - &self, - err: &str, - plugins_response: Option<&PluginListResponse>, - ) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from("Failed to load plugin details.".dim())); - - let mut items = vec![SelectionItem { - name: "Plugin detail unavailable".to_string(), - description: Some(err.to_string()), - is_disabled: true, - ..Default::default() - }]; - if let Some(plugins_response) = plugins_response.cloned() { - let cwd = self.config.cwd.to_path_buf(); - items.push(SelectionItem { - name: "Back to plugins".to_string(), - description: Some("Return to the plugin list.".to_string()), - selected_description: Some("Return to the plugin list.".to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { - cwd: cwd.clone(), - result: Ok(plugins_response.clone()), - }); - })], - ..Default::default() - }); - } - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - footer_hint: Some(plugin_detail_hint_line()), - items, - ..Default::default() - } - } - - fn plugins_popup_params( - &self, - response: &PluginListResponse, - active_tab_id: Option, - initial_selected_idx: Option, - ) -> SelectionViewParams { - let marketplaces: Vec<&PluginMarketplaceEntry> = response.marketplaces.iter().collect(); - - let total: usize = marketplaces - .iter() - .map(|marketplace| marketplace.plugins.len()) - .sum(); - let installed = marketplaces - .iter() - .flat_map(|marketplace| marketplace.plugins.iter()) - .filter(|plugin| plugin.installed) - .count(); - - let all_entries = plugin_entries_for_marketplaces(marketplaces.iter().copied()); - let name_column_width = all_entries - .iter() - .map(|(_, _, display_name)| { - PLUGIN_ROW_PREFIX_WIDTH + UnicodeWidthStr::width(display_name.as_str()) - }) - .chain([UnicodeWidthStr::width("Add marketplace")]) - .max(); - let installed_entries = all_entries - .iter() - .filter(|(_, plugin, _)| plugin.installed) - .cloned() - .collect(); - - let mut tabs = Vec::new(); - let mut tab_footer_hints = Vec::new(); - tabs.push(SelectionTab { - id: ALL_PLUGINS_TAB_ID.to_string(), - label: "All Plugins".to_string(), - header: plugins_header( - "Browse plugins from available marketplaces.".to_string(), - format!("Installed {installed} of {total} available plugins."), - ), - items: self.plugin_selection_items( - all_entries, - /*include_marketplace_names*/ true, - "No marketplace plugins available", - "No plugins are available in the discovered marketplaces.", - ), - }); - - tabs.push(SelectionTab { - id: INSTALLED_PLUGINS_TAB_ID.to_string(), - label: format!("Installed ({installed})"), - header: plugins_header( - "Installed plugins.".to_string(), - format!("Showing {installed} installed plugins."), - ), - items: self.plugin_selection_items( - installed_entries, - /*include_marketplace_names*/ true, - "No installed plugins", - "No installed plugins.", - ), - }); - - let curated_marketplace = marketplaces - .iter() - .find(|marketplace| is_openai_curated_marketplace_name(&marketplace.name)) - .copied(); - let curated_entries = curated_marketplace - .map(|marketplace| plugin_entries_for_marketplaces([marketplace])) - .unwrap_or_default(); - let curated_total = curated_entries.len(); - let curated_installed = curated_entries - .iter() - .filter(|(_, plugin, _)| plugin.installed) - .count(); - tabs.push(SelectionTab { - id: OPENAI_CURATED_TAB_ID.to_string(), - label: "OpenAI Curated".to_string(), - header: plugins_header( - "OpenAI Curated marketplace.".to_string(), - format!("Installed {curated_installed} of {curated_total} OpenAI Curated plugins."), - ), - items: self.plugin_selection_items( - curated_entries, - /*include_marketplace_names*/ false, - "No OpenAI Curated plugins available", - "No OpenAI Curated plugins available.", - ), - }); - - let mut additional_marketplaces: Vec<&PluginMarketplaceEntry> = marketplaces - .iter() - .copied() - .filter(|marketplace| !is_openai_curated_marketplace_name(&marketplace.name)) - .collect(); - additional_marketplaces.sort_by(|left, right| { - marketplace_display_name(left) - .to_ascii_lowercase() - .cmp(&marketplace_display_name(right).to_ascii_lowercase()) - .then_with(|| marketplace_display_name(left).cmp(&marketplace_display_name(right))) - .then_with(|| left.name.cmp(&right.name)) - }); - - let labels = disambiguate_duplicate_tab_labels( - additional_marketplaces - .iter() - .map(|marketplace| marketplace_display_name(marketplace)) - .collect(), - ); - for (marketplace, label) in additional_marketplaces.into_iter().zip(labels) { - let entries = plugin_entries_for_marketplaces([marketplace]); - let marketplace_total = entries.len(); - let marketplace_installed = entries - .iter() - .filter(|(_, plugin, _)| plugin.installed) - .count(); - let tab_id = marketplace_tab_id(marketplace); - let can_remove_marketplace = - marketplace_is_user_configured(&self.config, &marketplace.name); - let can_upgrade_marketplace = marketplace.path.is_some() - && marketplace_is_user_configured_git(&self.config, &marketplace.name); - if can_remove_marketplace || can_upgrade_marketplace { - tab_footer_hints.push(( - tab_id.clone(), - plugins_popup_hint_line( - /*can_remove_marketplace*/ can_remove_marketplace, - /*can_upgrade_marketplace*/ can_upgrade_marketplace, - ), - )); - } - let header = if self.newly_installed_marketplace_tab_id.as_deref() == Some(&tab_id) { - plugins_header( - format!("{label} installed successfully."), - "Select the plugins you want to use and press Enter to install or view details." - .to_string(), - ) - } else { - plugins_header( - format!("{label}."), - format!( - "Installed {marketplace_installed} of {marketplace_total} {label} plugins." - ), - ) - }; - tabs.push(SelectionTab { - id: tab_id, - label: label.clone(), - header, - items: self.plugin_selection_items( - entries, - /*include_marketplace_names*/ false, - "No plugins available in this marketplace", - "No plugins available in this marketplace.", - ), - }); - } - - tabs.push(self.marketplace_add_tab()); - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(()), - footer_hint: Some(plugins_popup_hint_line( - /*can_remove_marketplace*/ false, /*can_upgrade_marketplace*/ false, - )), - tab_footer_hints, - tabs, - initial_tab_id: active_tab_id, - is_searchable: true, - search_placeholder: Some("Type to search plugins".to_string()), - col_width_mode: ColumnWidthMode::AutoAllRows, - row_display: SelectionRowDisplay::SingleLine, - name_column_width, - initial_selected_idx, - ..Default::default() - } - } - - fn marketplace_add_tab(&self) -> SelectionTab { - SelectionTab { - id: ADD_MARKETPLACE_TAB_ID.to_string(), - label: "Add Marketplace".to_string(), - header: plugins_header( - "Add a marketplace from a Git repo or local root.".to_string(), - "Enter a source to make its plugins available in this menu.".to_string(), - ), - items: vec![SelectionItem { - name: "Add marketplace".to_string(), - description: Some( - "Enter owner/repo, a Git URL, or a local marketplace path.".to_string(), - ), - selected_description: Some( - "Press Enter to enter a marketplace source.".to_string(), - ), - actions: vec![Box::new(|tx| { - tx.send(AppEvent::OpenMarketplaceAddPrompt); - })], - ..Default::default() - }], - } - } - - fn plugin_detail_popup_params( - &self, - plugins_response: &PluginListResponse, - plugin: &PluginDetail, - ) -> SelectionViewParams { - let marketplace_label = plugin.marketplace_name.clone(); - let display_name = plugin_display_name(&plugin.summary); - let detail_status_label = - if plugin.summary.availability == PluginAvailability::DisabledByAdmin { - "Disabled by admin" - } else if plugin.summary.installed { - if plugin.summary.enabled { - "Installed" - } else { - "Disabled" - } - } else { - match plugin.summary.install_policy { - PluginInstallPolicy::NotAvailable => "Not installable", - PluginInstallPolicy::Available => "Can be installed", - PluginInstallPolicy::InstalledByDefault => "Available by default", - } - }; - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from( - format!("{display_name} · {detail_status_label} · {marketplace_label}").bold(), - )); - if !plugin.summary.installed { - header.push(PluginDisclosureLine { - line: Line::from(vec![ - "Data shared with this app is subject to the app's ".into(), - "terms of service".bold(), - " and ".into(), - "privacy policy".bold(), - ". ".into(), - "Learn more".cyan().underlined(), - ".".into(), - ]), - }); - } - if let Some(description) = plugin_detail_description(plugin) { - header.push(Line::from(description.dim())); - } - - let cwd = self.config.cwd.to_path_buf(); - let plugins_response = plugins_response.clone(); - let mut items = vec![SelectionItem { - name: "Back to plugins".to_string(), - description: Some("Return to the plugin list.".to_string()), - selected_description: Some("Return to the plugin list.".to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { - cwd: cwd.clone(), - result: Ok(plugins_response.clone()), - }); - })], - ..Default::default() - }]; - - if plugin.summary.installed { - if let Some(plugin_id) = plugin_uninstall_id(&plugin.summary) { - let uninstall_cwd = self.config.cwd.to_path_buf(); - let plugin_display_name = display_name; - items.push(SelectionItem { - name: "Uninstall plugin".to_string(), - description: Some("Remove this plugin now.".to_string()), - selected_description: Some("Remove this plugin now.".to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenPluginUninstallLoading { - plugin_display_name: plugin_display_name.clone(), - }); - tx.send(AppEvent::FetchPluginUninstall { - cwd: uninstall_cwd.clone(), - plugin_id: plugin_id.clone(), - plugin_display_name: plugin_display_name.clone(), - }); - })], - ..Default::default() - }); - } else { - items.push(SelectionItem { - name: "Uninstall plugin".to_string(), - description: Some( - "This remote plugin did not provide an uninstall identity.".to_string(), - ), - is_disabled: true, - ..Default::default() - }); - } - } else if plugin.summary.availability == PluginAvailability::DisabledByAdmin { - items.push(SelectionItem { - name: "Install plugin".to_string(), - description: Some("This plugin is disabled by your workspace admin.".to_string()), - is_disabled: true, - ..Default::default() - }); - } else if plugin.summary.install_policy == PluginInstallPolicy::NotAvailable { - items.push(SelectionItem { - name: "Install plugin".to_string(), - description: Some( - "This plugin is not installable from this marketplace.".to_string(), - ), - is_disabled: true, - ..Default::default() - }); - } else if let Some(location) = plugin_detail_location(plugin) { - let install_cwd = self.config.cwd.to_path_buf(); - let plugin_name = plugin_request_name(&plugin.summary); - let plugin_display_name = display_name; - items.push(SelectionItem { - name: "Install plugin".to_string(), - description: Some("Install this plugin now.".to_string()), - selected_description: Some("Install this plugin now.".to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenPluginInstallLoading { - plugin_display_name: plugin_display_name.clone(), - }); - tx.send(AppEvent::FetchPluginInstall { - cwd: install_cwd.clone(), - location: location.clone(), - plugin_name: plugin_name.clone(), - plugin_display_name: plugin_display_name.clone(), - }); - })], - ..Default::default() - }); - } else { - items.push(SelectionItem { - name: "Install plugin".to_string(), - description: Some("This plugin did not provide an install location.".to_string()), - is_disabled: true, - ..Default::default() - }); - } - - items.push(SelectionItem { - name: "Skills".to_string(), - description: Some(plugin_skill_summary(plugin)), - is_disabled: true, - ..Default::default() - }); - items.push(SelectionItem { - name: "Hooks".to_string(), - description: Some(plugin_hook_summary(plugin)), - is_disabled: true, - ..Default::default() - }); - items.push(SelectionItem { - name: "Apps".to_string(), - description: Some(plugin_app_summary(plugin)), - is_disabled: true, - ..Default::default() - }); - items.push(SelectionItem { - name: "MCP Servers".to_string(), - description: Some(plugin_mcp_summary(plugin)), - is_disabled: true, - ..Default::default() - }); - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - footer_hint: Some(plugin_detail_hint_line()), - items, - col_width_mode: ColumnWidthMode::AutoAllRows, - ..Default::default() - } - } - - fn plugin_selection_items<'a>( - &self, - mut plugin_entries: Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)>, - include_marketplace_names: bool, - empty_name: &str, - empty_description: &str, - ) -> Vec { - sort_plugin_entries(&mut plugin_entries); - let status_label_width = plugin_entries - .iter() - .map(|(_, plugin, _)| plugin_status_label(plugin).chars().count()) - .max() - .unwrap_or(0); - - let mut items: Vec = Vec::new(); - for (marketplace, plugin, display_name) in plugin_entries { - let marketplace_label = marketplace_display_name(marketplace); - let status_label = plugin_status_label(plugin); - let description = if include_marketplace_names { - plugin_brief_description(plugin, &marketplace_label, status_label_width) - } else { - plugin_brief_description_without_marketplace(plugin, status_label_width) - }; - let plugin_detail_request = plugin_detail_request_for_entry(marketplace, plugin); - let can_view_details = plugin_detail_request.is_some(); - let disabled_by_admin = plugin.availability == PluginAvailability::DisabledByAdmin; - let can_toggle_plugin = plugin.installed && !disabled_by_admin; - let selected_status_label = format!("{status_label: = - if let Some((location, plugin_name)) = plugin_detail_request { - vec![Box::new(move |tx| { - tx.send(AppEvent::OpenPluginDetailLoading { - plugin_display_name: plugin_display_name.clone(), - }); - let (marketplace_path, remote_marketplace_name) = - location.clone().into_request_params(); - tx.send(AppEvent::FetchPluginDetail { - cwd: cwd.clone(), - params: codex_app_server_protocol::PluginReadParams { - marketplace_path, - remote_marketplace_name, - plugin_name: plugin_name.clone(), - }, - }); - })] - } else { - Vec::new() - }; - let is_disabled = !can_view_details && !plugin.installed; - let disabled_reason = is_disabled.then(|| "plugin details are unavailable".to_string()); - - items.push(SelectionItem { - name: display_name, - toggle, - toggle_placeholder: (!can_toggle_plugin).then_some("[-] "), - description: Some(description), - selected_description: Some(selected_description), - search_value: Some(search_value), - actions, - is_disabled, - disabled_reason, - ..Default::default() - }); - } - - if items.is_empty() { - items.push(SelectionItem { - name: empty_name.to_string(), - description: Some(empty_description.to_string()), - is_disabled: true, - ..Default::default() - }); - } - items - } -} - -fn plugins_popup_hint_line( - can_remove_marketplace: bool, - can_upgrade_marketplace: bool, -) -> Line<'static> { - match (can_remove_marketplace, can_upgrade_marketplace) { - (true, true) => Line::from( - "ctrl + u upgrade · ctrl + r remove · space toggle · ←/→ tabs · enter details · esc close", - ), - (true, false) => { - Line::from("ctrl + r remove · space toggle · ←/→ tabs · enter details · esc close") - } - (false, true) => { - Line::from("ctrl + u upgrade · space toggle · ←/→ tabs · enter details · esc close") - } - (false, false) => Line::from( - "space enable/disable · ←/→ select marketplace · enter view details · esc close", - ), - } -} - -fn plugin_detail_hint_line() -> Line<'static> { - Line::from("Press esc to close.") -} - -fn plugins_header(subtitle: String, count_line: String) -> Box { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from(subtitle.dim())); - header.push(Line::from(count_line.dim())); - Box::new(header) -} - -fn plugin_entries_for_marketplaces<'a>( - marketplaces: impl IntoIterator, -) -> Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)> { - marketplaces - .into_iter() - .flat_map(|marketplace| { - marketplace - .plugins - .iter() - .map(move |plugin| (marketplace, plugin, plugin_display_name(plugin))) - }) - .collect() -} - -fn sort_plugin_entries(entries: &mut [(&PluginMarketplaceEntry, &PluginSummary, String)]) { - entries.sort_by(|left, right| { - right - .1 - .installed - .cmp(&left.1.installed) - .then_with(|| { - left.2 - .to_ascii_lowercase() - .cmp(&right.2.to_ascii_lowercase()) - }) - .then_with(|| left.2.cmp(&right.2)) - .then_with(|| left.1.name.cmp(&right.1.name)) - .then_with(|| left.1.id.cmp(&right.1.id)) - }); -} - -fn marketplace_tab_id(marketplace: &PluginMarketplaceEntry) -> String { - match marketplace.path.as_ref() { - Some(path) => marketplace_tab_id_from_path(path.as_path()), - None => format!("marketplace:{}", marketplace.name), - } -} - -fn marketplace_tab_id_from_path(path: &Path) -> String { - format!("{MARKETPLACE_TAB_ID_PREFIX}{}", path.display()) -} - -fn marketplace_tab_id_matching_saved_id( - saved_tab_id: &str, - marketplaces: &[PluginMarketplaceEntry], -) -> Option { - if let Some(tab_id) = marketplaces.iter().find_map(|marketplace| { - let tab_id = marketplace_tab_id(marketplace); - (tab_id == saved_tab_id).then_some(tab_id) - }) { - return Some(tab_id); - } - - let root = saved_tab_id.strip_prefix(MARKETPLACE_TAB_ID_PREFIX)?; - if root.is_empty() { - return None; - } - let root = Path::new(root); - marketplaces.iter().find_map(|marketplace| { - marketplace - .path - .as_ref() - .is_some_and(|path| path.as_path().starts_with(root)) - .then(|| marketplace_tab_id(marketplace)) - }) -} - -fn merge_remote_marketplaces( - response: &mut PluginListResponse, - remote_marketplaces: Vec, -) { - let remote_names = remote_marketplaces - .iter() - .map(|marketplace| marketplace.name.clone()) - .collect::>(); - response.marketplaces.retain(|marketplace| { - marketplace.path.is_some() - || !remote_marketplace_is_remote_section(marketplace) - && !remote_names.contains(marketplace.name.as_str()) - }); - response.marketplaces.extend(remote_marketplaces); -} - -fn remote_marketplace_is_remote_section(marketplace: &PluginMarketplaceEntry) -> bool { - matches!( - marketplace.name.as_str(), - REMOTE_WORKSPACE_MARKETPLACE_NAME - | REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME - | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME - | REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME - ) -} - -fn disambiguate_duplicate_tab_labels(labels: Vec) -> Vec { - let mut counts: Vec<(String, usize)> = Vec::new(); - for label in &labels { - if let Some((_, count)) = counts.iter_mut().find(|(existing, _)| existing == label) { - *count += 1; - } else { - counts.push((label.clone(), 1)); - } - } - - let mut seen: Vec<(String, usize)> = Vec::new(); - labels - .into_iter() - .map(|label| { - let total = counts - .iter() - .find(|(existing, _)| existing == &label) - .map(|(_, count)| *count) - .unwrap_or(1); - if total == 1 { - return label; - } - - let current = if let Some((_, seen_count)) = - seen.iter_mut().find(|(existing, _)| existing == &label) - { - *seen_count += 1; - *seen_count - } else { - seen.push((label.clone(), 1)); - 1 - }; - format!("{label} ({current}/{total})") - }) - .collect() -} - -fn marketplace_display_name(marketplace: &PluginMarketplaceEntry) -> String { - marketplace - .interface - .as_ref() - .and_then(|interface| interface.display_name.as_deref()) - .map(str::trim) - .filter(|display_name| !display_name.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| marketplace.name.clone()) -} - -fn marketplace_is_user_configured(config: &Config, marketplace_name: &str) -> bool { - let Some(user_config) = config.config_layer_stack.effective_user_config() else { - return false; - }; - user_config - .get("marketplaces") - .and_then(toml::Value::as_table) - .is_some_and(|marketplaces| marketplaces.contains_key(marketplace_name)) -} - -fn marketplace_is_user_configured_git(config: &Config, marketplace_name: &str) -> bool { - config - .config_layer_stack - .get_active_user_layer() - .and_then(|user_layer| user_layer.config.get("marketplaces")) - .and_then(toml::Value::as_table) - .and_then(|marketplaces| marketplaces.get(marketplace_name)) - .and_then(toml::Value::as_table) - .and_then(|marketplace| marketplace.get("source_type")) - .and_then(toml::Value::as_str) - .is_some_and(|source_type| source_type == "git") -} - -fn plugin_display_name(plugin: &PluginSummary) -> String { - plugin - .interface - .as_ref() - .and_then(|interface| interface.display_name.as_deref()) - .map(str::trim) - .filter(|display_name| !display_name.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| plugin.name.clone()) -} - -fn plugin_brief_description( - plugin: &PluginSummary, - marketplace_label: &str, - status_label_width: usize, -) -> String { - let status_label = plugin_status_label(plugin); - let status_label = format!("{status_label: format!("{status_label} · {marketplace_label} · {description}"), - None => format!("{status_label} · {marketplace_label}"), - } -} - -fn plugin_brief_description_without_marketplace( - plugin: &PluginSummary, - status_label_width: usize, -) -> String { - let status_label = plugin_status_label(plugin); - let status_label = format!("{status_label: format!("{status_label} · {description}"), - None => status_label, - } -} - -fn plugin_status_label(plugin: &PluginSummary) -> &'static str { - if plugin.availability == PluginAvailability::DisabledByAdmin { - return "Disabled by admin"; - } - if plugin.installed { - if plugin.enabled { - "Installed" - } else { - "Disabled" - } - } else { - match plugin.install_policy { - PluginInstallPolicy::NotAvailable => "Not installable", - PluginInstallPolicy::Available => "Available", - PluginInstallPolicy::InstalledByDefault => "Available", - } - } -} - -fn plugin_location_for_marketplace( - marketplace: &PluginMarketplaceEntry, - plugin: &PluginSummary, -) -> Option { - if let Some(marketplace_path) = marketplace.path.clone() { - return Some(PluginLocation::Local { marketplace_path }); - } - plugin_remote_identity(plugin).map(|_| PluginLocation::Remote { - marketplace_name: marketplace.name.clone(), - }) -} - -fn plugin_detail_location(plugin: &PluginDetail) -> Option { - if let Some(marketplace_path) = plugin.marketplace_path.clone() { - return Some(PluginLocation::Local { marketplace_path }); - } - plugin_remote_identity(&plugin.summary).map(|_| PluginLocation::Remote { - marketplace_name: plugin.marketplace_name.clone(), - }) -} - -fn plugin_detail_request_for_entry( - marketplace: &PluginMarketplaceEntry, - plugin: &PluginSummary, -) -> Option<(PluginLocation, String)> { - plugin_location_for_marketplace(marketplace, plugin) - .map(|location| (location, plugin_request_name(plugin))) -} - -fn plugin_request_name(plugin: &PluginSummary) -> String { - if matches!(&plugin.source, PluginSource::Remote) - && let Some(remote_plugin_id) = plugin_remote_identity(plugin) - { - return remote_plugin_id; - } - plugin.name.clone() -} - -fn plugin_remote_identity(plugin: &PluginSummary) -> Option { - plugin - .share_context - .as_ref() - .map(|context| context.remote_plugin_id.clone()) - .or_else(|| plugin.remote_plugin_id.clone()) -} - -fn plugin_uninstall_id(plugin: &PluginSummary) -> Option { - if matches!(&plugin.source, PluginSource::Remote) { - return plugin_remote_identity(plugin); - } - Some(plugin.id.clone()) -} - -fn plugin_description(plugin: &PluginSummary) -> Option { - plugin - .interface - .as_ref() - .and_then(|interface| { - interface - .short_description - .as_deref() - .or(interface.long_description.as_deref()) - }) - .map(str::trim) - .filter(|description| !description.is_empty()) - .map(str::to_string) -} - -fn plugin_detail_description(plugin: &PluginDetail) -> Option { - plugin - .description - .as_deref() - .or_else(|| { - plugin - .summary - .interface - .as_ref() - .and_then(|interface| interface.long_description.as_deref()) - }) - .or_else(|| { - plugin - .summary - .interface - .as_ref() - .and_then(|interface| interface.short_description.as_deref()) - }) - .map(str::trim) - .filter(|description| !description.is_empty()) - .map(str::to_string) -} - -fn plugin_skill_summary(plugin: &PluginDetail) -> String { - if plugin.skills.is_empty() { - "No plugin skills.".to_string() - } else { - plugin - .skills - .iter() - .map(|skill| skill.name.as_str()) - .collect::>() - .join(", ") - } -} - -fn plugin_app_summary(plugin: &PluginDetail) -> String { - if plugin.apps.is_empty() { - "No plugin apps.".to_string() - } else { - plugin - .apps - .iter() - .map(|app| app.name.as_str()) - .collect::>() - .join(", ") - } -} - -fn plugin_hook_summary(plugin: &PluginDetail) -> String { - if plugin.hooks.is_empty() { - "No plugin hooks.".to_string() - } else { - let mut event_counts = Vec::<(codex_app_server_protocol::HookEventName, usize)>::new(); - for hook in &plugin.hooks { - if let Some((_, handler_count)) = event_counts - .iter_mut() - .find(|(event_name, _)| *event_name == hook.event_name) - { - *handler_count += 1; - } else { - event_counts.push((hook.event_name, 1)); - } - } - event_counts - .into_iter() - .map(|(event_name, handler_count)| format!("{event_name:?} ({handler_count})")) - .collect::>() - .join(", ") - } -} - -fn plugin_mcp_summary(plugin: &PluginDetail) -> String { - if plugin.mcp_servers.is_empty() { - "No plugin MCP servers.".to_string() - } else { - plugin.mcp_servers.join(", ") - } } From cca39d51ba5e159bf71eae319e09915bc9ecce01 Mon Sep 17 00:00:00 2001 From: Konstantine Kahadze Date: Wed, 17 Jun 2026 12:16:18 -0700 Subject: [PATCH 38/70] [codex] Use compact OpenAI docs search queries (#28389) ## Summary Updates the bundled OpenAI Docs skill to use compact, title-like search queries. This performs better in Codex. ## Validation - OpenAI Docs skill validation passed - `git diff --check` --- codex-rs/skills/src/assets/samples/openai-docs/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md b/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md index 30526bd9d8..df395fb442 100644 --- a/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md +++ b/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md @@ -135,7 +135,7 @@ If MCP tools fail or no OpenAI docs resources are available: 5. Leave historical docs, examples, eval baselines, fixtures, provider comparisons, provider registries, pricing tables, alias defaults, low-cost fallback paths, and ambiguous older model usage unchanged unless the user explicitly asks to upgrade them. 6. Keep SDK, tooling, IDE, plugin, shell, auth, and provider-environment migrations out of a model-and-prompt upgrade unless the user explicitly asks for them. 7. If an upgrade needs API-surface changes, schema rewiring, tool-handler changes, or implementation work beyond a literal model-string replacement and prompt edits, report it as blocked or confirmation-needed. -8. For general docs lookup, search docs with a precise query, fetch the best page and exact section needed, and answer with concise citations. +8. For general docs lookup, start with a compact, title-like search query of 2-6 essential terms. Do not turn the full user question into a keyword list. Fetch the best page and exact section needed, and answer with concise citations. ## Reference map From 5867b529ae91afad02de74a0bc1a2162e3721688 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Wed, 17 Jun 2026 12:36:16 -0700 Subject: [PATCH 39/70] unified-exec: preserve PathUri through exec-server (#28681) ## Why It should be possible for app-server to handle "foreign" OS paths in unified_exec working directories, allowing e.g. a Linux app-server to run processes on e.g. a Windows exec-server. ## What Convert the core unified_exec cwd values to use `PathUri`. Adds fallible path conversion in several places to try to minimize the scope of this change. The only time this change suppresses errors from converting `PathUri` to an `AbsolutePathBuf` is when the turn is configured with no sandboxing at all to allow us to make progress testing without sandboxing. Future changes to apply_patch and sandboxing will clean up these error paths. A tool's cwd is resolved from joining a model-provided workdir to the environment's cwd. When using `AbsolutePathBuf::join()`, an absolute-path workdir would overwrite the environment's cwd and we would resolve permissions/sandboxing against the model-provided path. This change extends `PathUri::join()` to also treat an absolute rhs as an override of the base/lhs. This also removes some coverage from the remove_env_windows tests until a follow-up converts foreign paths in command exec events correctly. ## Breaking Changes When using `AbsolutePathBuf::join()` for workdir resolution, we ended up resolving tilde-prefixed paths against the app-server's `$HOME`, e.g. `~/foo/bar` becomes `/home/anp/foo/bar`. It's difficult to do this with `PathUri` joining, so after offline discussion this PR no longer implements it. A quick check of some power users' rollouts suggests that models don't actually generate home-prefixed absolute working directories for their spawns, so this shouldn't have any real blast radius. --- codex-rs/app-server/src/command_exec.rs | 4 + .../request_processors/thread_processor.rs | 24 ++- codex-rs/apply-patch/src/invocation.rs | 11 +- codex-rs/apply-patch/src/lib.rs | 4 +- codex-rs/core/src/apply_patch.rs | 14 +- codex-rs/core/src/codex_thread.rs | 3 +- codex-rs/core/src/exec.rs | 11 +- codex-rs/core/src/sandboxing/mod.rs | 6 +- codex-rs/core/src/tasks/user_shell.rs | 4 +- codex-rs/core/src/tools/events.rs | 11 +- .../core/src/tools/handlers/apply_patch.rs | 9 +- .../handlers/unified_exec/exec_command.rs | 107 ++++++++---- codex-rs/core/src/tools/orchestrator.rs | 6 +- .../core/src/tools/runtimes/apply_patch.rs | 31 +++- .../src/tools/runtimes/apply_patch_tests.rs | 5 +- codex-rs/core/src/tools/runtimes/mod_tests.rs | 4 +- .../tools/runtimes/shell/unix_escalation.rs | 29 ++- .../core/src/tools/runtimes/unified_exec.rs | 122 +++++++++---- codex-rs/core/src/tools/sandboxing.rs | 2 +- .../core/src/unified_exec/async_watcher.rs | 8 +- codex-rs/core/src/unified_exec/errors.rs | 3 + codex-rs/core/src/unified_exec/mod.rs | 8 +- codex-rs/core/src/unified_exec/mod_tests.rs | 9 +- .../core/src/unified_exec/process_manager.rs | 33 +++- .../src/unified_exec/process_manager_tests.rs | 12 +- .../remote_env_windows_test.rs | 19 +- codex-rs/exec-server/src/fs_sandbox.rs | 2 + codex-rs/exec/tests/suite/sandbox.rs | 7 +- codex-rs/sandboxing/src/manager.rs | 165 +++++++++++++----- codex-rs/sandboxing/src/manager_tests.rs | 13 +- .../utils/path-uri/src/api_path_string.rs | 4 + codex-rs/utils/path-uri/src/lib.rs | 63 ++++++- codex-rs/utils/path-uri/src/tests.rs | 87 ++++++++- 33 files changed, 634 insertions(+), 206 deletions(-) diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index 2f62f099e9..f66bd95794 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -237,6 +237,10 @@ impl CommandExecManager { arg0, .. } = exec_request; + // TODO(anp): Keep PathUri through the local command launch boundary. + let cwd = cwd + .to_abs_path() + .map_err(|err| invalid_request(format!("invalid command cwd: {err}")))?; let stream_stdin = tty || stream_stdin; let stream_stdout_stderr = tty || stream_stdout_stderr; diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 907b05a03b..91005d6f71 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1781,16 +1781,22 @@ impl ThreadRequestProcessor { .list_background_terminals() .await .into_iter() - .map(|terminal| ThreadBackgroundTerminal { - item_id: terminal.item_id, - process_id: terminal.process_id, - command: terminal.command, - cwd: terminal.cwd, - os_pid: None, - cpu_percent: None, - rss_kb: None, + .map(|terminal| { + // TODO(anp): Migrate ThreadBackgroundTerminal to PathUri. + let cwd = terminal.cwd.to_abs_path().map_err(|err| { + internal_error(format!("background terminal has invalid cwd: {err}")) + })?; + Ok(ThreadBackgroundTerminal { + item_id: terminal.item_id, + process_id: terminal.process_id, + command: terminal.command, + cwd, + os_pid: None, + cpu_percent: None, + rss_kb: None, + }) }) - .collect::>(); + .collect::, JSONRPCErrorError>>()?; let (data, next_cursor) = paginate_background_terminals(&terminals, cursor, limit)?; diff --git a/codex-rs/apply-patch/src/invocation.rs b/codex-rs/apply-patch/src/invocation.rs index 8701750fbe..0e0261c3b5 100644 --- a/codex-rs/apply-patch/src/invocation.rs +++ b/codex-rs/apply-patch/src/invocation.rs @@ -230,7 +230,7 @@ pub async fn verify_apply_patch_args( MaybeApplyPatchVerified::Body(ApplyPatchAction { changes, patch, - cwd: effective_cwd, + cwd: effective_cwd.into(), }) } @@ -811,7 +811,9 @@ PATCH"#, }, )]), patch: argv[1].clone(), - cwd: AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(), + cwd: AbsolutePathBuf::from_absolute_path(session_dir.path()) + .unwrap() + .into(), }) ); } @@ -851,7 +853,10 @@ PATCH"#, other => panic!("expected verified body, got {other:?}"), }; - assert_eq!(action.cwd.as_path(), worktree_dir.as_path()); + assert_eq!( + action.cwd.to_abs_path().unwrap().as_path(), + worktree_dir.as_path() + ); let source_path = worktree_dir.join(source_name); let change = action diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 386f1e5346..ff11c43242 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -142,7 +142,7 @@ pub struct ApplyPatchAction { pub patch: String, /// The working directory that was used to resolve relative paths in the patch. - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, } impl ApplyPatchAction { @@ -174,7 +174,7 @@ impl ApplyPatchAction { #[expect(clippy::expect_used)] Self { changes, - cwd: path.parent().expect("path should have parent"), + cwd: path.parent().expect("path should have parent").into(), patch, } } diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index 38dc45ec1f..208db44d9c 100644 --- a/codex-rs/core/src/apply_patch.rs +++ b/codex-rs/core/src/apply_patch.rs @@ -35,12 +35,24 @@ pub(crate) async fn apply_patch( file_system_sandbox_policy: &FileSystemSandboxPolicy, action: ApplyPatchAction, ) -> InternalApplyPatchInvocation { + // TODO(anp): Migrate patch safety checks to PathUri. + let cwd = match action.cwd.to_abs_path() { + Ok(cwd) => cwd, + Err(err) => { + return InternalApplyPatchInvocation::Output(Err(FunctionCallError::RespondToModel( + format!( + "patch cwd `{}` is not native to the Codex host: {err}", + action.cwd + ), + ))); + } + }; match assess_patch_safety( &action, turn_context.approval_policy.value(), &turn_context.permission_profile(), file_system_sandbox_policy, - &action.cwd, + &cwd, turn_context.windows_sandbox_level, ) { SafetyCheck::AutoApprove { diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 451b497827..99e0391d3b 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -41,6 +41,7 @@ use codex_thread_store::ThreadMetadataPatch; use codex_thread_store::ThreadStoreError; use codex_thread_store::ThreadStoreResult; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use rmcp::model::ReadResourceRequestParams; use std::collections::BTreeMap; use std::collections::HashMap; @@ -164,7 +165,7 @@ pub struct BackgroundTerminalInfo { pub item_id: String, pub process_id: String, pub command: String, - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, } /// Conduit for the bidirectional stream of messages that compose a thread diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 2b390ddba2..4983e0a806 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -381,7 +381,7 @@ pub fn build_exec_request( }) .map(|request| { let windows_sandbox_workspace_roots = if windows_sandbox_workspace_roots.is_empty() { - vec![request.sandbox_policy_cwd.clone()] + vec![sandbox_cwd.clone()] } else { windows_sandbox_workspace_roots.to_vec() }; @@ -440,6 +440,15 @@ pub(crate) async fn execute_exec_request( arg0, } = exec_request; + // TODO(anp): Keep PathUri through the local process launch boundary. + let cwd = cwd + .to_abs_path() + .map_err(|err| CodexErr::InvalidRequest(format!("invalid exec cwd: {err}")))?; + // TODO(anp): Keep PathUri through the Windows sandbox launch boundary. + let windows_sandbox_policy_cwd = windows_sandbox_policy_cwd + .to_abs_path() + .map_err(|err| CodexErr::InvalidRequest(format!("invalid sandbox cwd: {err}")))?; + let params = ExecParams { command, cwd, diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 009cd71da8..fa069afd4a 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -25,6 +25,7 @@ use codex_sandboxing::SandboxExecRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::WindowsSandboxFilesystemOverrides; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use std::collections::HashMap; #[derive(Debug)] @@ -42,14 +43,14 @@ pub(crate) struct ExecServerEnvConfig { #[derive(Debug)] pub struct ExecRequest { pub command: Vec, - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, pub env: HashMap, pub(crate) exec_server_env_config: Option, pub network: Option, pub expiration: ExecExpiration, pub capture_policy: ExecCapturePolicy, pub sandbox: SandboxType, - pub windows_sandbox_policy_cwd: AbsolutePathBuf, + pub windows_sandbox_policy_cwd: PathUri, pub windows_sandbox_workspace_roots: Vec, pub windows_sandbox_level: WindowsSandboxLevel, pub windows_sandbox_private_desktop: bool, @@ -76,6 +77,7 @@ impl ExecRequest { permission_profile: PermissionProfile, arg0: Option, ) -> Self { + let cwd = PathUri::from_abs_path(&cwd); let windows_sandbox_policy_cwd = cwd.clone(); let (file_system_sandbox_policy, network_sandbox_policy) = permission_profile.to_runtime_permissions(); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 37835c36a1..057718b9f7 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -199,7 +199,7 @@ pub(crate) async fn execute_user_shell_command( let permission_profile = PermissionProfile::Disabled; let exec_env = ExecRequest { command: exec_command.clone(), - cwd: cwd.clone(), + cwd: cwd.clone().into(), env: exec_env_map, exec_server_env_config: None, // `/shell` is the explicit full-access escape hatch, so it must not @@ -210,7 +210,7 @@ pub(crate) async fn execute_user_shell_command( expiration: USER_SHELL_TIMEOUT_MS.into(), capture_policy: ExecCapturePolicy::ShellTool, sandbox: SandboxType::None, - windows_sandbox_policy_cwd: cwd.clone(), + windows_sandbox_policy_cwd: cwd.clone().into(), windows_sandbox_workspace_roots: turn_context.config.effective_workspace_roots(), windows_sandbox_level: turn_context.windows_sandbox_level, windows_sandbox_private_desktop: turn_context diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index c30cee84ca..bb385f37b5 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -21,6 +21,7 @@ use codex_protocol::protocol::PatchApplyStatus; use codex_protocol::protocol::TurnDiffEvent; use codex_shell_command::parse_command::parse_command; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; @@ -133,7 +134,7 @@ pub(crate) enum ToolEmitter { }, UnifiedExec { command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, source: ExecCommandSource, parsed_cmd: Vec, process_id: Option, @@ -165,7 +166,7 @@ impl ToolEmitter { pub fn unified_exec( command: &[String], - cwd: AbsolutePathBuf, + cwd: PathUri, source: ExecCommandSource, process_id: Option, ) -> Self { @@ -320,11 +321,15 @@ impl ToolEmitter { }, stage, ) => { + // TODO(anp): Migrate exec command protocol events to PathUri. + let Ok(cwd) = cwd.to_abs_path() else { + return; + }; emit_exec_stage( ctx, ExecCommandInput::new( command, - cwd, + &cwd, parsed_cmd, *source, /*interaction_input*/ None, diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 9004eb9922..9a00fac94d 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -205,16 +205,19 @@ fn format_update_chunks_for_progress(chunks: &[codex_apply_patch::UpdateFileChun fn file_paths_for_action(action: &ApplyPatchAction) -> Vec { let mut keys = Vec::new(); - let cwd = &action.cwd; + // TODO(anp): Migrate permission path accounting to PathUri. + let Ok(cwd) = action.cwd.to_abs_path() else { + return keys; + }; for (path, change) in action.changes() { - if let Some(key) = to_abs_path(cwd, path) { + if let Some(key) = to_abs_path(&cwd, path) { keys.push(key); } if let ApplyPatchFileChange::Update { move_path, .. } = change && let Some(dest) = move_path - && let Some(key) = to_abs_path(cwd, dest) + && let Some(key) = to_abs_path(&cwd, dest) { keys.push(key); } diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index b5bfe1eb52..db13c6f0f9 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -28,9 +28,13 @@ use crate::unified_exec::generate_chunk_id; use codex_features::Feature; use codex_otel::SessionTelemetry; use codex_otel::TOOL_CALL_UNIFIED_EXEC_METRIC; +use codex_sandboxing::SandboxManager; +use codex_sandboxing::SandboxType; +use codex_sandboxing::SandboxablePreference; use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_utils_output_truncation::approx_token_count; +use codex_utils_path_uri::PathConvention; use super::super::shell_spec::CommandToolOptions; use super::super::shell_spec::create_exec_command_tool_with_environment_id; @@ -129,33 +133,68 @@ impl ExecCommandHandler { "unified exec is unavailable in this session".to_string(), )); }; - // TODO(anp): Resolve tool paths using the selected environment's native path convention - // so unified exec can support relative paths in foreign environments. - let native_environment_cwd = turn_environment.cwd().to_abs_path().map_err(|err| { - FunctionCallError::RespondToModel(format!( - "environment cwd `{}` is not native to the Codex host: {err}", - turn_environment.cwd() - )) - })?; + let native_environment_cwd = turn_environment.cwd().clone(); let cwd = environment_args .workdir .as_deref() .filter(|workdir| !workdir.is_empty()) .map_or_else( - || native_environment_cwd.clone(), + || Ok(native_environment_cwd.clone()), |workdir| native_environment_cwd.join(workdir), - ); + ) + .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?; let environment = Arc::clone(&turn_environment.environment); let fs = environment.get_filesystem(); - let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?; + + // A foreign cwd cannot seed the AbsolutePathBufGuard used to resolve relative paths in the + // permissions config below. Consult the configured platform-sandbox requirement before + // deciding whether parsing may continue without that base path. + let sandbox = SandboxManager::new().select_initial( + &turn.file_system_sandbox_policy(), + turn.network_sandbox_policy(), + SandboxablePreference::Auto, + turn.windows_sandbox_level, + turn.network.is_some(), + ); + // `to_abs_path()` alone cannot identify foreign drive paths: `file:///C:/repo` is + // representable as `/C:/repo` on POSIX. Require the inferred convention to match too. + let cwd_uses_native_convention = + cwd.infer_path_convention() == Some(PathConvention::native()); + // TODO(anp): Remove this parsing split once sandboxing supports foreign paths. + let native_cwd = match cwd.to_abs_path() { + Ok(cwd) if cwd_uses_native_convention => Some(cwd), + _ if sandbox == SandboxType::None => None, + Err(err) => return Err(FunctionCallError::RespondToModel(err.to_string())), + Ok(_) => { + return Err(FunctionCallError::RespondToModel(format!( + "path URI `{cwd}` does not use the host's native {} path convention", + PathConvention::native() + ))); + } + }; + let args: ExecCommandArgs = match native_cwd.as_ref() { + Some(native_cwd) => { + // The base path only resolves paths nested in the permissions config types. + parse_arguments_with_base_path(&arguments, native_cwd)? + } + None => { + // Parsing without a base only skips relative-path resolution inside the + // permissions config. That is safe only for a truly unsandboxed attempt; + // sandboxed attempts fall through and return the conversion error below. + parse_arguments(&arguments)? + } + }; let hook_command = args.cmd.clone(); - maybe_emit_implicit_skill_invocation( - session.as_ref(), - context.turn.as_ref(), - &hook_command, - &cwd, - ) - .await; + // TODO(anp) wire PathUri through implicit skills instead of skipping on foreign paths + if let Some(native_cwd) = native_cwd.as_ref() { + maybe_emit_implicit_skill_invocation( + session.as_ref(), + context.turn.as_ref(), + &hook_command, + native_cwd, + ) + .await; + } let process_id = manager.allocate_process_id().await; let shell_mode = shell_mode_for_environment(&turn.unified_exec_shell_mode, environment.as_ref()); @@ -191,10 +230,12 @@ impl ExecCommandHandler { let exec_permission_approvals_enabled = session.features().enabled(Feature::ExecPermissionApprovals); let requested_additional_permissions = additional_permissions.clone(); + // TODO(anp): Make permission matching operate on PathUri for remote environments. + let permission_cwd = native_cwd.as_ref().unwrap_or(&turn.config.cwd); let effective_additional_permissions = apply_granted_turn_permissions( context.session.as_ref(), &turn_environment.environment_id, - cwd.as_path(), + permission_cwd.as_path(), sandbox_permissions, additional_permissions, ) @@ -234,7 +275,7 @@ impl ExecCommandHandler { effective_additional_permissions.sandbox_permissions, effective_additional_permissions.additional_permissions, effective_additional_permissions.permissions_preapproved, - &cwd, + permission_cwd, ) }, |permissions| Ok(Some(permissions)), @@ -246,18 +287,20 @@ impl ExecCommandHandler { } }; - if let Some(output) = intercept_apply_patch( - &command, - &cwd, - fs.as_ref(), - turn_environment.clone(), - context.session.clone(), - context.turn.clone(), - Some(&tracker), - &context.call_id, - "exec_command", - ) - .await? + // TODO(anp) intercept apply_patch properly when cwd is a foreign path + if let Some(native_cwd) = native_cwd.as_ref() + && let Some(output) = intercept_apply_patch( + &command, + native_cwd, + fs.as_ref(), + turn_environment.clone(), + context.session.clone(), + context.turn.clone(), + Some(&tracker), + &context.call_id, + "exec_command", + ) + .await? { manager.release_process_id(process_id).await; return Ok(boxed_tool_output(ExecCommandToolOutput { diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 14c6ad74dc..803b8d6a9f 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -239,8 +239,10 @@ impl ToolOrchestrator { // Platform-specific flag gating is handled by SandboxManager::select_initial. let use_legacy_landlock = turn_ctx.config.features.use_legacy_landlock(); #[allow(deprecated)] - let sandbox_cwd = tool.sandbox_cwd(req).unwrap_or(&turn_ctx.cwd); - let sandbox_policy_cwd = PathUri::from_abs_path(sandbox_cwd); + let sandbox_policy_cwd = tool + .sandbox_cwd(req) + .cloned() + .unwrap_or_else(|| PathUri::from_abs_path(&turn_ctx.cwd)); let workspace_roots = turn_ctx.config.effective_workspace_roots(); let initial_attempt = SandboxAttempt { sandbox: initial_sandbox, diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 9d23fcab3c..9611429a7d 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -78,13 +78,15 @@ impl ApplyPatchRuntime { fn build_guardian_review_request( req: &ApplyPatchRequest, call_id: &str, - ) -> GuardianApprovalRequest { - GuardianApprovalRequest::ApplyPatch { + ) -> std::io::Result { + // TODO(anp): Remove this conversion once the guardian API supports PathUri. + let cwd = req.action.cwd.to_abs_path()?; + Ok(GuardianApprovalRequest::ApplyPatch { id: call_id.to_string(), - cwd: req.action.cwd.clone(), + cwd, files: req.file_paths.clone(), patch: req.action.patch.clone(), - } + }) } fn file_system_sandbox_context_for_attempt( @@ -149,7 +151,16 @@ impl Approvable for ApplyPatchRuntime { let guardian_review_id = ctx.guardian_review_id.clone(); Box::pin(async move { if let Some(review_id) = guardian_review_id { - let action = ApplyPatchRuntime::build_guardian_review_request(req, ctx.call_id); + let action = match ApplyPatchRuntime::build_guardian_review_request( + req, + ctx.call_id, + ) { + Ok(action) => action, + Err(err) => { + tracing::error!(cwd = %req.action.cwd, %err, "guardian apply_patch cwd is not host-native"); + return ReviewDecision::Abort; + } + }; return review_approval_request(session, turn, review_id, action, retry_reason) .await; } @@ -219,7 +230,7 @@ impl Approvable for ApplyPatchRuntime { } impl ToolRuntime for ApplyPatchRuntime { - fn sandbox_cwd<'a>(&self, req: &'a ApplyPatchRequest) -> Option<&'a AbsolutePathBuf> { + fn sandbox_cwd<'a>(&self, req: &'a ApplyPatchRequest) -> Option<&'a PathUri> { Some(&req.action.cwd) } @@ -234,9 +245,15 @@ impl ToolRuntime for ApplyPatchRunti let sandbox = Self::file_system_sandbox_context_for_attempt(req, attempt); let mut stdout = Vec::new(); let mut stderr = Vec::new(); + // TODO(anp): Teach apply_patch to operate on PathUri directly. + let cwd = req + .action + .cwd + .to_abs_path() + .map_err(|err| ToolError::Rejected(err.to_string()))?; let result = codex_apply_patch::apply_patch( &req.action.patch, - &req.action.cwd, + &cwd, &mut stdout, &mut stderr, fs.as_ref(), diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index 51ca0b98c8..0dc1ed9a67 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -54,7 +54,7 @@ async fn guardian_review_request_includes_patch_context() { .join("guardian-apply-patch-test.txt") .abs(); let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string()); - let expected_cwd = action.cwd.clone(); + let expected_cwd = action.cwd.to_abs_path().expect("native patch cwd"); let expected_patch = action.patch.clone(); let request = ApplyPatchRequest { turn_environment: test_turn_environment(codex_exec_server::LOCAL_ENVIRONMENT_ID), @@ -74,7 +74,8 @@ async fn guardian_review_request_includes_patch_context() { permissions_preapproved: false, }; - let guardian_request = ApplyPatchRuntime::build_guardian_review_request(&request, "call-1"); + let guardian_request = ApplyPatchRuntime::build_guardian_review_request(&request, "call-1") + .expect("native guardian request cwd"); assert_eq!( guardian_request, diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index 2b879e75ca..542589ad89 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -129,10 +129,10 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow:: ) .expect("prepare exec request"); - assert_eq!(exec_request.cwd, command_cwd); + assert_eq!(exec_request.cwd, PathUri::from_abs_path(&command_cwd)); assert_eq!( exec_request.windows_sandbox_policy_cwd, - native_sandbox_policy_cwd + PathUri::from_abs_path(&native_sandbox_policy_cwd) ); assert_eq!(exec_request.network, None); for key in PROXY_ENV_KEYS { diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 70390cb3c2..b8e417f856 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -172,6 +172,14 @@ pub(super) async fn try_run_zsh_fork( let exec_policy = Arc::new(RwLock::new( ctx.session.services.exec_policy.current().as_ref().clone(), )); + // TODO(anp): Keep PathUri through the shell escalation boundary. + let sandbox_cwd = sandbox_cwd + .to_abs_path() + .map_err(|err| ToolError::Rejected(err.to_string()))?; + // TODO(anp): Keep PathUri through the shell sandbox policy boundary. + let sandbox_policy_cwd = sandbox_policy_cwd + .to_abs_path() + .map_err(|err| ToolError::Rejected(err.to_string()))?; let command_executor = CoreShellCommandExecutor { command, cwd: sandbox_cwd, @@ -274,9 +282,19 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( let exec_policy = Arc::new(RwLock::new( ctx.session.services.exec_policy.current().as_ref().clone(), )); + // TODO(anp): Keep PathUri through the zsh-fork executor boundary. + let cwd = exec_request + .cwd + .to_abs_path() + .map_err(|err| ToolError::Rejected(err.to_string()))?; + // TODO(anp): Keep PathUri through the zsh-fork sandbox policy boundary. + let sandbox_policy_cwd = exec_request + .windows_sandbox_policy_cwd + .to_abs_path() + .map_err(|err| ToolError::Rejected(err.to_string()))?; let command_executor = CoreShellCommandExecutor { command: exec_request.command.clone(), - cwd: exec_request.cwd.clone(), + cwd, permission_profile: exec_request.permission_profile.clone(), file_system_sandbox_policy: exec_request.file_system_sandbox_policy.clone(), network_sandbox_policy: exec_request.network_sandbox_policy, @@ -285,7 +303,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( network: exec_request.network.clone(), windows_sandbox_level: exec_request.windows_sandbox_level, arg0: exec_request.arg0.clone(), - sandbox_policy_cwd: exec_request.windows_sandbox_policy_cwd.clone(), + sandbox_policy_cwd, windows_sandbox_workspace_roots: exec_request.windows_sandbox_workspace_roots.clone(), codex_linux_sandbox_exe: ctx.turn.config.codex_linux_sandbox_exe.clone(), use_legacy_landlock: ctx.turn.config.features.use_legacy_landlock(), @@ -857,14 +875,14 @@ impl CoreShellCommandExecutor { let result = crate::sandboxing::execute_exec_request_with_after_spawn( crate::sandboxing::ExecRequest { command: self.command.clone(), - cwd: self.cwd.clone(), + cwd: self.cwd.clone().into(), env: exec_env, exec_server_env_config: None, network: self.network.clone(), expiration: ExecExpiration::Cancellation(cancel_rx), capture_policy: ExecCapturePolicy::ShellTool, sandbox: self.sandbox, - windows_sandbox_policy_cwd: self.sandbox_policy_cwd.clone(), + windows_sandbox_policy_cwd: self.sandbox_policy_cwd.clone().into(), windows_sandbox_workspace_roots: self.windows_sandbox_workspace_roots.clone(), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: false, @@ -1010,7 +1028,8 @@ impl CoreShellCommandExecutor { Ok(PreparedExec { command: exec_request.command, - cwd: exec_request.cwd.to_path_buf(), + // TODO(anp): Keep PathUri through the execve-wrapper boundary. + cwd: exec_request.cwd.to_abs_path()?.to_path_buf(), env: exec_request.env, arg0: exec_request.arg0, }) diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 0b141faabc..80f0990b5c 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -21,7 +21,6 @@ use crate::tools::network_approval::NetworkApprovalSpec; use crate::tools::runtimes::RuntimePathPrepends; #[cfg(unix)] use crate::tools::runtimes::apply_zsh_fork_path_prepend; -use crate::tools::runtimes::build_sandbox_command; use crate::tools::runtimes::disable_powershell_profile_for_elevated_windows_sandbox; use crate::tools::runtimes::exec_env_for_sandbox_permissions; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; @@ -47,13 +46,15 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::ReviewDecision; +use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxablePreference; use codex_shell_command::powershell::prefix_powershell_script_with_utf8; use codex_tools::UnifiedExecShellMode; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use futures::future::BoxFuture; use std::collections::HashMap; use tokio_util::sync::CancellationToken; +use tracing::error; /// Request payload used by the unified-exec runtime after approvals and /// sandbox preferences have been resolved for the current turn. @@ -63,8 +64,8 @@ pub struct UnifiedExecRequest { pub shell_type: ShellType, pub hook_command: String, pub process_id: i32, - pub cwd: AbsolutePathBuf, - pub sandbox_cwd: AbsolutePathBuf, + pub cwd: PathUri, + pub sandbox_cwd: PathUri, pub turn_environment: TurnEnvironment, pub env: HashMap, pub exec_server_env_config: Option, @@ -85,7 +86,7 @@ pub struct UnifiedExecRequest { pub struct UnifiedExecApprovalKey { pub environment_id: String, pub command: Vec, - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, pub tty: bool, pub sandbox_permissions: SandboxPermissions, pub additional_permissions: Option, @@ -111,6 +112,24 @@ fn unified_exec_options( } } +fn build_unified_exec_sandbox_command( + command: &[String], + cwd: &PathUri, + env: &HashMap, + additional_permissions: Option, +) -> Result { + let (program, args) = command + .split_first() + .ok_or_else(|| ToolError::Rejected("command args are empty".to_string()))?; + Ok(SandboxCommand { + program: program.clone().into(), + args: args.to_vec(), + cwd: cwd.clone(), + env: env.clone(), + additional_permissions, + }) +} + impl<'a> UnifiedExecRuntime<'a> { /// Creates a runtime bound to the shared unified-exec process manager. pub fn new(manager: &'a UnifiedExecProcessManager, shell_mode: UnifiedExecShellMode) -> Self { @@ -155,12 +174,20 @@ impl Approvable for UnifiedExecRuntime<'_> { let turn = ctx.turn; let call_id = ctx.call_id.to_string(); let command = req.command.clone(); - let cwd = req.cwd.clone(); let environment_id = Some(req.turn_environment.environment_id.clone()); let retry_reason = ctx.retry_reason.clone(); let reason = retry_reason.clone().or_else(|| req.justification.clone()); let guardian_review_id = ctx.guardian_review_id.clone(); Box::pin(async move { + let native_cwd = match req.cwd.to_abs_path() { + Ok(c) => c, + Err(e) => { + // TODO(anp) make sandboxing work for foreign OSes, in the meantime this should + // be impossible for single-OS app-servers + error!(cwd = %req.cwd, ?e, "got non-native path in start_approval_async"); + return ReviewDecision::Abort; + } + }; if let Some(review_id) = guardian_review_id { return review_approval_request( session, @@ -169,7 +196,7 @@ impl Approvable for UnifiedExecRuntime<'_> { GuardianApprovalRequest::ExecCommand { id: call_id, command, - cwd: cwd.clone(), + cwd: native_cwd.clone(), sandbox_permissions: req.sandbox_permissions, additional_permissions: req.additional_permissions.clone(), justification: req.justification.clone(), @@ -188,7 +215,7 @@ impl Approvable for UnifiedExecRuntime<'_> { /*approval_id*/ None, environment_id, command, - cwd.clone(), + native_cwd, reason, ctx.network_approval_context.clone(), req.exec_approval_requirement @@ -226,7 +253,7 @@ impl Approvable for UnifiedExecRuntime<'_> { } impl<'a> ToolRuntime for UnifiedExecRuntime<'a> { - fn sandbox_cwd<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b AbsolutePathBuf> { + fn sandbox_cwd<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b PathUri> { Some(&req.sandbox_cwd) } @@ -249,7 +276,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt call_id: ctx.call_id.clone(), tool_name: flat_tool_name(&ctx.tool_name).into_owned(), command: req.command.clone(), - cwd: req.cwd.clone(), + cwd: req.cwd.to_abs_path().ok()?, sandbox_permissions: req.sandbox_permissions, additional_permissions: req.additional_permissions.clone(), justification: req.justification.clone(), @@ -272,7 +299,17 @@ impl<'a> ToolRuntime for UnifiedExecRunt .shell .as_ref() .unwrap_or(session_shell.as_ref()); - let shell_snapshot_location = req.turn_environment.shell_snapshot(&req.cwd); + let environment_is_remote = req.turn_environment.environment.is_remote(); + let shell_snapshot_location = if environment_is_remote { + None + } else { + // TODO(anp): Make shell snapshot lookup accept PathUri. + let native_cwd = req + .cwd + .to_abs_path() + .map_err(|err| ToolError::Rejected(err.to_string()))?; + req.turn_environment.shell_snapshot(&native_cwd) + }; let (file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions(); let launch_sandbox_permissions = sandbox_permissions_preserving_denied_reads( req.sandbox_permissions, @@ -286,7 +323,6 @@ impl<'a> ToolRuntime for UnifiedExecRunt if let Some(network) = managed_network { network.apply_to_env(&mut env); } - let environment_is_remote = req.turn_environment.environment.is_remote(); let explicit_env_overrides = req.explicit_env_overrides.clone(); #[cfg(unix)] let runtime_path_prepends = { @@ -333,14 +369,18 @@ impl<'a> ToolRuntime for UnifiedExecRunt }; if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode { - let command = - build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone()) - .map_err(|error| match error { - ToolError::Rejected(_) => { - ToolError::Rejected("missing command line for PTY".to_string()) - } - error @ ToolError::Codex(_) => error, - })?; + let command = build_unified_exec_sandbox_command( + &command, + &req.cwd, + &env, + req.additional_permissions.clone(), + ) + .map_err(|error| match error { + ToolError::Rejected(_) => { + ToolError::Rejected("missing command line for PTY".to_string()) + } + error @ ToolError::Codex(_) => error, + })?; let options = unified_exec_options(attempt.network_denial_cancellation_token.clone()); let mut exec_env = attempt .env_for(command, options, managed_network) @@ -389,14 +429,18 @@ impl<'a> ToolRuntime for UnifiedExecRunt } } } - let command = - build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone()) - .map_err(|error| match error { - ToolError::Rejected(_) => { - ToolError::Rejected("missing command line for PTY".to_string()) - } - error @ ToolError::Codex(_) => error, - })?; + let command = build_unified_exec_sandbox_command( + &command, + &req.cwd, + &env, + req.additional_permissions.clone(), + ) + .map_err(|error| match error { + ToolError::Rejected(_) => { + ToolError::Rejected("missing command line for PTY".to_string()) + } + error @ ToolError::Codex(_) => error, + })?; let options = unified_exec_options(attempt.network_denial_cancellation_token.clone()); let mut exec_env = attempt .env_for(command, options, managed_network) @@ -431,16 +475,17 @@ mod tests { use codex_exec_server::Environment; use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_tools::ZshForkConfig; + use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use std::sync::Arc; use std::time::Duration; use tempfile::tempdir; - fn test_turn_environment(cwd: AbsolutePathBuf) -> TurnEnvironment { + fn test_turn_environment(cwd: PathUri) -> TurnEnvironment { TurnEnvironment::new( LOCAL_ENVIRONMENT_ID.to_string(), Arc::new(Environment::default_for_tests()), - PathUri::from_abs_path(&cwd), + cwd, /*shell*/ None, ) } @@ -501,9 +546,9 @@ mod tests { shell_type: ShellType::Sh, hook_command: "pwd".to_string(), process_id: 1000, - cwd, - sandbox_cwd: sandbox_cwd.clone(), - turn_environment: test_turn_environment(sandbox_cwd.clone()), + cwd: cwd.into(), + sandbox_cwd: sandbox_cwd.clone().into(), + turn_environment: test_turn_environment(sandbox_cwd.clone().into()), env: HashMap::new(), exec_server_env_config: None, explicit_env_overrides: HashMap::new(), @@ -520,7 +565,10 @@ mod tests { }, }; - assert_eq!(runtime.sandbox_cwd(&request), Some(&sandbox_cwd)); + assert_eq!( + runtime.sandbox_cwd(&request), + Some(&PathUri::from_abs_path(&sandbox_cwd)) + ); } #[tokio::test] @@ -600,9 +648,9 @@ mod tests { shell_type: ShellType::Zsh, hook_command: "echo hi".to_string(), process_id: 1000, - cwd: cwd.clone(), - sandbox_cwd: cwd.clone(), - turn_environment: test_turn_environment(cwd), + cwd: cwd.clone().into(), + sandbox_cwd: cwd.clone().into(), + turn_environment: test_turn_environment(cwd.into()), env: HashMap::new(), exec_server_env_config: None, explicit_env_overrides: HashMap::new(), diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 499cdfbdae..cc7a7913dd 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -394,7 +394,7 @@ pub(crate) trait ToolRuntime: Approvable + Sandboxable { None } - fn sandbox_cwd<'a>(&self, _req: &'a Req) -> Option<&'a AbsolutePathBuf> { + fn sandbox_cwd<'a>(&self, _req: &'a Req) -> Option<&'a PathUri> { None } diff --git a/codex-rs/core/src/unified_exec/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs index 33dbf843dc..38010754e9 100644 --- a/codex-rs/core/src/unified_exec/async_watcher.rs +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -22,7 +22,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandOutputDeltaEvent; use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::ExecOutputStream; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; pub(crate) const TRAILING_OUTPUT_GRACE: Duration = Duration::from_millis(100); @@ -110,7 +110,7 @@ pub(crate) fn spawn_exit_watcher( turn_ref: Arc, call_id: String, command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, process_id: i32, transcript: Arc>, started_at: Instant, @@ -197,7 +197,7 @@ pub(crate) async fn emit_exec_end_for_unified_exec( turn_ref: Arc, call_id: String, command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, process_id: Option, transcript: Arc>, fallback_output: String, @@ -242,7 +242,7 @@ pub(crate) async fn emit_failed_exec_end_for_unified_exec( turn_ref: Arc, call_id: String, command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, process_id: Option, transcript: Arc>, fallback_output: String, diff --git a/codex-rs/core/src/unified_exec/errors.rs b/codex-rs/core/src/unified_exec/errors.rs index 0576e7d7cf..b4e2882c9b 100644 --- a/codex-rs/core/src/unified_exec/errors.rs +++ b/codex-rs/core/src/unified_exec/errors.rs @@ -1,4 +1,5 @@ use codex_protocol::exec_output::ExecToolCallOutput; +use codex_utils_path_uri::PathUri; use thiserror::Error; #[derive(Debug, Error)] @@ -23,6 +24,8 @@ pub(crate) enum UnifiedExecError { message: String, output: ExecToolCallOutput, }, + #[error("{path} is not valid on {}", std::env::consts::OS)] + ForeignPath { path: PathUri }, } impl UnifiedExecError { diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 23dd3bf1b5..34d88c8149 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -30,8 +30,8 @@ use std::sync::Weak; use codex_network_proxy::NetworkProxy; use codex_protocol::models::AdditionalPermissionProfile; use codex_tools::UnifiedExecShellMode; -use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_path_uri::PathUri; use rand::Rng; use rand::rng; use tokio::sync::Mutex; @@ -96,8 +96,8 @@ pub(crate) struct ExecCommandRequest { pub process_id: i32, pub yield_time_ms: u64, pub max_output_tokens: Option, - pub cwd: AbsolutePathBuf, - pub sandbox_cwd: AbsolutePathBuf, + pub cwd: PathUri, + pub sandbox_cwd: PathUri, pub turn_environment: TurnEnvironment, pub shell_mode: UnifiedExecShellMode, pub network: Option, @@ -156,7 +156,7 @@ struct ProcessEntry { process: Arc, call_id: String, process_id: i32, - cwd: AbsolutePathBuf, + cwd: PathUri, initial_exec_command_active: Arc, hook_command: String, tty: bool, diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index dce521f6f4..0d4e21d93c 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -20,6 +20,7 @@ use codex_exec_server::StartedExecProcess; use codex_exec_server::WriteResponse; use codex_exec_server::WriteStatus; use codex_sandboxing::SandboxType; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::approx_token_count; use core_test_support::get_remote_test_env; @@ -128,7 +129,7 @@ async fn exec_command_with_tty( process: Arc::clone(&process), call_id: context.call_id.clone(), process_id, - cwd: cwd.clone(), + cwd: cwd.clone().into(), initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(true)), hook_command: cmd.to_string(), tty, @@ -370,7 +371,7 @@ async fn unified_exec_persists_across_requests() -> anyhow::Result<()> { item_id: "call".to_string(), process_id: process_id.to_string(), command: "bash -i".to_string(), - cwd, + cwd: cwd.into(), }] ); @@ -674,7 +675,7 @@ async fn terminating_initial_exec_command_rechecks_initial_response_state() -> a process, call_id: "call".to_string(), process_id, - cwd, + cwd: cwd.into(), initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(true)), hook_command: "sleep 60".to_string(), tty: true, @@ -747,7 +748,7 @@ async fn terminating_during_stdin_poll_returns_exited_response() -> anyhow::Resu process: Arc::clone(&process), call_id: "call".to_string(), process_id, - cwd, + cwd: cwd.into(), initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(false)), hook_command: "sleep 60".to_string(), tty: true, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index e683df963c..0aac491823 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -55,7 +55,6 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; use codex_protocol::protocol::ExecCommandSource; use codex_tools::ToolName; -use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::approx_token_count; use codex_utils_path_uri::PathUri; @@ -157,7 +156,7 @@ fn exec_server_params_for_request( codex_exec_server::ExecParams { process_id: exec_server_process_id(process_id).into(), argv: request.command.clone(), - cwd: PathUri::from_abs_path(&request.cwd), + cwd: request.cwd.clone(), env_policy, env, tty, @@ -294,7 +293,7 @@ async fn emit_failed_initial_exec_end_if_unstored( process_started_alive: bool, context: &UnifiedExecContext, request: &ExecCommandRequest, - cwd: AbsolutePathBuf, + cwd: PathUri, transcript: Arc>, fallback_output: String, message: String, @@ -841,7 +840,7 @@ impl UnifiedExecProcessManager { context: &UnifiedExecContext, command: &[String], hook_command: String, - cwd: AbsolutePathBuf, + cwd: PathUri, started_at: Instant, process_id: i32, tty: bool, @@ -899,6 +898,14 @@ impl UnifiedExecProcessManager { #[cfg(target_os = "windows")] if request.sandbox == codex_sandboxing::SandboxType::WindowsRestrictedToken { + // TODO(anp): Keep PathUri through the Windows sandbox launch boundary. + let native_cwd = + request + .cwd + .to_abs_path() + .map_err(|_| UnifiedExecError::ForeignPath { + path: request.cwd.clone(), + })?; let codex_home = crate::config::find_codex_home().map_err(|err| { UnifiedExecError::create_process(format!( "windows sandbox: failed to resolve codex_home: {err}" @@ -933,7 +940,7 @@ impl UnifiedExecProcessManager { request.windows_sandbox_workspace_roots.as_slice(), codex_home.as_ref(), request.command.clone(), - request.cwd.as_path(), + native_cwd.as_path(), request.env.clone(), request.network.is_some(), None, @@ -955,7 +962,7 @@ impl UnifiedExecProcessManager { request.windows_sandbox_workspace_roots.as_slice(), codex_home.as_ref(), request.command.clone(), - request.cwd.as_path(), + native_cwd.as_path(), request.env.clone(), None, &additional_deny_read_paths, @@ -991,6 +998,14 @@ impl UnifiedExecProcessManager { return UnifiedExecProcess::from_exec_server_started(started, request.sandbox).await; } + // TODO(anp): Keep PathUri through the local PTY/process launch boundary. + let native_cwd = request + .cwd + .to_abs_path() + .map_err(|_| UnifiedExecError::ForeignPath { + path: request.cwd.clone(), + })?; + let (program, args) = request .command .split_first() @@ -999,7 +1014,7 @@ impl UnifiedExecProcessManager { codex_utils_pty::pty::spawn_process_with_inherited_fds( program, args, - request.cwd.as_path(), + native_cwd.as_path(), &request.env, &request.arg0, codex_utils_pty::TerminalSize::default(), @@ -1010,7 +1025,7 @@ impl UnifiedExecProcessManager { codex_utils_pty::pipe::spawn_process_no_stdin_with_inherited_fds( program, args, - request.cwd.as_path(), + native_cwd.as_path(), &request.env, &request.arg0, &inherited_fds, @@ -1026,7 +1041,7 @@ impl UnifiedExecProcessManager { pub(super) async fn open_session_with_sandbox( &self, request: &ExecCommandRequest, - cwd: AbsolutePathBuf, + cwd: PathUri, context: &UnifiedExecContext, ) -> Result<(UnifiedExecProcess, Option), UnifiedExecError> { let local_policy_env = create_env( diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index 49758ec799..c399b9c988 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -78,7 +78,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { let permission_profile = codex_protocol::models::PermissionProfile::Disabled; let request = ExecRequest { command: vec!["bash".to_string(), "-lc".to_string(), "true".to_string()], - cwd: cwd.clone(), + cwd: cwd.clone().into(), env: HashMap::from([ ("HOME".to_string(), "/client-home".to_string()), ("PATH".to_string(), "/sandbox-path".to_string()), @@ -101,7 +101,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { expiration: crate::exec::ExecExpiration::DefaultTimeout, capture_policy: crate::exec::ExecCapturePolicy::ShellTool, sandbox: codex_sandboxing::SandboxType::None, - windows_sandbox_policy_cwd: cwd.clone(), + windows_sandbox_policy_cwd: cwd.clone().into(), windows_sandbox_workspace_roots: vec![cwd], windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -116,7 +116,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { exec_server_params_for_request(/*process_id*/ 123, &request, /*tty*/ true); assert_eq!(params.process_id.as_str(), "123"); - assert_eq!(params.cwd, PathUri::from_abs_path(&request.cwd)); + assert_eq!(params.cwd, request.cwd); assert!(params.env_policy.is_some()); assert_eq!( params.env, @@ -200,9 +200,9 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { yield_time_ms: 1000, max_output_tokens: None, #[allow(deprecated)] - cwd: turn.cwd.clone(), + cwd: turn.cwd.clone().into(), #[allow(deprecated)] - sandbox_cwd: turn.cwd.clone(), + sandbox_cwd: turn.cwd.clone().into(), turn_environment: turn .environments .primary() @@ -229,7 +229,7 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { &context, &request, #[allow(deprecated)] - turn.cwd.clone(), + turn.cwd.clone().into(), transcript, "PRE_DENIAL_MARKER".to_string(), "Network access denied".to_string(), diff --git a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs index c092c7dcc4..593dd7ea05 100644 --- a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs +++ b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs @@ -20,7 +20,6 @@ use codex_features::Feature; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::ExecCommandStatus; use codex_protocol::protocol::Op; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::TurnEnvironmentSelections; @@ -58,6 +57,9 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { let arguments = serde_json::to_string(&json!({ "cmd": COMMAND, "login": false, + // An absolute foreign workdir should replace the selected environment cwd and + // reach exec-server without conversion to the host path convention. + "workdir": r"C:\windows", "yield_time_ms": 10_000, }))?; let response_mock = mount_sse_sequence( @@ -125,6 +127,10 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { }) .await?; + // TODO(anp): Re-enable these event assertions once exec command events retain a + // PathUri cwd. Today the host-native event conversion drops begin/end events for a + // foreign cwd. + /* let mut begin = None; let mut end = None; let mut turn_complete = false; @@ -152,13 +158,16 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { "unexpected command: {:?}", begin.command ); - assert_eq!( - &begin.command[1..], - ["-NoProfile", "-Command", COMMAND] - ); + assert_eq!(&begin.command[1..], ["-NoProfile", "-Command", COMMAND]); let end = end.context("exec_command should emit an end event")?; assert_eq!((end.exit_code, end.status), (0, ExecCommandStatus::Completed)); + */ + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; let request = response_mock .last_request() diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index 9e9dbcf6b4..ea420c9ea2 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -341,6 +341,8 @@ fn spawn_command( #[cfg(not(unix))] let _ = arg0; command.args(args); + // TODO(anp): Keep PathUri through the filesystem helper launch boundary. + let cwd = cwd.to_abs_path().map_err(io_error)?; command.current_dir(cwd.as_path()); command.env_clear(); command.envs(env); diff --git a/codex-rs/exec/tests/suite/sandbox.rs b/codex-rs/exec/tests/suite/sandbox.rs index 4b8cecc515..3171a445a1 100644 --- a/codex-rs/exec/tests/suite/sandbox.rs +++ b/codex-rs/exec/tests/suite/sandbox.rs @@ -60,7 +60,12 @@ async fn spawn_command_under_sandbox( child.arg0(arg0); } child.args(args); - child.current_dir(exec_request.cwd); + // TODO(anp): Keep PathUri through the macOS sandbox process launch boundary. + let native_cwd = exec_request + .cwd + .to_abs_path() + .map_err(|err| io::Error::other(err.to_string()))?; + child.current_dir(native_cwd); child.env_clear(); child.envs(exec_request.env); diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 6bfbaa3459..25032a8050 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -109,8 +109,8 @@ pub struct SandboxCommand { #[derive(Debug)] pub struct SandboxExecRequest { pub command: Vec, - pub cwd: AbsolutePathBuf, - pub sandbox_policy_cwd: AbsolutePathBuf, + pub cwd: PathUri, + pub sandbox_policy_cwd: PathUri, pub env: HashMap, pub network: Option, pub sandbox: SandboxType, @@ -150,6 +150,52 @@ pub struct SandboxDirectSpawnTransformRequest<'a> { pub workspace_roots: &'a [AbsolutePathBuf], } +// TODO(anp): Revisit this preparation type once this module's PathUri migration is complete. +struct PendingSandboxedExecRequest { + native_command_cwd: AbsolutePathBuf, + native_sandbox_policy_cwd: AbsolutePathBuf, + effective_permission_profile: PermissionProfile, + effective_file_system_policy: FileSystemSandboxPolicy, + effective_network_policy: NetworkSandboxPolicy, +} + +impl PendingSandboxedExecRequest { + fn new( + command_cwd: &PathUri, + sandbox_policy_cwd: &PathUri, + effective_permission_profile: PermissionProfile, + managed_mitm_ca_trust_bundle_path: Option<&AbsolutePathBuf>, + ) -> Result { + // TODO(anp): Move PathUri conversion into the platform sandbox implementations. + let native_command_cwd = command_cwd.to_abs_path().map_err(|source| { + SandboxTransformError::InvalidCommandCwd { + cwd: command_cwd.clone(), + source, + } + })?; + let native_sandbox_policy_cwd = sandbox_policy_cwd.to_abs_path().map_err(|source| { + SandboxTransformError::InvalidSandboxPolicyCwd { + cwd: sandbox_policy_cwd.clone(), + source, + } + })?; + let effective_permission_profile = with_managed_mitm_ca_readable_root( + effective_permission_profile, + managed_mitm_ca_trust_bundle_path, + native_sandbox_policy_cwd.as_path(), + ); + let (effective_file_system_policy, effective_network_policy) = + effective_permission_profile.to_runtime_permissions(); + Ok(Self { + native_command_cwd, + native_sandbox_policy_cwd, + effective_permission_profile, + effective_file_system_policy, + effective_network_policy, + }) + } +} + #[derive(Debug)] pub enum SandboxTransformError { InvalidCommandCwd { @@ -266,47 +312,37 @@ impl SandboxManager { windows_sandbox_level, windows_sandbox_private_desktop, } = request; - let native_command_cwd = command.cwd.to_abs_path().map_err(|source| { - SandboxTransformError::InvalidCommandCwd { - cwd: command.cwd.clone(), - source, - } - })?; - let native_sandbox_policy_cwd = sandbox_policy_cwd.to_abs_path().map_err(|source| { - SandboxTransformError::InvalidSandboxPolicyCwd { - cwd: sandbox_policy_cwd.clone(), - source, - } - })?; let additional_permissions = command.additional_permissions.take(); let managed_mitm_ca_trust_bundle_path = network.and_then(NetworkProxy::managed_mitm_ca_trust_bundle_path); - let effective_permission_profile = + let base_effective_permission_profile = effective_permission_profile(permissions, additional_permissions.as_ref()); - let effective_permission_profile = with_managed_mitm_ca_readable_root( - effective_permission_profile, + let pending_sandboxed_request = PendingSandboxedExecRequest::new( + &command.cwd, + sandbox_policy_cwd, + base_effective_permission_profile.clone(), managed_mitm_ca_trust_bundle_path.as_ref(), - native_sandbox_policy_cwd.as_path(), ); - let (effective_file_system_policy, effective_network_policy) = - effective_permission_profile.to_runtime_permissions(); + let (base_file_system_policy, base_network_policy) = + base_effective_permission_profile.to_runtime_permissions(); let mut argv = Vec::with_capacity(1 + command.args.len()); argv.push(command.program); argv.extend(command.args.into_iter().map(OsString::from)); - let (argv, arg0_override) = match sandbox { - SandboxType::None => (os_argv_to_strings(argv), None), + let (argv, arg0_override, pending_sandboxed_request) = match sandbox { + SandboxType::None => (os_argv_to_strings(argv), None, None), #[cfg(target_os = "macos")] SandboxType::MacosSeatbelt => { use crate::seatbelt::CreateSeatbeltCommandArgsParams; use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use crate::seatbelt::create_seatbelt_command_args; + let pending = pending_sandboxed_request?; let mut args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams { command: os_argv_to_strings(argv), - file_system_sandbox_policy: &effective_file_system_policy, - network_sandbox_policy: effective_network_policy, - sandbox_policy_cwd: native_sandbox_policy_cwd.as_path(), + file_system_sandbox_policy: &pending.effective_file_system_policy, + network_sandbox_policy: pending.effective_network_policy, + sandbox_policy_cwd: pending.native_sandbox_policy_cwd.as_path(), enforce_managed_network, network, extra_allow_unix_sockets: &[], @@ -314,52 +350,84 @@ impl SandboxManager { let mut full_command = Vec::with_capacity(1 + args.len()); full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string()); full_command.append(&mut args); - (full_command, None) + (full_command, None, Some(pending)) } #[cfg(not(target_os = "macos"))] SandboxType::MacosSeatbelt => return Err(SandboxTransformError::SeatbeltUnavailable), SandboxType::LinuxSeccomp => { + let pending = pending_sandboxed_request?; let exe = codex_linux_sandbox_exe .ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?; let allow_proxy_network = allow_network_for_proxy(enforce_managed_network); #[cfg(target_os = "linux")] ensure_linux_bubblewrap_is_supported( - &effective_file_system_policy, + &pending.effective_file_system_policy, use_legacy_landlock, allow_proxy_network, is_wsl1(), )?; let mut args = create_linux_sandbox_command_args_for_permission_profile( os_argv_to_strings(argv), - native_command_cwd.as_path(), - &effective_permission_profile, - native_sandbox_policy_cwd.as_path(), + pending.native_command_cwd.as_path(), + &pending.effective_permission_profile, + pending.native_sandbox_policy_cwd.as_path(), use_legacy_landlock, allow_proxy_network, ); let mut full_command = Vec::with_capacity(1 + args.len()); full_command.push(os_string_to_command_component(exe.as_os_str().to_owned())); full_command.append(&mut args); - (full_command, Some(linux_sandbox_arg0_override(exe))) + ( + full_command, + Some(linux_sandbox_arg0_override(exe)), + Some(pending), + ) } #[cfg(target_os = "windows")] - SandboxType::WindowsRestrictedToken => (os_argv_to_strings(argv), None), + SandboxType::WindowsRestrictedToken => ( + os_argv_to_strings(argv), + None, + Some(pending_sandboxed_request?), + ), #[cfg(not(target_os = "windows"))] - SandboxType::WindowsRestrictedToken => (os_argv_to_strings(argv), None), + SandboxType::WindowsRestrictedToken => ( + os_argv_to_strings(argv), + None, + Some(pending_sandboxed_request?), + ), }; + // Unsandboxed exec-server requests may have foreign cwd values that cannot be prepared + // locally, but their effective permissions must still be preserved. In that case, carry + // forward the base profile and its derived runtime policies. + let (permission_profile, file_system_sandbox_policy, network_sandbox_policy) = + pending_sandboxed_request.map_or( + ( + base_effective_permission_profile, + base_file_system_policy, + base_network_policy, + ), + |pending| { + ( + pending.effective_permission_profile, + pending.effective_file_system_policy, + pending.effective_network_policy, + ) + }, + ); + Ok(SandboxExecRequest { command: argv, - cwd: native_command_cwd, - sandbox_policy_cwd: native_sandbox_policy_cwd, + cwd: command.cwd, + sandbox_policy_cwd: sandbox_policy_cwd.clone(), env: command.env, network: network.cloned(), sandbox, windows_sandbox_level, windows_sandbox_private_desktop, - permission_profile: effective_permission_profile, - file_system_sandbox_policy: effective_file_system_policy, - network_sandbox_policy: effective_network_policy, + permission_profile, + file_system_sandbox_policy, + network_sandbox_policy, arg0: arg0_override, }) } @@ -406,6 +474,21 @@ fn wrap_windows_sandbox_exec_request_for_direct_spawn( workspace_roots: &[AbsolutePathBuf], codex_home: &Path, ) -> Result<(), SandboxTransformError> { + // TODO(anp): Keep PathUri through the Windows sandbox wrapper boundary. + let native_cwd = + request + .cwd + .to_abs_path() + .map_err(|source| SandboxTransformError::InvalidCommandCwd { + cwd: request.cwd.clone(), + source, + })?; + let native_sandbox_policy_cwd = request.sandbox_policy_cwd.to_abs_path().map_err(|source| { + SandboxTransformError::InvalidSandboxPolicyCwd { + cwd: request.sandbox_policy_cwd.clone(), + source, + } + })?; let Some(program) = request.command.first_mut() else { return Err(SandboxTransformError::WindowsSandboxPreparation( "sandbox command was empty".to_string(), @@ -423,14 +506,14 @@ fn wrap_windows_sandbox_exec_request_for_direct_spawn( resolve_windows_elevated_filesystem_overrides( request.sandbox, &request.permission_profile, - &request.sandbox_policy_cwd, + &native_sandbox_policy_cwd, use_elevated, ) } else { resolve_windows_restricted_token_filesystem_overrides( request.sandbox, &request.permission_profile, - &request.sandbox_policy_cwd, + &native_sandbox_policy_cwd, request.windows_sandbox_level, ) } @@ -454,7 +537,7 @@ fn wrap_windows_sandbox_exec_request_for_direct_spawn( let mut wrapper_args = codex_windows_sandbox::create_windows_sandbox_command_args_for_permission_profile( inner_command, - &request.cwd, + &native_cwd, workspace_roots, &request.env, &request.permission_profile, diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index 8f736b994f..344787f141 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -74,10 +74,13 @@ fn restricted_file_system_uses_platform_sandbox_without_managed_network() { } #[test] -fn transform_preserves_unrestricted_file_system_policy_for_restricted_network() { +fn unsandboxed_transform_preserves_foreign_cwd_and_unrestricted_file_system_policy() { let manager = SandboxManager::new(); - let cwd = AbsolutePathBuf::current_dir().expect("current dir"); - let cwd_uri = PathUri::from_abs_path(&cwd); + let cwd_uri = if cfg!(windows) { + PathUri::parse("file:///workspace/remote").expect("POSIX path URI") + } else { + PathUri::parse("file:///C:/workspace/remote").expect("Windows path URI") + }; let permissions = PermissionProfile::from_runtime_permissions( &FileSystemSandboxPolicy::unrestricted(), NetworkSandboxPolicy::Restricted, @@ -103,8 +106,8 @@ fn transform_preserves_unrestricted_file_system_policy_for_restricted_network() }) .expect("transform"); - assert_eq!(exec_request.cwd, cwd); - assert_eq!(exec_request.sandbox_policy_cwd, cwd); + assert_eq!(exec_request.cwd, cwd_uri); + assert_eq!(exec_request.sandbox_policy_cwd, cwd_uri); assert_eq!( exec_request.file_system_sandbox_policy, FileSystemSandboxPolicy::unrestricted() diff --git a/codex-rs/utils/path-uri/src/api_path_string.rs b/codex-rs/utils/path-uri/src/api_path_string.rs index f9ae8690c8..0881c64d7c 100644 --- a/codex-rs/utils/path-uri/src/api_path_string.rs +++ b/codex-rs/utils/path-uri/src/api_path_string.rs @@ -33,6 +33,10 @@ use ts_rs::TS; pub struct LegacyAppPathString(String); impl LegacyAppPathString { + pub(super) fn from_str(path: &str) -> Self { + Self(path.to_string()) + } + /// Renders an absolute path using the current host's path convention. pub fn from_abs_path(path: &AbsolutePathBuf) -> Self { Self(path.to_string_lossy().into_owned()) diff --git a/codex-rs/utils/path-uri/src/lib.rs b/codex-rs/utils/path-uri/src/lib.rs index 18142c7b19..a7f3c83369 100644 --- a/codex-rs/utils/path-uri/src/lib.rs +++ b/codex-rs/utils/path-uri/src/lib.rs @@ -211,18 +211,20 @@ impl PathUri { Some(Self(url)) } - /// Lexically joins a relative URI path onto this URI. + /// Lexically resolves native absolute or relative path text against this URI. /// + /// Path text is interpreted using the POSIX or Windows convention inferred + /// from the base URI. An absolute path replaces the base URI's path, while a + /// relative path is appended lexically. Windows root-relative paths retain + /// the base drive or UNC share, while drive-relative paths are rejected. /// Empty and `.` segments are ignored, while `..` removes one segment - /// without escaping the URI root. Literal `%`, `?`, and `#` characters are - /// percent-encoded as filename text. Paths containing a null character are - /// rejected because they cannot be safely converted to native paths. + /// without escaping the POSIX root, Windows drive, or UNC share. Literal + /// `%`, `?`, and `#` characters are percent-encoded as filename text. Paths + /// containing a null character are rejected because they cannot be safely + /// converted to native paths. /// Opaque fallback URIs created by [`Self::from_abs_path`] reject non-empty /// joins. pub fn join(&self, path: &str) -> Result { - if path.starts_with('/') { - return Err(PathUriParseError::JoinPathMustBeRelative(path.to_string())); - } if path.contains('\0') { return Err(PathUriParseError::InvalidFileUriPath { path: path.to_string(), @@ -231,6 +233,24 @@ impl PathUri { if path.is_empty() { return Ok(self.clone()); } + let convention = + self.infer_path_convention() + .ok_or_else(|| PathUriParseError::InvalidFileUriPath { + path: self.to_string(), + })?; + // An absolute native path is already fully resolved, so replace the base URI's main path + // instead of appending it. + if let Ok(absolute) = LegacyAppPathString::from_str(path).to_path_uri(convention) { + return Ok(absolute); + } + let path_bytes = path.as_bytes(); + if convention == PathConvention::Windows + && matches!(path_bytes, [drive, b':', ..] if drive.is_ascii_alphabetic()) + { + return Err(PathUriParseError::InvalidFileUriPath { + path: path.to_string(), + }); + } if decode_bad_path_uri(&self.0).is_some() { return Err(PathUriParseError::InvalidFileUriPath { path: self.to_string(), @@ -238,19 +258,40 @@ impl PathUri { } let mut url = self.0.clone(); + let anchor_depth = usize::from(convention == PathConvention::Windows); + let mut depth = url + .path_segments() + .map(|segments| segments.filter(|segment| !segment.is_empty()).count()) + .unwrap_or_default(); + let windows_root_relative = convention == PathConvention::Windows + && matches!(path_bytes, [b'\\' | b'/', rest @ ..] if !matches!(rest, [b'\\' | b'/', ..])); { let Ok(mut segments) = url.path_segments_mut() else { unreachable!("validated file URLs support hierarchical path segments"); }; segments.pop_if_empty(); + if windows_root_relative { + while depth > anchor_depth { + segments.pop(); + depth -= 1; + } + } + let path = match convention { + PathConvention::Posix => path.to_string(), + PathConvention::Windows => path.replace('\\', "/"), + }; for component in path.split('/') { match component { "" | "." => {} ".." => { - segments.pop(); + if depth > anchor_depth { + segments.pop(); + depth -= 1; + } } component => { segments.push(component); + depth += 1; } } } @@ -350,6 +391,12 @@ impl TryFrom for PathUri { } } +impl From for PathUri { + fn from(p: AbsolutePathBuf) -> Self { + Self::from_abs_path(&p) + } +} + impl<'de> Deserialize<'de> for PathUri { fn deserialize(deserializer: D) -> Result where diff --git a/codex-rs/utils/path-uri/src/tests.rs b/codex-rs/utils/path-uri/src/tests.rs index 1992cc9c75..6e7dc7294d 100644 --- a/codex-rs/utils/path-uri/src/tests.rs +++ b/codex-rs/utils/path-uri/src/tests.rs @@ -522,13 +522,72 @@ fn join_normalizes_relative_uri_segments() { } #[test] -fn join_rejects_absolute_and_null_paths() { +fn join_replaces_posix_absolute_path() { let base = PathUri::parse("file:///workspace").expect("valid base URI"); - assert!(matches!( + assert_eq!( base.join("/src"), - Err(PathUriParseError::JoinPathMustBeRelative(path)) if path == "/src" - )); + Ok(PathUri::parse("file:///src").expect("valid absolute URI")) + ); +} + +#[test] +fn join_replaces_windows_absolute_path() { + let base = PathUri::parse("file:///C:/workspace/src").expect("valid base URI"); + + assert_eq!( + base.join(r"D:\tmp\test.rs"), + Ok(PathUri::parse("file:///D:/tmp/test.rs").expect("valid absolute URI")) + ); +} + +#[test] +fn join_windows_root_relative_path_preserves_drive_or_share() { + for (base, path, expected) in [ + ("file:///C:/base/dir", r"\Windows", "file:///C:/Windows"), + ( + "file://server/share/base/dir", + r"\Windows", + "file://server/share/Windows", + ), + ] { + let base = PathUri::parse(base).expect("valid base URI"); + let expected = PathUri::parse(expected).expect("valid expected URI"); + assert_eq!(base.join(path), Ok(expected), "joining {path}"); + } +} + +#[test] +fn join_rejects_windows_drive_relative_path() { + let base = PathUri::parse("file:///C:/base").expect("valid base URI"); + + assert_eq!( + base.join(r"D:tmp"), + Err(PathUriParseError::InvalidFileUriPath { + path: r"D:tmp".to_string(), + }) + ); +} + +#[test] +fn join_parent_segments_preserve_windows_drive_or_share_anchor() { + for (base, expected) in [ + ("file:///C:/base/dir", "file:///C:/Windows"), + ( + "file://server/share/base/dir", + "file://server/share/Windows", + ), + ] { + let base = PathUri::parse(base).expect("valid base URI"); + let expected = PathUri::parse(expected).expect("valid expected URI"); + assert_eq!(base.join(r"..\..\..\Windows"), Ok(expected)); + } +} + +#[test] +fn join_rejects_null_paths() { + let base = PathUri::parse("file:///workspace").expect("valid base URI"); + assert_eq!( base.join("src\0file"), Err(PathUriParseError::InvalidFileUriPath { @@ -537,6 +596,26 @@ fn join_rejects_absolute_and_null_paths() { ); } +#[test] +fn join_uses_the_base_uri_path_convention() { + for (base, path, expected) in [ + ( + "file:///workspace/src", + "../tests/test.rs", + "file:///workspace/tests/test.rs", + ), + ( + "file:///C:/workspace/src", + r"..\tests\test.rs", + "file:///C:/workspace/tests/test.rs", + ), + ] { + let base = PathUri::parse(base).expect("valid base URI"); + let expected = PathUri::parse(expected).expect("valid expected URI"); + assert_eq!(base.join(path), Ok(expected), "joining {path}"); + } +} + #[test] fn to_url_returns_the_validated_url() { let uri = PathUri::parse("file://localhost/workspace/a%20file.rs").expect("valid file URI"); From 3959ab0ffcd4e48620d5a380ccad66f00176a6e4 Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Wed, 17 Jun 2026 13:16:34 -0700 Subject: [PATCH 40/70] [codex] Track plugin install and import telemetry failures (#28731) ## Summary - Track plugin install failures through the unified `codex_plugin_install_failed` event for local installs, remote install preflight failures, bundle failures, and remote catalog/backend failures. - Send classified `error_type` values in plugin install failure analytics instead of raw error strings. - Stop sending raw external-agent import errors in analytics while preserving raw failure details in app-facing import notifications/history. - Keep raw plugin/migration diagnostics in `tracing::warn!` logs. - Keep remote failure plugin names as the existing local placeholder (`unknown`) and remove the extra telemetry plugin-name override. - Change `ExternalAgentConfigImportParams.source` from a generated enum to `string | null`, with legacy `claudeCode` / `claudeCowork` inputs normalized to existing analytics values. ## Testing --- .../analytics/src/analytics_client_tests.rs | 221 ++++++++++++++ codex-rs/analytics/src/client.rs | 30 ++ codex-rs/analytics/src/events.rs | 50 ++++ codex-rs/analytics/src/facts.rs | 24 ++ codex-rs/analytics/src/lib.rs | 2 + codex-rs/analytics/src/reducer.rs | 75 +++++ .../schema/json/ClientRequest.json | 9 +- .../codex_app_server_protocol.schemas.json | 9 +- .../codex_app_server_protocol.v2.schemas.json | 9 +- .../v2/ExternalAgentConfigDetectParams.json | 2 +- .../v2/ExternalAgentConfigImportParams.json | 7 + .../v2/ExternalAgentConfigDetectParams.ts | 2 +- .../v2/ExternalAgentConfigImportParams.ts | 6 +- .../src/protocol/v2/config.rs | 5 +- .../src/protocol/v2/tests.rs | 1 + codex-rs/app-server/README.md | 2 +- .../src/config/external_agent_config.rs | 27 +- .../src/config/external_agent_config_tests.rs | 48 ++-- codex-rs/app-server/src/message_processor.rs | 3 +- .../external_agent_config_processor.rs | 91 +++++- .../src/request_processors/plugins.rs | 162 ++++++++++- .../tests/suite/v2/external_agent_config.rs | 272 ++++++++++++++++-- .../tests/suite/v2/plugin_install.rs | 187 +++++++++++- codex-rs/core-plugins/src/manager.rs | 162 ++++++++++- codex-rs/tui/src/app_server_session.rs | 5 +- 25 files changed, 1314 insertions(+), 97 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d7be7f1df9..d6792587f2 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -9,7 +9,11 @@ use crate::events::CodexCommandExecutionEventParams; use crate::events::CodexCommandExecutionEventRequest; use crate::events::CodexCompactionEventRequest; use crate::events::CodexHookRunEventRequest; +use crate::events::CodexOnboardingExternalAgentImportFailureEventRequest; +use crate::events::CodexOnboardingExternalAgentImportFailureMetadata; use crate::events::CodexPluginEventRequest; +use crate::events::CodexPluginInstallFailedEventRequest; +use crate::events::CodexPluginInstallFailedMetadata; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexReviewEventParams; use crate::events::CodexReviewEventRequest; @@ -51,10 +55,13 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::ExternalAgentConfigImportCompletedInput; +use crate::facts::ExternalAgentConfigImportFailureInput; use crate::facts::HookRunFact; use crate::facts::HookRunInput; use crate::facts::InputError; use crate::facts::InvocationType; +use crate::facts::PluginInstallFailedInput; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; @@ -3054,6 +3061,36 @@ fn plugin_management_event_serializes_expected_shape() { ); } +#[test] +fn plugin_install_failed_event_serializes_expected_shape() { + let event = TrackEventRequest::PluginInstallFailed(CodexPluginInstallFailedEventRequest { + event_type: "codex_plugin_install_failed", + event_params: CodexPluginInstallFailedMetadata { + plugin: codex_plugin_metadata(sample_plugin_metadata()), + error_type: "store_io".to_string(), + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize plugin install failed event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_plugin_install_failed", + "event_params": { + "plugin_id": "sample@test", + "plugin_name": "sample", + "marketplace_name": "test", + "has_skills": true, + "mcp_server_count": 2, + "connector_ids": ["calendar", "drive"], + "product_client_id": originator().value, + "error_type": "store_io" + } + }) + ); +} + #[test] fn plugin_management_event_can_use_remote_plugin_id_override() { let mut plugin = sample_plugin_metadata(); @@ -3421,6 +3458,190 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[tokio::test] +async fn reducer_ingests_plugin_install_failed_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::PluginInstallFailed( + PluginInstallFailedInput { + plugin: sample_plugin_metadata(), + error_type: "invalid_plugin".to_string(), + }, + )), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload, + json!([{ + "event_type": "codex_plugin_install_failed", + "event_params": { + "plugin_id": "sample@test", + "plugin_name": "sample", + "marketplace_name": "test", + "has_skills": true, + "mcp_server_count": 2, + "connector_ids": ["calendar", "drive"], + "product_client_id": originator().value, + "error_type": "invalid_plugin" + } + }]) + ); +} + +#[tokio::test] +async fn reducer_ingests_plugin_install_failed_fact_without_detail() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + let mut plugin = PluginTelemetryMetadata::from_plugin_id( + &PluginId::parse("unknown@openai-curated-remote").expect("valid plugin id"), + ); + plugin.remote_plugin_id = Some("plugins~Plugin_00000000000000000000000000000000".to_string()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::PluginInstallFailed( + PluginInstallFailedInput { + plugin, + error_type: "remote_catalog_unexpected_status".to_string(), + }, + )), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload, + json!([{ + "event_type": "codex_plugin_install_failed", + "event_params": { + "plugin_id": "plugins~Plugin_00000000000000000000000000000000", + "plugin_name": "unknown", + "marketplace_name": "openai-curated-remote", + "has_skills": null, + "mcp_server_count": null, + "connector_ids": null, + "product_client_id": originator().value, + "error_type": "remote_catalog_unexpected_status" + } + }]) + ); +} + +#[tokio::test] +async fn reducer_ingests_external_agent_config_import_completed_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::ExternalAgentConfigImportCompleted( + ExternalAgentConfigImportCompletedInput { + import_id: "import-1".to_string(), + source: "app_server".to_string(), + item_type: "PLUGINS".to_string(), + success_count: 2, + failed_count: 1, + }, + )), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload, + json!([{ + "event_type": "codex_onboarding_external_agent_import_complete", + "event_params": { + "import_id": "import-1", + "source": "app_server", + "type": "PLUGINS", + "success_count": 2, + "failed_count": 1, + "product_client_id": originator().value, + } + }]) + ); +} + +#[test] +fn external_agent_config_import_failure_event_serializes_expected_shape() { + let event = TrackEventRequest::ExternalAgentConfigImportFailure( + CodexOnboardingExternalAgentImportFailureEventRequest { + event_type: "codex_onboarding_external_agent_import_failure", + event_params: CodexOnboardingExternalAgentImportFailureMetadata { + import_id: "import-1".to_string(), + source: "app_server".to_string(), + item_type: "SESSIONS".to_string(), + failure_stage: "session_missing".to_string(), + error_type: "session_missing".to_string(), + product_client_id: Some(originator().value), + }, + }, + ); + + let payload = serde_json::to_value(&event).expect("serialize import failure event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_onboarding_external_agent_import_failure", + "event_params": { + "import_id": "import-1", + "source": "app_server", + "type": "SESSIONS", + "failure_stage": "session_missing", + "error_type": "session_missing", + "product_client_id": originator().value, + } + }) + ); +} + +#[tokio::test] +async fn reducer_ingests_external_agent_config_import_failure_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::ExternalAgentConfigImportFailure( + ExternalAgentConfigImportFailureInput { + import_id: "import-1".to_string(), + source: "app_server".to_string(), + item_type: "SESSIONS".to_string(), + failure_stage: "session_missing".to_string(), + error_type: "session_missing".to_string(), + }, + )), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload, + json!([{ + "event_type": "codex_onboarding_external_agent_import_failure", + "event_params": { + "import_id": "import-1", + "source": "app_server", + "type": "SESSIONS", + "failure_stage": "session_missing", + "error_type": "session_missing", + "product_client_id": originator().value, + } + }]) + ); +} + #[test] fn turn_event_serializes_expected_shape() { let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e88c0114a0..24ff523c9c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -11,8 +11,11 @@ use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexGoalEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::ExternalAgentConfigImportCompletedInput; +use crate::facts::ExternalAgentConfigImportFailureInput; use crate::facts::HookRunFact; use crate::facts::HookRunInput; +use crate::facts::PluginInstallFailedInput; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::SkillInvocation; @@ -343,6 +346,33 @@ impl AnalyticsEventsClient { )); } + pub fn track_plugin_install_failed(&self, plugin: PluginTelemetryMetadata, error_type: String) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::PluginInstallFailed(PluginInstallFailedInput { + plugin, + error_type, + }), + )); + } + + pub fn track_external_agent_config_import_completed( + &self, + input: ExternalAgentConfigImportCompletedInput, + ) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::ExternalAgentConfigImportCompleted(input), + )); + } + + pub fn track_external_agent_config_import_failure( + &self, + input: ExternalAgentConfigImportFailureInput, + ) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::ExternalAgentConfigImportFailure(input), + )); + } + pub fn track_plugin_uninstalled(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index eed972852a..29d896156b 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -83,6 +83,9 @@ pub(crate) enum TrackEventRequest { PluginUninstalled(CodexPluginEventRequest), PluginEnabled(CodexPluginEventRequest), PluginDisabled(CodexPluginEventRequest), + PluginInstallFailed(CodexPluginInstallFailedEventRequest), + ExternalAgentConfigImportCompleted(CodexOnboardingExternalAgentImportCompleteEventRequest), + ExternalAgentConfigImportFailure(CodexOnboardingExternalAgentImportFailureEventRequest), } impl TrackEventRequest { @@ -954,6 +957,53 @@ pub(crate) struct CodexPluginEventRequest { pub(crate) event_params: CodexPluginMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexPluginInstallFailedMetadata { + #[serde(flatten)] + pub(crate) plugin: CodexPluginMetadata, + pub(crate) error_type: String, +} + +#[derive(Serialize)] +pub(crate) struct CodexPluginInstallFailedEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexPluginInstallFailedMetadata, +} + +#[derive(Serialize)] +pub(crate) struct CodexOnboardingExternalAgentImportCompleteMetadata { + pub(crate) import_id: String, + pub(crate) source: String, + #[serde(rename = "type")] + pub(crate) item_type: String, + pub(crate) success_count: usize, + pub(crate) failed_count: usize, + pub(crate) product_client_id: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexOnboardingExternalAgentImportCompleteEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexOnboardingExternalAgentImportCompleteMetadata, +} + +#[derive(Serialize)] +pub(crate) struct CodexOnboardingExternalAgentImportFailureMetadata { + pub(crate) import_id: String, + pub(crate) source: String, + #[serde(rename = "type")] + pub(crate) item_type: String, + pub(crate) failure_stage: String, + pub(crate) error_type: String, + pub(crate) product_client_id: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexOnboardingExternalAgentImportFailureEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexOnboardingExternalAgentImportFailureMetadata, +} + #[derive(Serialize)] pub(crate) struct CodexPluginUsedEventRequest { pub(crate) event_type: &'static str, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index ab278e223c..a8d1d6d6b0 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -504,6 +504,9 @@ pub(crate) enum CustomAnalyticsFact { HookRun(HookRunInput), PluginUsed(PluginUsedInput), PluginStateChanged(PluginStateChangedInput), + PluginInstallFailed(PluginInstallFailedInput), + ExternalAgentConfigImportCompleted(ExternalAgentConfigImportCompletedInput), + ExternalAgentConfigImportFailure(ExternalAgentConfigImportFailureInput), } pub(crate) struct SkillInvokedInput { @@ -542,6 +545,27 @@ pub(crate) struct PluginStateChangedInput { pub state: PluginState, } +pub(crate) struct PluginInstallFailedInput { + pub plugin: PluginTelemetryMetadata, + pub error_type: String, +} + +pub struct ExternalAgentConfigImportCompletedInput { + pub import_id: String, + pub source: String, + pub item_type: String, + pub success_count: usize, + pub failed_count: usize, +} + +pub struct ExternalAgentConfigImportFailureInput { + pub import_id: String, + pub source: String, + pub item_type: String, + pub failure_stage: String, + pub error_type: String, +} + #[derive(Clone, Copy)] pub(crate) enum PluginState { Installed, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 687a3a7631..58228f5c5c 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -36,6 +36,8 @@ pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::ExternalAgentConfigImportCompletedInput; +pub use facts::ExternalAgentConfigImportFailureInput; pub use facts::GoalEventKind; pub use facts::HookRunFact; pub use facts::InputError; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 89d6e57dc5..b647984dff 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -21,7 +21,13 @@ use crate::events::CodexImageGenerationEventParams; use crate::events::CodexImageGenerationEventRequest; use crate::events::CodexMcpToolCallEventParams; use crate::events::CodexMcpToolCallEventRequest; +use crate::events::CodexOnboardingExternalAgentImportCompleteEventRequest; +use crate::events::CodexOnboardingExternalAgentImportCompleteMetadata; +use crate::events::CodexOnboardingExternalAgentImportFailureEventRequest; +use crate::events::CodexOnboardingExternalAgentImportFailureMetadata; use crate::events::CodexPluginEventRequest; +use crate::events::CodexPluginInstallFailedEventRequest; +use crate::events::CodexPluginInstallFailedMetadata; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexReviewEventParams; use crate::events::CodexReviewEventRequest; @@ -66,7 +72,10 @@ use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; use crate::facts::CodexGoalEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::ExternalAgentConfigImportCompletedInput; +use crate::facts::ExternalAgentConfigImportFailureInput; use crate::facts::HookRunInput; +use crate::facts::PluginInstallFailedInput; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; @@ -511,6 +520,15 @@ impl AnalyticsReducer { CustomAnalyticsFact::PluginStateChanged(input) => { self.ingest_plugin_state_changed(input, out); } + CustomAnalyticsFact::PluginInstallFailed(input) => { + self.ingest_plugin_install_failed(input, out); + } + CustomAnalyticsFact::ExternalAgentConfigImportCompleted(input) => { + self.ingest_external_agent_config_import_completed(input, out); + } + CustomAnalyticsFact::ExternalAgentConfigImportFailure(input) => { + self.ingest_external_agent_config_import_failure(input, out); + } }, } } @@ -775,6 +793,63 @@ impl AnalyticsReducer { }); } + fn ingest_plugin_install_failed( + &mut self, + input: PluginInstallFailedInput, + out: &mut Vec, + ) { + let PluginInstallFailedInput { plugin, error_type } = input; + out.push(TrackEventRequest::PluginInstallFailed( + CodexPluginInstallFailedEventRequest { + event_type: "codex_plugin_install_failed", + event_params: CodexPluginInstallFailedMetadata { + plugin: codex_plugin_metadata(plugin), + error_type, + }, + }, + )); + } + + fn ingest_external_agent_config_import_completed( + &mut self, + input: ExternalAgentConfigImportCompletedInput, + out: &mut Vec, + ) { + out.push(TrackEventRequest::ExternalAgentConfigImportCompleted( + CodexOnboardingExternalAgentImportCompleteEventRequest { + event_type: "codex_onboarding_external_agent_import_complete", + event_params: CodexOnboardingExternalAgentImportCompleteMetadata { + import_id: input.import_id, + source: input.source, + item_type: input.item_type, + success_count: input.success_count, + failed_count: input.failed_count, + product_client_id: Some(originator().value), + }, + }, + )); + } + + fn ingest_external_agent_config_import_failure( + &mut self, + input: ExternalAgentConfigImportFailureInput, + out: &mut Vec, + ) { + out.push(TrackEventRequest::ExternalAgentConfigImportFailure( + CodexOnboardingExternalAgentImportFailureEventRequest { + event_type: "codex_onboarding_external_agent_import_failure", + event_params: CodexOnboardingExternalAgentImportFailureMetadata { + import_id: input.import_id, + source: input.source, + item_type: input.item_type, + failure_stage: input.failure_stage, + error_type: input.error_type, + product_client_id: Some(originator().value), + }, + }, + )); + } + async fn ingest_response( &mut self, connection_id: u64, diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 4f04698e37..509670f61b 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -800,7 +800,7 @@ ] }, "includeHome": { - "description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + "description": "If true, include detection under the user's home directory.", "type": "boolean" } }, @@ -813,6 +813,13 @@ "$ref": "#/definitions/ExternalAgentConfigMigrationItem" }, "type": "array" + }, + "source": { + "description": "Source product that produced the migration items. Missing means unspecified.", + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 5cbf96f24c..99a8a24e39 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9119,7 +9119,7 @@ ] }, "includeHome": { - "description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + "description": "If true, include detection under the user's home directory.", "type": "boolean" } }, @@ -9282,6 +9282,13 @@ "$ref": "#/definitions/v2/ExternalAgentConfigMigrationItem" }, "type": "array" + }, + "source": { + "description": "Source product that produced the migration items. Missing means unspecified.", + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e5a922ea78..b0399a2232 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -5404,7 +5404,7 @@ ] }, "includeHome": { - "description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + "description": "If true, include detection under the user's home directory.", "type": "boolean" } }, @@ -5567,6 +5567,13 @@ "$ref": "#/definitions/ExternalAgentConfigMigrationItem" }, "type": "array" + }, + "source": { + "description": "Source product that produced the migration items. Missing means unspecified.", + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json index 20ddd6e48a..01fda3b071 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json @@ -12,7 +12,7 @@ ] }, "includeHome": { - "description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + "description": "If true, include detection under the user's home directory.", "type": "boolean" } }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json index b26e9d187a..bc432b873a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json @@ -184,6 +184,13 @@ "$ref": "#/definitions/ExternalAgentConfigMigrationItem" }, "type": "array" + }, + "source": { + "description": "Source product that produced the migration items. Missing means unspecified.", + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts index 163d961925..48b55e9b9a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts @@ -4,7 +4,7 @@ export type ExternalAgentConfigDetectParams = { /** - * If true, include detection under the user's home (~/.claude, ~/.codex, etc.). + * If true, include detection under the user's home directory. */ includeHome?: boolean, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts index 7bc5d9d91f..be7f7ffe30 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigrationItem"; -export type ExternalAgentConfigImportParams = { migrationItems: Array, }; +export type ExternalAgentConfigImportParams = { migrationItems: Array, +/** + * Source product that produced the migration items. Missing means unspecified. + */ +source?: string | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 4f299d0227..b37f10dce7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -651,7 +651,7 @@ pub struct ExternalAgentConfigDetectResponse { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ExternalAgentConfigDetectParams { - /// If true, include detection under the user's home (~/.claude, ~/.codex, etc.). + /// If true, include detection under the user's home directory. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub include_home: bool, /// Zero or more working directories to include for repo-scoped detection. @@ -664,6 +664,9 @@ pub struct ExternalAgentConfigDetectParams { #[ts(export_to = "v2/")] pub struct ExternalAgentConfigImportParams { pub migration_items: Vec, + /// Source product that produced the migration items. Missing means unspecified. + #[ts(optional = nullable)] + pub source: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 39bc52717a..b973b8b4ae 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -376,6 +376,7 @@ fn external_agent_config_import_params_accept_legacy_plugin_details() { ..Default::default() }), }], + source: None, } ); } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index ab001cc682..d342313917 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -232,7 +232,7 @@ Example with notification opt-out: - `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. - `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`. - `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin/session migration items may additionally include structured `details` grouping plugin ids or session metadata. -- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. Returns an `importId` used to correlate the completion notification. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes with type-level `itemResults` containing each migrated type's success count, error count, successes, and raw errors (immediately after the response when everything completed synchronously, or after background imports finish). +- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. Callers may pass `source` to identify the product that initiated the import; omitted or `null` means unspecified. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately. - `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. - `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. diff --git a/codex-rs/app-server/src/config/external_agent_config.rs b/codex-rs/app-server/src/config/external_agent_config.rs index 79171e1717..ce10d6e81c 100644 --- a/codex-rs/app-server/src/config/external_agent_config.rs +++ b/codex-rs/app-server/src/config/external_agent_config.rs @@ -237,7 +237,7 @@ impl ExternalAgentConfigService { pub(crate) async fn import( &self, migration_items: Vec, - ) -> io::Result { + ) -> ExternalAgentConfigImportOutcome { let mut outcome = ExternalAgentConfigImportOutcome::default(); for migration_item in migration_items { let item_type = migration_item.item_type; @@ -413,17 +413,28 @@ impl ExternalAgentConfigService { })(), ExternalAgentConfigMigrationItemType::Sessions => Ok(()), }; - if let Err(err) = import_result { - if item_type == ExternalAgentConfigMigrationItemType::Plugins { - outcome.item_results.push(item_result); - continue; - } - return Err(err); + if let Err(err) = import_result + && item_type != ExternalAgentConfigMigrationItemType::Plugins + { + let message = err.to_string(); + let error_type = if message.contains("invalid existing config.toml") { + "invalid_existing_config" + } else { + "external_agent_config_import_error" + }; + item_result.record_error(ExternalAgentConfigImportRawError { + item_type, + error_type: Some(error_type.to_string()), + failure_stage: "import_request_failed".to_string(), + message, + cwd: item_result.cwd.clone(), + source: None, + }); } outcome.item_results.push(item_result); } - Ok(outcome) + outcome } async fn detect_migrations( diff --git a/codex-rs/app-server/src/config/external_agent_config_tests.rs b/codex-rs/app-server/src/config/external_agent_config_tests.rs index d6fa5a927e..447741d14a 100644 --- a/codex-rs/app-server/src/config/external_agent_config_tests.rs +++ b/codex-rs/app-server/src/config/external_agent_config_tests.rs @@ -561,8 +561,7 @@ async fn import_repo_migrates_mcp_hooks_commands_and_subagents() { details: None, }, ]) - .await - .expect("import"); + .await; let config: TomlValue = toml::from_str( &fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), @@ -724,8 +723,7 @@ url = "https://example.com/mixed-transport" cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; assert_eq!( fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), @@ -838,8 +836,7 @@ async fn import_home_migrates_supported_config_fields_skills_and_agents_md() { details: None, }, ]) - .await - .expect("import"); + .await; assert_eq!( fs::read_to_string(codex_home.join("AGENTS.md")).expect("read agents"), @@ -894,8 +891,7 @@ async fn import_home_config_uses_local_settings_over_project_settings() { cwd: None, details: None, }]) - .await - .expect("import"); + .await; let config: TomlValue = toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config")) @@ -939,8 +935,7 @@ async fn import_home_config_ignores_invalid_local_settings() { cwd: None, details: None, }]) - .await - .expect("import"); + .await; assert_eq!( fs::read_to_string(codex_home.join("config.toml")).expect("read config"), @@ -965,8 +960,7 @@ async fn import_home_skips_empty_config_migration() { cwd: None, details: None, }]) - .await - .expect("import"); + .await; assert_eq!( outcome.item_results, @@ -1043,8 +1037,7 @@ async fn import_local_plugins_returns_completed_status() { ..Default::default() }), }]) - .await - .expect("import"); + .await; assert_eq!( outcome.pending_plugin_imports, @@ -1104,8 +1097,7 @@ async fn import_git_plugins_returns_pending_async_status() { ..Default::default() }), }]) - .await - .expect("import"); + .await; assert_eq!( outcome.pending_plugin_imports, @@ -1235,8 +1227,7 @@ async fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() { details: None, }, ]) - .await - .expect("import"); + .await; assert_eq!( outcome.item_results, @@ -1302,8 +1293,7 @@ async fn import_repo_agents_md_overwrites_empty_targets() { cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; assert_eq!( outcome.item_results, @@ -1403,8 +1393,7 @@ async fn import_repo_hooks_preserves_disabled_codex_hooks_feature() { cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; assert_eq!( outcome.item_results, @@ -1481,8 +1470,7 @@ async fn import_repo_mcp_uses_home_settings_toggles_when_repo_settings_missing() cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; assert_eq!( outcome.item_results, @@ -1559,8 +1547,7 @@ async fn import_repo_mcp_uses_local_settings_toggles_over_project_settings() { cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; let config: TomlValue = toml::from_str( &fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), @@ -1607,8 +1594,7 @@ async fn import_repo_mcp_ignores_invalid_home_settings_when_repo_settings_missin cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; let config: TomlValue = toml::from_str( &fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), @@ -1649,8 +1635,7 @@ async fn import_repo_uses_non_empty_external_agent_agents_source() { cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; assert_eq!( fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), @@ -1683,8 +1668,7 @@ async fn import_continues_after_failed_migration_item() { details: None, }, ]) - .await - .expect("import continues"); + .await; assert_eq!( fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 985160985d..3544592b49 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -515,7 +515,7 @@ impl MessageProcessor { outgoing.clone(), config_manager.clone(), thread_manager.clone(), - analytics_events_client, + analytics_events_client.clone(), ); let external_agent_config_processor = ExternalAgentConfigRequestProcessor::new(ExternalAgentConfigRequestProcessorArgs { @@ -525,6 +525,7 @@ impl MessageProcessor { config_manager: config_manager.clone(), config_processor: config_processor.clone(), state_db, + analytics_events_client, arg0_paths, codex_home: config.codex_home.to_path_buf(), }); diff --git a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs index b3cdb0e213..69bb4218ce 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs @@ -15,6 +15,9 @@ use crate::config_manager::ConfigManager; use crate::error_code::internal_error; use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; +use codex_analytics::AnalyticsEventsClient; +use codex_analytics::ExternalAgentConfigImportCompletedInput; +use codex_analytics::ExternalAgentConfigImportFailureInput; use codex_app_server_protocol::CommandMigration; use codex_app_server_protocol::ExternalAgentConfigDetectParams; use codex_app_server_protocol::ExternalAgentConfigDetectResponse; @@ -57,6 +60,7 @@ pub(crate) struct ExternalAgentConfigRequestProcessor { thread_manager: Arc, config_processor: ConfigRequestProcessor, state_db: Option, + analytics_events_client: AnalyticsEventsClient, } pub(crate) struct ExternalAgentConfigRequestProcessorArgs { @@ -66,6 +70,7 @@ pub(crate) struct ExternalAgentConfigRequestProcessorArgs { pub(crate) config_manager: ConfigManager, pub(crate) config_processor: ConfigRequestProcessor, pub(crate) state_db: Option, + pub(crate) analytics_events_client: AnalyticsEventsClient, pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) codex_home: PathBuf, } @@ -79,6 +84,7 @@ impl ExternalAgentConfigRequestProcessor { config_manager, config_processor, state_db, + analytics_events_client, arg0_paths, codex_home, } = args; @@ -96,6 +102,7 @@ impl ExternalAgentConfigRequestProcessor { thread_manager, config_processor, state_db, + analytics_events_client, } } @@ -199,6 +206,7 @@ impl ExternalAgentConfigRequestProcessor { params: ExternalAgentConfigImportParams, ) -> Result<(), JSONRPCErrorError> { let import_id = Uuid::new_v4().to_string(); + let analytics_source = params.source.clone().unwrap_or_default(); let needs_runtime_refresh = migration_items_need_runtime_refresh(¶ms.migration_items); let has_migration_items = !params.migration_items.is_empty(); let has_plugin_imports = params.migration_items.iter().any(|item| { @@ -209,7 +217,7 @@ impl ExternalAgentConfigRequestProcessor { }); let (pending_session_imports, session_validation_result) = self.validate_pending_session_imports(¶ms); - let import_outcome = self.import_external_agent_config(params).await?; + let import_outcome = self.import_external_agent_config(params).await; if needs_runtime_refresh { self.config_processor.handle_config_mutation().await; } @@ -242,7 +250,9 @@ impl ExternalAgentConfigRequestProcessor { send_completed_import_notification( &self.outgoing, self.state_db.as_ref(), + &self.analytics_events_client, import_id, + analytics_source, &completed_item_results, ) .await; @@ -253,6 +263,7 @@ impl ExternalAgentConfigRequestProcessor { let plugin_processor = self.clone(); let outgoing = Arc::clone(&self.outgoing); let state_db = self.state_db.clone(); + let analytics_events_client = self.analytics_events_client.clone(); let thread_manager = Arc::clone(&self.thread_manager); let session_import_result = (!pending_session_imports.is_empty()).then(|| { CoreImportItemResult::new( @@ -324,7 +335,9 @@ impl ExternalAgentConfigRequestProcessor { send_completed_import_notification( &outgoing, state_db.as_ref(), + &analytics_events_client, import_id, + analytics_source, &completed_item_results, ) .await; @@ -421,7 +434,7 @@ impl ExternalAgentConfigRequestProcessor { async fn import_external_agent_config( &self, params: ExternalAgentConfigImportParams, - ) -> Result { + ) -> CoreImportOutcome { self.migration_service .import( params @@ -516,7 +529,6 @@ impl ExternalAgentConfigRequestProcessor { .collect(), ) .await - .map_err(|err| internal_error(err.to_string())) } async fn complete_pending_plugin_import( @@ -551,10 +563,14 @@ async fn send_import_progress( async fn send_completed_import_notification( outgoing: &OutgoingMessageSender, state_db: Option<&StateDbHandle>, + analytics_events_client: &AnalyticsEventsClient, import_id: String, + analytics_source: String, item_results: &[CoreImportItemResult], ) { let notification = completed_notification(import_id, item_results); + log_completed_import_failures(¬ification); + track_completed_import_notification(analytics_events_client, &analytics_source, ¬ification); if let Some(state_db) = state_db && let Err(err) = record_completed_import_notification(state_db, ¬ification).await { @@ -571,6 +587,75 @@ async fn send_completed_import_notification( .await; } +fn log_completed_import_failures(notification: &ExternalAgentConfigImportCompletedNotification) { + for type_result in ¬ification.item_type_results { + for failure in &type_result.failures { + let error_type = import_failure_error_type(failure); + tracing::warn!( + import_id = %notification.import_id, + item_type = ?failure.item_type, + error_type = %error_type, + failure_stage = %failure.failure_stage, + cwd = ?failure.cwd, + source = ?failure.source, + error = %failure.message, + "external agent config migration item failed" + ); + } + } +} + +fn track_completed_import_notification( + analytics_events_client: &AnalyticsEventsClient, + analytics_source: &str, + notification: &ExternalAgentConfigImportCompletedNotification, +) { + for type_result in ¬ification.item_type_results { + let item_type = analytics_migration_item_type(type_result.item_type).to_string(); + analytics_events_client.track_external_agent_config_import_completed( + ExternalAgentConfigImportCompletedInput { + import_id: notification.import_id.clone(), + source: analytics_source.to_string(), + item_type: item_type.clone(), + success_count: type_result.successes.len(), + failed_count: type_result.failures.len(), + }, + ); + for failure in &type_result.failures { + analytics_events_client.track_external_agent_config_import_failure( + ExternalAgentConfigImportFailureInput { + import_id: notification.import_id.clone(), + source: analytics_source.to_string(), + item_type: item_type.clone(), + failure_stage: failure.failure_stage.clone(), + error_type: import_failure_error_type(failure), + }, + ); + } + } +} + +fn import_failure_error_type(failure: &ProtocolImportFailure) -> String { + failure + .error_type + .clone() + .unwrap_or_else(|| failure.failure_stage.clone()) +} + +fn analytics_migration_item_type(item_type: ExternalAgentConfigMigrationItemType) -> &'static str { + match item_type { + ExternalAgentConfigMigrationItemType::AgentsMd => "AGENTS_MD", + ExternalAgentConfigMigrationItemType::Config => "CONFIG", + ExternalAgentConfigMigrationItemType::Skills => "SKILLS", + ExternalAgentConfigMigrationItemType::Plugins => "PLUGINS", + ExternalAgentConfigMigrationItemType::McpServerConfig => "MCP_SERVER_CONFIG", + ExternalAgentConfigMigrationItemType::Subagents => "SUBAGENTS", + ExternalAgentConfigMigrationItemType::Hooks => "HOOKS", + ExternalAgentConfigMigrationItemType::Commands => "COMMANDS", + ExternalAgentConfigMigrationItemType::Sessions => "SESSIONS", + } +} + async fn record_completed_import_notification( state_db: &StateDbHandle, notification: &ExternalAgentConfigImportCompletedNotification, diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index d2cd08b63a..e9bf044766 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -18,9 +18,12 @@ use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETP use codex_core_plugins::remote::RemoteAppTemplateUnavailableReason; use codex_core_plugins::remote::is_valid_remote_plugin_id; use codex_core_plugins::remote::validate_remote_plugin_id; +use codex_core_plugins::remote_bundle::RemotePluginBundleInstallError; use codex_mcp::McpOAuthLoginSupport; use codex_mcp::oauth_login_support; use codex_mcp::should_retry_without_scopes; +use codex_plugin::PluginId; +use codex_plugin::PluginTelemetryMetadata; use codex_rmcp_client::perform_oauth_login_silent; #[derive(Clone)] @@ -1437,15 +1440,24 @@ impl PluginRequestProcessor { } let plugins_manager = self.thread_manager.plugins_manager(); + let marketplace_display = marketplace_path.display().to_string(); + let plugin_name_for_log = plugin_name.clone(); let request = PluginInstallRequest { plugin_name, marketplace_path, }; - let result = plugins_manager - .install_plugin(request) - .await - .map_err(Self::plugin_install_error)?; + let result = match plugins_manager.install_plugin(request).await { + Ok(result) => result, + Err(err) => { + warn!( + marketplace = %marketplace_display, + plugin_name = %plugin_name_for_log, + "failed to install plugin: {err}" + ); + return Err(Self::plugin_install_error(err)); + } + }; let config = match self.load_latest_config(config_cwd).await { Ok(config) => config, Err(err) => { @@ -1512,11 +1524,20 @@ impl PluginRequestProcessor { ) .await .map_err(|err| { + let error_type = remote_plugin_catalog_error_type(&err); + self.track_plugin_install_failed_for_remote_plugin( + &remote_plugin_id, + &remote_marketplace_name, + error_type, + err.to_string(), + ); remote_plugin_catalog_error_to_jsonrpc( err, "read remote plugin details before install", ) })?; + let actual_remote_marketplace_name = remote_detail.marketplace_name.clone(); + let remote_plugin_name = remote_detail.summary.name.clone(); if remote_detail.summary.availability == PluginAvailability::DisabledByAdmin { return Err(invalid_request(format!( "remote plugin {remote_plugin_id} is disabled by admin" @@ -1527,31 +1548,48 @@ impl PluginRequestProcessor { "remote plugin {remote_plugin_id} is not available for install" ))); } - let actual_remote_marketplace_name = remote_detail.marketplace_name.clone(); // Direct install writes the same cache tree that installed-plugin sync // prunes before the backend installed snapshot can include this plugin. let _remote_plugin_cache_mutation = codex_core_plugins::remote::mark_remote_plugin_cache_mutation_in_flight( config.codex_home.as_path(), &actual_remote_marketplace_name, - &remote_detail.summary.name, + &remote_plugin_name, ); let validated_bundle = codex_core_plugins::remote_bundle::validate_remote_plugin_bundle( &remote_plugin_id, &actual_remote_marketplace_name, - &remote_detail.summary.name, + &remote_plugin_name, remote_detail.release_version.as_deref(), remote_detail.bundle_download_url.as_deref(), remote_detail.app_manifest.clone(), ) - .map_err(remote_plugin_bundle_install_error_to_jsonrpc)?; + .map_err(|err| { + let error_type = remote_plugin_bundle_install_error_type(&err); + self.track_plugin_install_failed_for_remote_plugin( + &remote_plugin_id, + &actual_remote_marketplace_name, + error_type, + err.to_string(), + ); + remote_plugin_bundle_install_error_to_jsonrpc(err) + })?; let result = codex_core_plugins::remote_bundle::download_and_install_remote_plugin_bundle( config.codex_home.to_path_buf(), validated_bundle, ) .await - .map_err(remote_plugin_bundle_install_error_to_jsonrpc)?; + .map_err(|err| { + let error_type = remote_plugin_bundle_install_error_type(&err); + self.track_plugin_install_failed_for_remote_plugin( + &remote_plugin_id, + &actual_remote_marketplace_name, + error_type, + err.to_string(), + ); + remote_plugin_bundle_install_error_to_jsonrpc(err) + })?; // Cache first so a backend install cannot succeed when local materialization fails. // If this backend call fails, the cache entry is harmless because remote installed state @@ -1563,7 +1601,16 @@ impl PluginRequestProcessor { &remote_plugin_id, ) .await - .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin"))?; + .map_err(|err| { + let error_type = remote_plugin_catalog_error_type(&err); + self.track_plugin_install_failed_for_remote_plugin( + &remote_plugin_id, + &actual_remote_marketplace_name, + error_type, + err.to_string(), + ); + remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin") + })?; self.thread_manager .plugins_manager() @@ -1646,6 +1693,32 @@ impl PluginRequestProcessor { }) } + fn track_plugin_install_failed_for_remote_plugin( + &self, + remote_plugin_id: &str, + marketplace_name: &str, + error_type: &'static str, + error_message: String, + ) { + tracing::warn!( + remote_plugin_id = %remote_plugin_id, + marketplace_name = %marketplace_name, + error_type = %error_type, + error = %error_message, + "remote plugin install failed" + ); + // The remote id is reported separately; this local name only satisfies + // PluginId validation before remote details are available. + let Ok(plugin_id) = PluginId::new("unknown".to_string(), marketplace_name.to_string()) + else { + return; + }; + let mut plugin = PluginTelemetryMetadata::from_plugin_id(&plugin_id); + plugin.remote_plugin_id = Some(remote_plugin_id.to_string()); + self.analytics_events_client + .track_plugin_install_failed(plugin, error_type.to_string()); + } + async fn plugin_apps_needing_auth_for_install( &self, config: &Config, @@ -2145,6 +2218,75 @@ fn remote_plugin_detail_to_info( } } +fn remote_plugin_catalog_error_type(err: &RemotePluginCatalogError) -> &'static str { + match err { + RemotePluginCatalogError::AuthRequired => "remote_catalog_auth_required", + RemotePluginCatalogError::UnsupportedAuthMode => "remote_catalog_unsupported_auth_mode", + RemotePluginCatalogError::AuthToken(_) => "remote_catalog_auth_token", + RemotePluginCatalogError::Request { .. } => "remote_catalog_request", + RemotePluginCatalogError::UnexpectedStatus { .. } => "remote_catalog_unexpected_status", + RemotePluginCatalogError::Decode { .. } => "remote_catalog_decode", + RemotePluginCatalogError::InvalidBaseUrl(_) => "remote_catalog_invalid_base_url", + RemotePluginCatalogError::InvalidBaseUrlPath => "remote_catalog_invalid_base_url_path", + RemotePluginCatalogError::UnknownMarketplace { .. } => "remote_catalog_unknown_marketplace", + RemotePluginCatalogError::UnexpectedPluginId { .. } => { + "remote_catalog_unexpected_plugin_id" + } + RemotePluginCatalogError::UnexpectedSkillName { .. } => { + "remote_catalog_unexpected_skill_name" + } + RemotePluginCatalogError::UnexpectedEnabledState { .. } => { + "remote_catalog_unexpected_enabled_state" + } + RemotePluginCatalogError::InvalidPluginPath { .. } => "remote_catalog_invalid_plugin_path", + RemotePluginCatalogError::PluginShareCheckoutNotAvailable { .. } => { + "remote_catalog_plugin_share_checkout_not_available" + } + RemotePluginCatalogError::Archive { .. } => "remote_catalog_archive", + RemotePluginCatalogError::ArchiveJoin(_) => "remote_catalog_archive_join", + RemotePluginCatalogError::ArchiveTooLarge { .. } => "remote_catalog_archive_too_large", + RemotePluginCatalogError::MissingUploadEtag => "remote_catalog_missing_upload_etag", + RemotePluginCatalogError::UnexpectedResponse(_) => "remote_catalog_unexpected_response", + RemotePluginCatalogError::CacheRemove(_) => "remote_catalog_cache_remove", + } +} + +fn remote_plugin_bundle_install_error_type(err: &RemotePluginBundleInstallError) -> &'static str { + match err { + RemotePluginBundleInstallError::MissingReleaseVersion { .. } => { + "remote_bundle_missing_release_version" + } + RemotePluginBundleInstallError::InvalidReleaseVersion { .. } => { + "remote_bundle_invalid_release_version" + } + RemotePluginBundleInstallError::MissingBundleDownloadUrl { .. } => { + "remote_bundle_missing_download_url" + } + RemotePluginBundleInstallError::InvalidBundleDownloadUrl { .. } => { + "remote_bundle_invalid_download_url" + } + RemotePluginBundleInstallError::UnsupportedBundleDownloadUrlScheme { .. } => { + "remote_bundle_unsupported_download_url_scheme" + } + RemotePluginBundleInstallError::InvalidPluginId { .. } => "remote_bundle_invalid_plugin_id", + RemotePluginBundleInstallError::DownloadRequest { .. } => "remote_bundle_download_request", + RemotePluginBundleInstallError::DownloadStatus { .. } => "remote_bundle_download_status", + RemotePluginBundleInstallError::DownloadBody { .. } => "remote_bundle_download_body", + RemotePluginBundleInstallError::DownloadTooLarge { .. } => { + "remote_bundle_download_too_large" + } + RemotePluginBundleInstallError::UnsupportedBundleDownloadFinalUrl { .. } => { + "remote_bundle_unsupported_download_final_url" + } + RemotePluginBundleInstallError::ExtractedBundleTooLarge { .. } => { + "remote_bundle_extracted_too_large" + } + RemotePluginBundleInstallError::Io { .. } => "remote_bundle_io", + RemotePluginBundleInstallError::InvalidBundle(_) => "remote_bundle_invalid_bundle", + RemotePluginBundleInstallError::Store(_) => "remote_bundle_store", + } +} + fn remote_plugin_catalog_error_to_jsonrpc( err: RemotePluginCatalogError, context: &str, diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index b48aeca2b0..a5d918cbaf 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -1,9 +1,12 @@ use std::time::Duration; use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::TestAppServer; use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::start_analytics_events_server; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; use app_test_support::write_mock_responses_config_toml; use codex_app_server_protocol::ExternalAgentConfigDetectResponse; use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification; @@ -11,7 +14,6 @@ use codex_app_server_protocol::ExternalAgentConfigImportHistoriesReadResponse; use codex_app_server_protocol::ExternalAgentConfigImportProgressNotification; use codex_app_server_protocol::ExternalAgentConfigImportResponse; use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; -use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; @@ -25,16 +27,25 @@ use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::UserInput; +use codex_config::types::AuthCredentialsStoreMode; use core_test_support::responses; use pretty_assertions::assert_eq; use std::collections::BTreeMap; +use std::path::Path; +use std::path::PathBuf; use tempfile::TempDir; #[cfg(unix)] use tokio::io::AsyncWriteExt; use tokio::time::timeout; +use super::analytics::wait_for_analytics_event; + const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); +fn external_agent_home(codex_home: &Path) -> PathBuf { + codex_home.join(concat!(".", "cl", "aude")) +} + fn assert_import_response(response: ExternalAgentConfigImportResponse) -> String { assert!(!response.import_id.is_empty()); response.import_id @@ -158,15 +169,172 @@ async fn external_agent_config_import_sends_completion_notification_for_sync_onl } #[tokio::test] -async fn external_agent_config_import_returns_error_for_failed_sync_import() -> Result<()> { +async fn external_agent_config_import_reports_failed_sync_import_in_completion() -> Result<()> { let codex_home = TempDir::new()?; - std::fs::create_dir_all(codex_home.path().join(".claude"))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + let source_home = external_agent_home(codex_home.path()); + std::fs::create_dir_all(&source_home)?; std::fs::write( - codex_home.path().join(".claude").join("settings.json"), + source_home.join("settings.json"), r#"{"env":{"FOO":"bar"}}"#, )?; std::fs::write(codex_home.path().join("config.toml"), "invalid = [")?; let home_dir = codex_home.path().display().to_string(); + let analytics_capture_file = codex_home.path().join("analytics-events.jsonl"); + let analytics_capture_file = analytics_capture_file.display().to_string(); + let mut mcp = TestAppServer::new_with_env( + codex_home.path(), + &[ + ("HOME", Some(home_dir.as_str())), + ( + "CODEX_ANALYTICS_EVENTS_CAPTURE_FILE", + Some(analytics_capture_file.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ + "source": "test_import", + "migrationItems": [ + { + "itemType": "CONFIG", + "description": "Import config", + "cwd": null + }, + { + "itemType": "COMMANDS", + "description": "Import commands", + "cwd": null + } + ] + })), + ) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ExternalAgentConfigImportResponse = to_response(response)?; + let import_id = assert_import_response(response); + + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + let completed: ExternalAgentConfigImportCompletedNotification = + serde_json::from_value(notification.params.expect("completed params"))?; + assert_eq!(completed.import_id, import_id); + let config_result = completed + .item_type_results + .iter() + .find(|result| result.item_type == ExternalAgentConfigMigrationItemType::Config) + .expect("config result"); + assert!(config_result.successes.is_empty()); + assert_eq!(config_result.failures.len(), 1); + let config_failure = &config_result.failures[0]; + assert_eq!( + config_failure.error_type.as_deref(), + Some("invalid_existing_config") + ); + assert_eq!(config_failure.failure_stage, "import_request_failed"); + assert!( + config_failure + .message + .contains("invalid existing config.toml"), + "unexpected failure: {config_failure:?}" + ); + let commands_result = completed + .item_type_results + .iter() + .find(|result| result.item_type == ExternalAgentConfigMigrationItemType::Commands) + .expect("commands result"); + assert!(commands_result.successes.is_empty()); + assert!(commands_result.failures.is_empty()); + + let events = timeout(DEFAULT_TIMEOUT, async { + loop { + let contents = match std::fs::read_to_string(&analytics_capture_file) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + } + Err(err) => return Err(err.into()), + }; + let mut captured_events = Vec::new(); + for line in contents.lines() { + let payload: serde_json::Value = serde_json::from_str(line)?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + captured_events.extend(events.iter().cloned()); + } + if captured_events.iter().any(|event| { + event["event_type"] == "codex_onboarding_external_agent_import_complete" + && event["event_params"]["type"] == "COMMANDS" + }) { + return Ok::, anyhow::Error>(captured_events); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await??; + let event = events + .iter() + .find(|event| { + event["event_type"] == "codex_onboarding_external_agent_import_failure" + && event["event_params"]["type"] == "CONFIG" + }) + .expect("config failure analytics event"); + let event_params = &event["event_params"]; + assert_eq!(event_params["import_id"], import_id); + assert_eq!(event_params["source"], "test_import"); + assert_eq!(event_params["type"], "CONFIG"); + assert_eq!(event_params["failure_stage"], "import_request_failed"); + assert_eq!(event_params["error_type"], "invalid_existing_config"); + assert!(event_params.get("raw_errors").is_none()); + assert!(event_params.get("message").is_none()); + assert!(!events.iter().any(|event| { + event["event_type"] == "codex_onboarding_external_agent_import_failure" + && event["event_params"]["type"] == "COMMANDS" + })); + + Ok(()) +} + +#[tokio::test] +async fn external_agent_config_import_completed_tracks_analytics_event() -> Result<()> { + let analytics_server = start_analytics_events_server().await?; + let codex_home = TempDir::new()?; + write_analytics_config(codex_home.path(), &analytics_server.uri())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let missing_session_path = + external_agent_home(codex_home.path()).join("projects/repo/missing.jsonl"); + let project_root = codex_home.path().join("repo"); + let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) .await?; @@ -176,25 +344,70 @@ async fn external_agent_config_import_returns_error_for_failed_sync_import() -> .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ + "source": "test_import", "migrationItems": [{ - "itemType": "CONFIG", - "description": "Import config", - "cwd": null + "itemType": "SESSIONS", + "description": "Migrate recent sessions", + "cwd": null, + "details": { + "sessions": [{ + "path": missing_session_path, + "cwd": project_root, + "title": "missing session" + }] + } }] })), ) .await?; - - let error: JSONRPCError = timeout( + let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; - assert_eq!(error.error.code, -32603); - assert!( - error.error.message.contains("invalid existing config.toml"), - "unexpected error: {error:?}" - ); + let response: ExternalAgentConfigImportResponse = to_response(response)?; + let import_id = assert_import_response(response); + + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + let completed: ExternalAgentConfigImportCompletedNotification = + serde_json::from_value(notification.params.expect("completed params"))?; + assert_eq!(completed.import_id, import_id); + assert_eq!(completed.item_type_results.len(), 1); + assert_eq!(completed.item_type_results[0].successes.len(), 0); + assert_eq!(completed.item_type_results[0].failures.len(), 1); + + let event = wait_for_analytics_event( + &analytics_server, + DEFAULT_TIMEOUT, + "codex_onboarding_external_agent_import_complete", + ) + .await?; + let event_params = &event["event_params"]; + assert_eq!(event_params["import_id"], serde_json::json!(import_id)); + assert_eq!(event_params["source"], "test_import"); + assert_eq!(event_params["type"], "SESSIONS"); + assert_eq!(event_params["success_count"], 0); + assert_eq!(event_params["failed_count"], 1); + assert!(event_params.get("raw_errors").is_none()); + + let event = wait_for_analytics_event( + &analytics_server, + DEFAULT_TIMEOUT, + "codex_onboarding_external_agent_import_failure", + ) + .await?; + let event_params = &event["event_params"]; + assert_eq!(event_params["import_id"], serde_json::json!(import_id)); + assert_eq!(event_params["source"], "test_import"); + assert_eq!(event_params["type"], "SESSIONS"); + assert_eq!(event_params["failure_stage"], "session_missing"); + assert_eq!(event_params["error_type"], "session_missing"); + assert!(event_params.get("raw_errors").is_none()); + assert!(event_params.get("message").is_none()); Ok(()) } @@ -226,7 +439,8 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample","version":"0.1.0"}"#, )?; - std::fs::create_dir_all(codex_home.path().join(".claude"))?; + let source_home = external_agent_home(codex_home.path()); + std::fs::create_dir_all(&source_home)?; let settings = serde_json::json!({ "enabledPlugins": { "sample@debug": true @@ -239,7 +453,7 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl } }); std::fs::write( - codex_home.path().join(".claude").join("settings.json"), + source_home.join("settings.json"), serde_json::to_string_pretty(&settings)?, )?; @@ -318,11 +532,12 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl async fn external_agent_config_import_sends_completion_notification_after_pending_plugins_finish() -> Result<()> { let codex_home = TempDir::new()?; - std::fs::create_dir_all(codex_home.path().join(".claude"))?; + let source_home = external_agent_home(codex_home.path()); + std::fs::create_dir_all(&source_home)?; // This test only needs a pending non-local plugin import. Use an invalid // source so the background completion path cannot make a real network clone. std::fs::write( - codex_home.path().join(".claude").join("settings.json"), + source_home.join("settings.json"), r#"{ "enabledPlugins": { "formatter@acme-tools": true @@ -387,7 +602,7 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { create_config_toml(codex_home.path(), &server.uri())?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -613,7 +828,7 @@ required = true std::fs::write(codex_home.path().join("config.toml"), config)?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -698,7 +913,7 @@ async fn external_agent_config_import_accepts_detected_session_payload_after_res create_config_toml(codex_home.path(), &server.uri())?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -788,7 +1003,7 @@ async fn external_agent_config_import_skips_already_imported_session_versions() create_config_toml(codex_home.path(), &server.uri())?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -882,7 +1097,7 @@ async fn external_agent_config_import_returns_before_background_session_import_f create_config_toml(codex_home.path(), &server.uri())?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -1043,7 +1258,7 @@ async fn external_agent_config_import_compacts_huge_session_before_first_follow_ let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -1209,3 +1424,10 @@ stream_max_retries = 0 ), ) } + +fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!("chatgpt_base_url = \"{base_url}\"\n"), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 6965e759e0..cc008e73a9 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -511,6 +511,46 @@ async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_install_tracks_analytics_when_remote_detail_fetch_fails() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_empty_remote_installed_plugins(&server).await; + mount_backend_analytics_events(&server).await; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("failed with status 404")); + + let payload = wait_for_plugin_analytics_payload(&server).await?; + let event_params = &payload["events"][0]["event_params"]; + assert_eq!( + payload["events"][0]["event_type"], + "codex_plugin_install_failed" + ); + assert_eq!(event_params["plugin_id"], REMOTE_PLUGIN_ID); + assert_eq!(event_params["plugin_name"], "unknown"); + assert_eq!( + event_params["marketplace_name"], + "caller-marketplace-is-ignored" + ); + assert_eq!( + event_params["error_type"], + "remote_catalog_unexpected_status" + ); + Ok(()) +} + #[tokio::test] async fn plugin_install_rejects_remote_plugin_disabled_by_admin_before_download() -> Result<()> { let codex_home = TempDir::new()?; @@ -571,6 +611,42 @@ async fn plugin_install_rejects_remote_plugin_disabled_by_admin_before_download( Ok(()) } +#[tokio::test] +async fn plugin_install_rejects_remote_plugin_not_available() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_detail_with_install_policy( + &server, + REMOTE_PLUGIN_ID, + "1.2.3", + /*install_policy*/ "NOT_AVAILABLE", + ) + .await; + mount_empty_remote_installed_plugins(&server).await; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("not available for install")); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + /*expected_count*/ 0, + ) + .await?; + Ok(()) +} + #[tokio::test] async fn plugin_install_rejects_when_workspace_codex_plugins_disabled() -> Result<()> { let codex_home = TempDir::new()?; @@ -820,6 +896,66 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_install_failure_tracks_analytics_event() -> Result<()> { + let analytics_server = start_analytics_events_server().await?; + let codex_home = TempDir::new()?; + write_analytics_config(codex_home.path(), &analytics_server.uri())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let repo_root = TempDir::new()?; + write_plugin_marketplace( + repo_root.path(), + "debug", + "sample-plugin", + "./missing-plugin", + /*install_policy*/ None, + /*auth_policy*/ None, + )?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(err.error.code, -32600); + + let payload = wait_for_plugin_analytics_payload(&analytics_server).await?; + let event_params = &payload["events"][0]["event_params"]; + assert_eq!( + payload["events"][0]["event_type"], + "codex_plugin_install_failed" + ); + assert_eq!(event_params["plugin_id"], "sample-plugin@debug"); + assert_eq!(event_params["plugin_name"], "sample-plugin"); + assert_eq!(event_params["marketplace_name"], "debug"); + assert_eq!(event_params["has_skills"], json!(null)); + assert_eq!(event_params["mcp_server_count"], json!(null)); + assert_eq!(event_params["connector_ids"], json!(null)); + assert_eq!(event_params["product_client_id"], DEFAULT_CLIENT_NAME); + assert_eq!(event_params["error_type"], "store_invalid"); + Ok(()) +} + #[tokio::test] async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> { let codex_home = TempDir::new()?; @@ -887,6 +1023,7 @@ async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; mount_empty_remote_installed_plugins(&server).await; mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).await; + mount_backend_analytics_events(&server).await; let mut mcp = TestAppServer::new_with_env( codex_home.path(), @@ -918,6 +1055,15 @@ async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> /*expected_count*/ 0, ) .await?; + let payload = wait_for_plugin_analytics_payload(&server).await?; + let event_params = &payload["events"][0]["event_params"]; + assert_eq!( + payload["events"][0]["event_type"], + "codex_plugin_install_failed" + ); + assert_eq!(event_params["plugin_id"], REMOTE_PLUGIN_ID); + assert_eq!(event_params["marketplace_name"], "openai-curated-remote"); + assert_eq!(event_params["error_type"], "remote_bundle_download_status"); assert!( !codex_home .path() @@ -1867,6 +2013,45 @@ async fn mount_remote_plugin_detail_with_status_and_app_manifest( bundle_download_url: Option<&str>, status: PluginAvailability, app_manifest: Option, +) { + mount_remote_plugin_detail_with_options( + server, + remote_plugin_id, + release_version, + bundle_download_url, + status, + "AVAILABLE", + app_manifest, + ) + .await; +} + +async fn mount_remote_plugin_detail_with_install_policy( + server: &MockServer, + remote_plugin_id: &str, + release_version: &str, + install_policy: &str, +) { + mount_remote_plugin_detail_with_options( + server, + remote_plugin_id, + release_version, + /*bundle_download_url*/ None, + PluginAvailability::Available, + install_policy, + /*app_manifest*/ None, + ) + .await; +} + +async fn mount_remote_plugin_detail_with_options( + server: &MockServer, + remote_plugin_id: &str, + release_version: &str, + bundle_download_url: Option<&str>, + status: PluginAvailability, + install_policy: &str, + app_manifest: Option, ) { let status = match status { PluginAvailability::Available => "ENABLED", @@ -1883,7 +2068,7 @@ async fn mount_remote_plugin_detail_with_status_and_app_manifest( "id": "{remote_plugin_id}", "name": "linear", "scope": "GLOBAL", - "installation_policy": "AVAILABLE", + "installation_policy": "{install_policy}", "authentication_policy": "ON_USE", "status": "{status}", "release": {{ diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 7fbbcc0023..88b6cd3bb1 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -69,6 +69,7 @@ use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginIdError; +use codex_plugin::PluginTelemetryMetadata; use codex_plugin::app_connector_ids_from_declarations; use codex_plugin::prompt_safe_plugin_description; use codex_protocol::protocol::HookEventName; @@ -1079,12 +1080,29 @@ impl PluginsManager { &self, request: PluginInstallRequest, ) -> Result { - let resolved = find_installable_marketplace_plugin( + let resolved = match find_installable_marketplace_plugin( &request.marketplace_path, &request.plugin_name, self.restriction_product, - )?; - self.install_resolved_plugin(resolved).await + ) { + Ok(resolved) => resolved, + Err(err) => { + self.track_plugin_install_resolution_failed(&err); + return Err(err.into()); + } + }; + let plugin_id = resolved.plugin_id.clone(); + match self.install_resolved_plugin(resolved).await { + Ok(outcome) => Ok(outcome), + Err(err) => { + self.track_plugin_install_failed( + &plugin_id, + plugin_install_error_type(&err), + err.to_string(), + ); + Err(err) + } + } } pub async fn install_plugin_with_remote_sync( @@ -1093,21 +1111,101 @@ impl PluginsManager { auth: Option<&CodexAuth>, request: PluginInstallRequest, ) -> Result { - let resolved = find_installable_marketplace_plugin( + let resolved = match find_installable_marketplace_plugin( &request.marketplace_path, &request.plugin_name, self.restriction_product, - )?; + ) { + Ok(resolved) => resolved, + Err(err) => { + self.track_plugin_install_resolution_failed(&err); + return Err(err.into()); + } + }; let plugin_id = resolved.plugin_id.as_key(); // This only forwards the backend mutation before the local install flow. - crate::remote_legacy::enable_remote_plugin( + if let Err(err) = crate::remote_legacy::enable_remote_plugin( &remote_plugin_service_config(config), auth, &plugin_id, ) .await - .map_err(PluginInstallError::from)?; - self.install_resolved_plugin(resolved).await + { + let err = PluginInstallError::from(err); + self.track_plugin_install_failed( + &resolved.plugin_id, + plugin_install_error_type(&err), + err.to_string(), + ); + return Err(err); + } + let plugin_id = resolved.plugin_id.clone(); + match self.install_resolved_plugin(resolved).await { + Ok(outcome) => Ok(outcome), + Err(err) => { + self.track_plugin_install_failed( + &plugin_id, + plugin_install_error_type(&err), + err.to_string(), + ); + Err(err) + } + } + } + + fn track_plugin_install_resolution_failed(&self, err: &MarketplaceError) { + let plugin_id = match err { + MarketplaceError::PluginNotFound { + plugin_name, + marketplace_name, + } + | MarketplaceError::PluginNotAvailable { + plugin_name, + marketplace_name, + } => PluginId::new(plugin_name.clone(), marketplace_name.clone()).ok(), + MarketplaceError::Io { .. } + | MarketplaceError::MarketplaceNotFound { .. } + | MarketplaceError::InvalidMarketplaceFile { .. } + | MarketplaceError::PluginsDisabled + | MarketplaceError::InvalidPlugin(_) => None, + }; + if let Some(plugin_id) = plugin_id { + self.track_plugin_install_failed( + &plugin_id, + marketplace_error_type(err), + err.to_string(), + ); + } else { + tracing::warn!( + error_type = %marketplace_error_type(err), + error = %err, + "plugin install failed while resolving marketplace plugin" + ); + } + } + + fn track_plugin_install_failed( + &self, + plugin_id: &PluginId, + error_type: &'static str, + error_message: String, + ) { + tracing::warn!( + plugin_id = %plugin_id.as_key(), + error_type = %error_type, + error = %error_message, + "plugin install failed" + ); + let analytics_events_client = match self.analytics_events_client.read() { + Ok(client) => client.clone(), + Err(err) => err.into_inner().clone(), + }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_plugin_install_failed( + PluginTelemetryMetadata::from_plugin_id(plugin_id), + error_type.to_string(), + ); + } } async fn install_resolved_plugin( @@ -2173,6 +2271,54 @@ impl PluginInstallError { } } +fn plugin_install_error_type(err: &PluginInstallError) -> &'static str { + match err { + PluginInstallError::Marketplace(err) => marketplace_error_type(err), + PluginInstallError::Remote(err) => remote_plugin_mutation_error_type(err), + PluginInstallError::Store(err) => plugin_store_error_type(err), + PluginInstallError::Config(_) => "config", + PluginInstallError::Join(_) => "join", + } +} + +fn marketplace_error_type(err: &MarketplaceError) -> &'static str { + match err { + MarketplaceError::Io { .. } => "marketplace_io", + MarketplaceError::MarketplaceNotFound { .. } => "marketplace_not_found", + MarketplaceError::InvalidMarketplaceFile { .. } => "invalid_marketplace_file", + MarketplaceError::PluginNotFound { .. } => "plugin_not_found", + MarketplaceError::PluginNotAvailable { .. } => "plugin_not_available", + MarketplaceError::PluginsDisabled => "plugins_disabled", + MarketplaceError::InvalidPlugin(_) => "invalid_plugin", + } +} + +fn remote_plugin_mutation_error_type(err: &RemotePluginMutationError) -> &'static str { + match err { + RemotePluginMutationError::AuthRequired => "remote_mutation_auth_required", + RemotePluginMutationError::UnsupportedAuthMode => "remote_mutation_unsupported_auth_mode", + RemotePluginMutationError::AuthToken(_) => "remote_mutation_auth_token", + RemotePluginMutationError::InvalidBaseUrl(_) => "remote_mutation_invalid_base_url", + RemotePluginMutationError::InvalidBaseUrlPath => "remote_mutation_invalid_base_url_path", + RemotePluginMutationError::Request { .. } => "remote_mutation_request", + RemotePluginMutationError::UnexpectedStatus { .. } => "remote_mutation_unexpected_status", + RemotePluginMutationError::Decode { .. } => "remote_mutation_decode", + RemotePluginMutationError::UnexpectedPluginId { .. } => { + "remote_mutation_unexpected_plugin_id" + } + RemotePluginMutationError::UnexpectedEnabledState { .. } => { + "remote_mutation_unexpected_enabled_state" + } + } +} + +fn plugin_store_error_type(err: &PluginStoreError) -> &'static str { + match err { + PluginStoreError::Io { .. } => "store_io", + PluginStoreError::Invalid(_) => "store_invalid", + } +} + #[derive(Debug, thiserror::Error)] pub enum PluginUninstallError { #[error("{0}")] diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 4049ca14a6..e57b26840d 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -391,7 +391,10 @@ impl AppServerSession { .client .request_typed(ClientRequest::ExternalAgentConfigImport { request_id, - params: ExternalAgentConfigImportParams { migration_items }, + params: ExternalAgentConfigImportParams { + migration_items, + source: None, + }, }) .await .wrap_err("externalAgentConfig/import failed during Claude Code import"); From a0586ad12d59925be16e0b1c236c471c3375dbeb Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Wed, 17 Jun 2026 13:27:25 -0700 Subject: [PATCH 41/70] exec-server: expose environment registry payloads (#28651) ## Why Services that proxy the exec-server environment registry endpoints need to deserialize and forward the same Noise registration and harness-key validation payloads. Those wire models currently live as private, serialize-only structs in `exec-server`, which forces consumers to duplicate the contract. ## What changed - Add owned serde models for registration and harness-key validation requests and responses. - Use those models in the existing exec-server registry client. - Re-export the models from `codex-exec-server` and `codex-core-api`. - Keep the harness authorization request free of a derived `Debug` implementation so it is not accidentally logged. ## Testing - Focused exec-server registration and harness-key validation tests: 2 passed. - `cargo check -p codex-core-api` The full `codex-exec-server` suite compiled and ran 254 tests: 222 passed, while 32 existing filesystem sandbox tests could not run under the nested macOS sandbox (`sandbox_apply: Operation not permitted`). Co-authored-by: Codex --- codex-rs/core-api/src/lib.rs | 4 ++ .../exec-server/src/environment_registry.rs | 34 +++++++++++++++ codex-rs/exec-server/src/lib.rs | 5 +++ codex-rs/exec-server/src/remote.rs | 41 ++++--------------- 4 files changed, 52 insertions(+), 32 deletions(-) create mode 100644 codex-rs/exec-server/src/environment_registry.rs diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index aac0e21681..5a64a2b85e 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -48,6 +48,10 @@ pub use codex_core::resolve_installation_id; pub use codex_core::skills::SkillsService; pub use codex_core::thread_store_from_config; pub use codex_exec_server::EnvironmentManager; +pub use codex_exec_server::EnvironmentRegistryHarnessKeyValidationRequest; +pub use codex_exec_server::EnvironmentRegistryHarnessKeyValidationResponse; +pub use codex_exec_server::EnvironmentRegistryRegistrationRequest; +pub use codex_exec_server::EnvironmentRegistryRegistrationResponse; pub use codex_exec_server::ExecServerError; pub use codex_exec_server::ExecServerRuntimePaths; pub use codex_exec_server::NoiseChannelIdentity; diff --git a/codex-rs/exec-server/src/environment_registry.rs b/codex-rs/exec-server/src/environment_registry.rs new file mode 100644 index 0000000000..09ac5415f5 --- /dev/null +++ b/codex-rs/exec-server/src/environment_registry.rs @@ -0,0 +1,34 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::NoiseChannelPublicKey; + +/// Request body for registering an executor with the environment registry. +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +pub struct EnvironmentRegistryRegistrationRequest { + pub security_profile: String, + pub executor_public_key: NoiseChannelPublicKey, +} + +/// Environment registry response returned after executor registration. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct EnvironmentRegistryRegistrationResponse { + pub environment_id: String, + pub url: String, + pub security_profile: String, + pub executor_registration_id: String, +} + +/// Request body for authorizing a harness key with the environment registry. +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +pub struct EnvironmentRegistryHarnessKeyValidationRequest { + pub executor_registration_id: String, + pub harness_public_key: NoiseChannelPublicKey, + pub harness_key_authorization: String, +} + +/// Environment registry response returned after harness key validation. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct EnvironmentRegistryHarnessKeyValidationResponse { + pub valid: bool, +} diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index d6afe80e1e..df71eda95a 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -4,6 +4,7 @@ mod client_transport; mod connection; mod environment; mod environment_provider; +mod environment_registry; mod environment_toml; mod file_read; mod fs_helper; @@ -56,6 +57,10 @@ pub use environment::REMOTE_ENVIRONMENT_ID; pub use environment_provider::DefaultEnvironmentProvider; pub use environment_provider::EnvironmentProvider; pub use environment_provider::EnvironmentProviderFuture; +pub use environment_registry::EnvironmentRegistryHarnessKeyValidationRequest; +pub use environment_registry::EnvironmentRegistryHarnessKeyValidationResponse; +pub use environment_registry::EnvironmentRegistryRegistrationRequest; +pub use environment_registry::EnvironmentRegistryRegistrationResponse; pub use fs_helper::CODEX_FS_HELPER_ARG1; pub use fs_helper_main::main as run_fs_helper_main; pub use local_file_system::LOCAL_FS; diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index 7a470f3d95..ace1578da8 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -3,7 +3,6 @@ use std::time::Duration; use codex_api::SharedAuthProvider; use reqwest::StatusCode; use serde::Deserialize; -use serde::Serialize; use tokio::time::sleep; use tokio_tungstenite::connect_async_with_config; use tracing::debug; @@ -12,6 +11,10 @@ use tracing::warn; use codex_utils_rustls_provider::ensure_rustls_crypto_provider; +use crate::EnvironmentRegistryHarnessKeyValidationRequest; +use crate::EnvironmentRegistryHarnessKeyValidationResponse; +use crate::EnvironmentRegistryRegistrationRequest; +use crate::EnvironmentRegistryRegistrationResponse; use crate::ExecServerError; use crate::ExecServerRuntimePaths; use crate::NoiseChannelIdentity; @@ -67,8 +70,8 @@ impl EnvironmentRegistryClient { )) .headers(self.auth_provider.to_auth_headers()) .json(&EnvironmentRegistryRegistrationRequest { - security_profile: NOISE_RELAY_SECURITY_PROFILE, - executor_public_key, + security_profile: NOISE_RELAY_SECURITY_PROFILE.to_string(), + executor_public_key: executor_public_key.clone(), }) .send() .await?; @@ -120,32 +123,6 @@ impl EnvironmentRegistryClient { } } -#[derive(Serialize)] -struct EnvironmentRegistryRegistrationRequest<'a> { - security_profile: &'static str, - executor_public_key: &'a NoiseChannelPublicKey, -} - -#[derive(Debug, Clone, Eq, PartialEq, Deserialize)] -struct EnvironmentRegistryRegistrationResponse { - environment_id: String, - url: String, - security_profile: String, - executor_registration_id: String, -} - -#[derive(Serialize)] -struct EnvironmentRegistryHarnessKeyValidationRequest<'a> { - executor_registration_id: &'a str, - harness_public_key: &'a NoiseChannelPublicKey, - harness_key_authorization: &'a str, -} - -#[derive(Deserialize)] -struct EnvironmentRegistryHarnessKeyValidationResponse { - valid: bool, -} - #[derive(Clone)] struct RegistryHarnessKeyValidator { client: EnvironmentRegistryClient, @@ -172,9 +149,9 @@ impl HarnessKeyValidator for RegistryHarnessKeyValidator { )) .headers(self.client.auth_provider.to_auth_headers()) .json(&EnvironmentRegistryHarnessKeyValidationRequest { - executor_registration_id: &self.executor_registration_id, - harness_public_key, - harness_key_authorization: authorization, + executor_registration_id: self.executor_registration_id.clone(), + harness_public_key: harness_public_key.clone(), + harness_key_authorization: authorization.to_string(), }) .send() .await?; From a760b63f838db94369f91b829a366c76f4761107 Mon Sep 17 00:00:00 2001 From: Casey Chow Date: Wed, 17 Jun 2026 17:06:42 -0400 Subject: [PATCH 42/70] fix(plugins): support root local marketplace plugins (#28771) ## Summary - allow local marketplace `source.path: "."` and `source.path: "./"` to resolve to the marketplace root - keep `""` invalid and preserve rejection of non-root paths without `./` plus non-normal/traversal paths - add focused regression coverage for repo-root plugin layouts and rejected local paths ## Tests - `RUSTUP_TOOLCHAIN=stable just fmt` - `RUSTUP_TOOLCHAIN=stable just test -p codex-core-plugins` - `RUSTUP_TOOLCHAIN=stable just fix -p codex-core-plugins` Note: plain pinned-toolchain `just fmt` was blocked locally by a rustup `clippy` component conflict, so validation used the working stable 1.95 toolchain fallback. --- codex-rs/core-plugins/src/marketplace.rs | 22 ++- .../core-plugins/src/marketplace_tests.rs | 138 +++++++++++++++--- 2 files changed, 128 insertions(+), 32 deletions(-) diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index 228495c48b..ce4fcd2ffc 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -544,20 +544,26 @@ fn resolve_local_plugin_source_path( marketplace_path: &AbsolutePathBuf, path: &str, ) -> Result { - let Some(path) = path.strip_prefix("./") else { + match path { + "" => { + return Err(MarketplaceError::InvalidMarketplaceFile { + path: marketplace_path.to_path_buf(), + message: "local plugin source path must not be empty".to_string(), + }); + } + "." | "./" => return marketplace_root_dir(marketplace_path), + _ => {} + } + + // Non-root local sources must keep the explicit `./` prefix and remain normalized. + let Some(relative_path) = path.strip_prefix("./") else { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: "local plugin source path must start with `./`".to_string(), }); }; - if path.is_empty() { - return Err(MarketplaceError::InvalidMarketplaceFile { - path: marketplace_path.to_path_buf(), - message: "local plugin source path must not be empty".to_string(), - }); - } - let relative_source_path = Path::new(path); + let relative_source_path = Path::new(relative_path); if relative_source_path .components() .any(|component| !matches!(component, Component::Normal(_))) diff --git a/codex-rs/core-plugins/src/marketplace_tests.rs b/codex-rs/core-plugins/src/marketplace_tests.rs index 47c88b73aa..61385f1db0 100644 --- a/codex-rs/core-plugins/src/marketplace_tests.rs +++ b/codex-rs/core-plugins/src/marketplace_tests.rs @@ -420,6 +420,92 @@ fn list_marketplaces_supports_alternate_manifest_layout() { ); } +#[test] +fn list_marketplaces_supports_repo_root_local_plugin_sources() { + for path in [".", "./"] { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(repo_root.join(".codex-plugin")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + format!( + r#"{{ + "name": "repo-root-marketplace", + "plugins": [ + {{ + "name": "repo-root-plugin", + "source": {{ + "source": "local", + "path": "{path}" + }} + }} + ] +}}"# + ), + ) + .unwrap(); + fs::write( + repo_root.join(".codex-plugin/plugin.json"), + r#"{ + "name":"repo-root-plugin", + "interface": { + "displayName": "Repo Root Plugin" + } +}"#, + ) + .unwrap(); + + let marketplaces = list_marketplaces_with_home( + &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], + /*home_dir*/ None, + ) + .unwrap() + .marketplaces; + + assert_eq!( + marketplaces, + vec![Marketplace { + name: "repo-root-marketplace".to_string(), + path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")) + .unwrap(), + interface: None, + plugins: vec![MarketplacePlugin { + name: "repo-root-plugin".to_string(), + local_version: None, + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(repo_root).unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: Some(PluginManifestInterface { + display_name: Some("Repo Root Plugin".to_string()), + short_description: None, + long_description: None, + developer_name: None, + category: None, + capabilities: Vec::new(), + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: None, + logo: None, + screenshots: Vec::new(), + }), + keywords: Vec::new(), + }], + }] + ); + } +} + #[test] fn list_marketplaces_includes_plugins_without_discoverable_manifest() { let tmp = tempdir().unwrap(); @@ -1423,37 +1509,41 @@ fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() { #[test] fn find_marketplace_plugin_skips_invalid_local_paths() { - let tmp = tempdir().unwrap(); - let repo_root = tmp.path().join("repo"); - fs::create_dir_all(repo_root.join(".git")).unwrap(); - fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); - fs::write( - repo_root.join(".agents/plugins/marketplace.json"), - r#"{ + for path in ["", "plugin-1", "././", "./plugins/../", "../plugin-1"] { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + format!( + r#"{{ "name": "codex-curated", "plugins": [ - { + {{ "name": "local-plugin", - "source": { + "source": {{ "source": "local", - "path": "../plugin-1" - } - } + "path": "{path}" + }} + }} ] -}"#, - ) - .unwrap(); +}}"# + ), + ) + .unwrap(); - let err = find_marketplace_plugin( - &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), - "local-plugin", - ) - .unwrap_err(); + let err = find_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "local-plugin", + ) + .unwrap_err(); - assert_eq!( - err.to_string(), - "plugin `local-plugin` was not found in marketplace `codex-curated`" - ); + assert_eq!( + err.to_string(), + "plugin `local-plugin` was not found in marketplace `codex-curated`" + ); + } } #[test] From 243243ab8f0146cfc59c22dbf00cb6f7a34da536 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Wed, 17 Jun 2026 14:48:17 -0700 Subject: [PATCH 43/70] bazel: refresh expired macOS SDK pin (#28791) ## Why macOS Bazel jobs fail before target analysis because the pinned Apple CDN object now returns HTTP 403. ## What Uprev the pin to Apple's currently live macOS 26.5 Command Line Tools package, including its checksum and SDK extraction path. ## Validation - Built `@macos_sdk//sysroot` from a fresh Bazel output root. - Regenerated and checked `MODULE.bazel.lock`; it remains unchanged. --- MODULE.bazel | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/MODULE.bazel b/MODULE.bazel index 98619420aa..e9ec662d09 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -31,11 +31,11 @@ register_toolchains("@llvm//toolchain:all") osx = use_extension("@llvm//extensions:osx.bzl", "osx") osx.from_archive( - sha256 = "1bde70c0b1c2ab89ff454acbebf6741390d7b7eb149ca2a3ca24cc9203a408b7", - strip_prefix = "Payload/Library/Developer/CommandLineTools/SDKs/MacOSX26.4.sdk", + sha256 = "5f044578cd78a3a9b9c965a42d56bad609ee5d252e1d4e6aa7c42fc3f35fee7b", + strip_prefix = "Payload/Library/Developer/CommandLineTools/SDKs/MacOSX26.5.sdk", type = "pkg", urls = [ - "https://swcdn.apple.com/content/downloads/32/53/047-96692-A_OAHIHT53YB/ybtshxmrcju8m2qvw3w5elr4rajtg1x3y3/CLTools_macOSNMOS_SDK.pkg", + "https://swcdn.apple.com/content/downloads/09/08/047-91568-A_Y1CFZWQCD4/4xekpyz43i26dbp4enxfro8eb1q7wiujh5/CLTools_macOSNMOS_SDK.pkg", ], ) osx.frameworks(names = [ From 38211a3ff8e99a908257f34eb1fe9cd802fce501 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Wed, 17 Jun 2026 14:53:54 -0700 Subject: [PATCH 44/70] [codex] trace tools build latency (#28782) Add more tracing spans around tool building. --- codex-rs/core-plugins/src/manager.rs | 9 +++++++ codex-rs/core-plugins/src/remote.rs | 2 ++ codex-rs/core/src/connectors.rs | 1 + .../core/src/tools/handlers/tool_search.rs | 7 ++++++ codex-rs/core/src/tools/registry.rs | 2 ++ codex-rs/core/src/tools/router.rs | 1 + codex-rs/core/src/tools/spec_plan.rs | 24 +++++++++++++++++++ codex-rs/login/src/auth/manager.rs | 2 ++ 8 files changed, 48 insertions(+) diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 88b6cd3bb1..fb004f4943 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -942,6 +942,14 @@ impl PluginsManager { Ok(featured_plugin_ids) } + #[instrument( + level = "trace", + skip_all, + fields( + plugins_enabled = config.plugins_enabled, + remote_plugin_enabled = config.remote_plugin_enabled + ) + )] pub async fn recommended_plugins_mode_for_config( &self, config: &PluginsConfigInput, @@ -1014,6 +1022,7 @@ impl PluginsManager { /// Returns endpoint recommendations eligible for installation in the current client. /// `None` selects the legacy discovery workflow. + #[instrument(level = "trace", skip_all)] pub async fn recommended_plugin_candidates_for_config( &self, input: RecommendedPluginCandidatesInput<'_>, diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 1f8d3f7201..a0007aa1df 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -27,6 +27,7 @@ use std::fs; use std::path::Path; use std::path::PathBuf; use std::time::Duration; +use tracing::instrument; use url::Url; mod catalog_cache; @@ -805,6 +806,7 @@ pub async fn fetch_and_cache_global_remote_plugin_catalog( Ok(()) } +#[instrument(level = "trace", skip_all)] pub async fn fetch_recommended_plugins( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index f1c8c5607b..496f14e86a 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -94,6 +94,7 @@ pub(crate) async fn list_accessible_and_enabled_connectors_from_manager( .collect() } +#[instrument(level = "trace", skip_all)] pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( config: &Config, plugins_manager: &PluginsManager, diff --git a/codex-rs/core/src/tools/handlers/tool_search.rs b/codex-rs/core/src/tools/handlers/tool_search.rs index 5c201388b6..272da2f226 100644 --- a/codex-rs/core/src/tools/handlers/tool_search.rs +++ b/codex-rs/core/src/tools/handlers/tool_search.rs @@ -20,6 +20,7 @@ use codex_tools::ToolSpec; use codex_tools::coalesce_loadable_tool_specs; use std::sync::Arc; use std::sync::Mutex; +use tracing::instrument; pub struct ToolSearchHandler { search_infos: Vec, @@ -33,6 +34,7 @@ pub(crate) struct ToolSearchHandlerCache { } impl ToolSearchHandlerCache { + #[instrument(level = "trace", skip_all, fields(search_info_count = search_infos.len()))] pub(crate) fn get_or_build(&self, search_infos: Vec) -> Arc { { let cached = self.cached(); @@ -64,6 +66,11 @@ impl ToolSearchHandlerCache { } impl ToolSearchHandler { + #[instrument( + level = "trace", + skip_all, + fields(search_info_count = search_infos.len()) + )] pub(crate) fn new(search_infos: Vec) -> Self { let search_source_infos = search_infos .iter() diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 23294e6944..2220d4244a 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -34,6 +34,7 @@ use codex_tools::ToolSearchInfo; use codex_tools::ToolSpec; use futures::future::BoxFuture; use serde_json::Value; +use tracing::instrument; pub(crate) type ToolTelemetryTags = Vec<(&'static str, String)>; @@ -327,6 +328,7 @@ impl ToolRegistry { Self { tools } } + #[instrument(level = "trace", skip_all)] pub(crate) fn from_tools(tools: impl IntoIterator>) -> Self { let mut tools_by_name = HashMap::new(); for tool in tools { diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 0acd6e37c2..b488448398 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -240,6 +240,7 @@ impl ToolRouter { } } +#[instrument(level = "trace", skip_all)] pub(crate) fn extension_tool_executors( session: &Session, ) -> Vec>> { diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 7680e5d4ff..c697f287aa 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -256,6 +256,7 @@ fn spec_for_model_request( } } +#[instrument(level = "trace", skip_all)] fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec { let turn_context = context.turn_context; // Responses Lite accepts schemas for client-executed tools, not hosted Responses tools. @@ -500,6 +501,7 @@ fn build_code_mode_executors( ] } +#[instrument(level = "trace", skip_all, fields(tool_spec_count = specs.len()))] fn merge_into_namespaces(specs: Vec) -> Vec { let mut merged_specs = Vec::with_capacity(specs.len()); let mut namespace_indices = BTreeMap::::new(); @@ -592,6 +594,7 @@ fn standalone_web_search_enabled(turn_context: &TurnContext) -> bool { .enabled(Feature::StandaloneWebSearch)) } +#[instrument(level = "trace", skip_all)] fn add_shell_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { let turn_context = context.turn_context; let features = turn_context.config.features.get(); @@ -643,6 +646,7 @@ fn unified_exec_should_include_shell_parameter(turn_context: &TurnContext) -> bo .any(|environment| environment.environment.is_remote()) } +#[instrument(level = "trace", skip_all)] fn add_mcp_resource_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { if context.mcp_tools.is_some() { planned_tools.add(ListMcpResourcesHandler); @@ -651,6 +655,7 @@ fn add_mcp_resource_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut } } +#[instrument(level = "trace", skip_all)] fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { let turn_context = context.turn_context; let features = turn_context.config.features.get(); @@ -722,6 +727,7 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut } } +#[instrument(level = "trace", skip_all)] fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { let turn_context = context.turn_context; if collab_tools_enabled(turn_context) { @@ -810,6 +816,14 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu } } +#[instrument( + level = "trace", + skip_all, + fields( + direct_mcp_tool_count = context.mcp_tools.map_or(0, <[ToolInfo]>::len), + deferred_mcp_tool_count = context.deferred_mcp_tools.map_or(0, <[ToolInfo]>::len) + ) +)] fn add_mcp_runtime_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { if let Some(mcp_tools) = context.mcp_tools { for tool in mcp_tools { @@ -836,6 +850,11 @@ fn add_mcp_runtime_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut } } +#[instrument( + level = "trace", + skip_all, + fields(dynamic_tool_count = context.dynamic_tools.len()) +)] fn add_dynamic_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { for spec in context.dynamic_tools { match spec { @@ -868,6 +887,11 @@ fn add_dynamic_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut Plan } } +#[instrument( + level = "trace", + skip_all, + fields(extension_tool_executor_count = context.extension_tool_executors.len()) +)] fn add_extension_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { // Extension ToolContributor implementations are resolved into executors // before planning. Core only adapts those executors into its runtime set. diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index f4ed2a9369..bb548ffe78 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -17,6 +17,7 @@ use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; use tokio::sync::Semaphore; use tokio::sync::watch; +use tracing::instrument; use codex_agent_identity::ChatGptEnvironment; use codex_agent_identity::decode_agent_identity_jwt; @@ -1719,6 +1720,7 @@ impl AuthManager { /// Current cached auth (clone). May be `None` if not logged in or load failed. /// For managed ChatGPT auth that needs a proactive refresh, first performs /// a guarded reload and then refreshes only if the on-disk auth is unchanged. + #[instrument(level = "trace", skip_all)] pub async fn auth(&self) -> Option { if let Some(auth) = self.resolve_external_api_key_auth().await { return Some(auth); From e7b6e0d8591870130018eff5d3fe518304145053 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Wed, 17 Jun 2026 15:17:07 -0700 Subject: [PATCH 45/70] path-uri: decouple native path parsing (#28778) ## Why `PathUri::join` should not depend on the app-server compatibility wrapper `LegacyAppPathString` to parse native paths. Native path parsing belongs to the URI abstraction that it constructs. ## What Move platform-independent native path parsing into the root `PathUri` module. `PathUri::join` and `LegacyAppPathString` now share the crate-private `PathUri::from_absolute_native_path` constructor. --- .../utils/path-uri/src/api_path_string.rs | 135 +----------------- codex-rs/utils/path-uri/src/lib.rs | 130 ++++++++++++++++- 2 files changed, 135 insertions(+), 130 deletions(-) diff --git a/codex-rs/utils/path-uri/src/api_path_string.rs b/codex-rs/utils/path-uri/src/api_path_string.rs index 0881c64d7c..5fc204b178 100644 --- a/codex-rs/utils/path-uri/src/api_path_string.rs +++ b/codex-rs/utils/path-uri/src/api_path_string.rs @@ -1,4 +1,6 @@ +use crate::PathConvention; use crate::PathUri; +use crate::is_windows_separator_byte; use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; @@ -33,10 +35,6 @@ use ts_rs::TS; pub struct LegacyAppPathString(String); impl LegacyAppPathString { - pub(super) fn from_str(path: &str) -> Self { - Self(path.to_string()) - } - /// Renders an absolute path using the current host's path convention. pub fn from_abs_path(path: &AbsolutePathBuf) -> Self { Self(path.to_string_lossy().into_owned()) @@ -69,13 +67,11 @@ impl LegacyAppPathString { &self, convention: PathConvention, ) -> Result { - let path = match convention { - PathConvention::Posix => parse_posix_path(&self.0), - PathConvention::Windows => parse_windows_path(&self.0), - }; - path.ok_or_else(|| LegacyAppPathStringError::InvalidNativePath { - path: self.0.clone(), - convention, + PathUri::from_absolute_native_path(&self.0, convention).ok_or_else(|| { + LegacyAppPathStringError::InvalidNativePath { + path: self.0.clone(), + convention, + } }) } @@ -115,88 +111,6 @@ impl From for LegacyAppPathString { } } -fn parse_posix_path(path: &str) -> Option { - let path = path.strip_prefix('/')?; - if path.contains('\0') { - return Some(PathUri::from_opaque_path_bytes( - format!("/{path}").as_bytes(), - )); - } - path_uri_from_segments(/*host*/ None, path.split('/')) -} - -fn parse_windows_path(path: &str) -> Option { - let bytes = path.as_bytes(); - let uses_namespace = matches!( - bytes, - [first, second, namespace @ (b'.' | b'?'), separator, ..] - if is_windows_separator_byte(*first) - && is_windows_separator_byte(*second) - && is_windows_separator_byte(*separator) - && matches!(*namespace, b'.' | b'?') - ); - if uses_namespace || path.contains('\0') { - return Some(windows_opaque_path_uri(path)); - } - - if matches!( - bytes, - [drive, b':', separator, ..] - if drive.is_ascii_alphabetic() && is_windows_separator_byte(*separator) - ) { - return path_uri_from_segments( - /*host*/ None, - std::iter::once(&path[..2]).chain(path[3..].split(is_windows_separator_char)), - ); - } - - if matches!(bytes, [first, second, ..] - if is_windows_separator_byte(*first) && is_windows_separator_byte(*second)) - { - let mut components = path[2..].split(is_windows_separator_char); - let host = components.next().filter(|host| !host.is_empty())?; - let share = components.next().filter(|share| !share.is_empty())?; - return path_uri_from_segments(Some(host), std::iter::once(share).chain(components)) - .or_else(|| Some(windows_opaque_path_uri(path))); - } - - None -} - -fn path_uri_from_segments<'a>( - host: Option<&str>, - segments: impl Iterator, -) -> Option { - let mut url = url::Url::parse("file:///").ok()?; - if let Some(host) = host { - url.set_host(Some(host)).ok()?; - } - { - let mut url_segments = url.path_segments_mut().ok()?; - url_segments.clear(); - for segment in segments { - url_segments.push(segment); - } - } - PathUri::try_from(url).ok() -} - -fn windows_opaque_path_uri(path: &str) -> PathUri { - let path_bytes = path - .encode_utf16() - .flat_map(u16::to_le_bytes) - .collect::>(); - PathUri::from_opaque_path_bytes(&path_bytes) -} - -fn is_windows_separator_char(character: char) -> bool { - matches!(character, '\\' | '/') -} - -fn is_windows_separator_byte(character: u8) -> bool { - matches!(character, b'\\' | b'/') -} - fn render_opaque_fallback( path: &PathUri, path_bytes: &[u8], @@ -372,41 +286,6 @@ pub enum LegacyAppPathStringError { }, } -/// Path syntax used to render a [`PathUri`] as an operating-system path. -/// -/// This describes path grammar rather than a specific operating system because -/// Linux and macOS share the POSIX representation relevant here. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case")] -pub enum PathConvention { - Posix, - Windows, -} - -impl PathConvention { - /// Returns the path convention used by the current process. - #[cfg(windows)] - pub const fn native() -> Self { - Self::Windows - } - - /// Returns the path convention used by the current process. - #[cfg(unix)] - pub const fn native() -> Self { - Self::Posix - } -} - -impl fmt::Display for PathConvention { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Posix => f.write_str("POSIX"), - Self::Windows => f.write_str("Windows"), - } - } -} - #[cfg(test)] #[path = "api_path_string_tests.rs"] mod tests; diff --git a/codex-rs/utils/path-uri/src/lib.rs b/codex-rs/utils/path-uri/src/lib.rs index a7f3c83369..5e8b5b2cbc 100644 --- a/codex-rs/utils/path-uri/src/lib.rs +++ b/codex-rs/utils/path-uri/src/lib.rs @@ -21,7 +21,6 @@ mod api_path_string; pub use api_path_string::LegacyAppPathString; pub use api_path_string::LegacyAppPathStringError; -pub use api_path_string::PathConvention; pub const FILE_SCHEME: &str = "file"; const BAD_PATH_URI_PREFIX: &str = "file:///%00/bad/path/"; @@ -97,6 +96,16 @@ impl PathUri { Self::from_opaque_path_bytes(&path_bytes) } + pub(crate) fn from_absolute_native_path( + path: &str, + convention: PathConvention, + ) -> Option { + match convention { + PathConvention::Posix => parse_posix_path(path), + PathConvention::Windows => parse_windows_path(path), + } + } + fn from_opaque_path_bytes(path_bytes: &[u8]) -> Self { let encoded_path = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(path_bytes); let Ok(uri) = Self::parse(&format!("{BAD_PATH_URI_PREFIX}{encoded_path}")) else { @@ -240,7 +249,7 @@ impl PathUri { })?; // An absolute native path is already fully resolved, so replace the base URI's main path // instead of appending it. - if let Ok(absolute) = LegacyAppPathString::from_str(path).to_path_uri(convention) { + if let Some(absolute) = Self::from_absolute_native_path(path, convention) { return Ok(absolute); } let path_bytes = path.as_bytes(); @@ -521,6 +530,88 @@ fn infer_opaque_path_convention(path_bytes: &[u8]) -> Option { (has_drive || has_unc_prefix).then_some(PathConvention::Windows) } +fn parse_posix_path(path: &str) -> Option { + let path = path.strip_prefix('/')?; + if path.contains('\0') { + return Some(PathUri::from_opaque_path_bytes( + format!("/{path}").as_bytes(), + )); + } + path_uri_from_segments(/*host*/ None, path.split('/')) +} + +fn parse_windows_path(path: &str) -> Option { + let bytes = path.as_bytes(); + let uses_namespace = matches!( + bytes, + [first, second, namespace @ (b'.' | b'?'), separator, ..] + if is_windows_separator_byte(*first) + && is_windows_separator_byte(*second) + && is_windows_separator_byte(*separator) + && matches!(*namespace, b'.' | b'?') + ); + if uses_namespace || path.contains('\0') { + return Some(windows_opaque_path_uri(path)); + } + + if matches!( + bytes, + [drive, b':', separator, ..] + if drive.is_ascii_alphabetic() && is_windows_separator_byte(*separator) + ) { + return path_uri_from_segments( + /*host*/ None, + std::iter::once(&path[..2]).chain(path[3..].split(is_windows_separator_char)), + ); + } + + if matches!(bytes, [first, second, ..] + if is_windows_separator_byte(*first) && is_windows_separator_byte(*second)) + { + let mut components = path[2..].split(is_windows_separator_char); + let host = components.next().filter(|host| !host.is_empty())?; + let share = components.next().filter(|share| !share.is_empty())?; + return path_uri_from_segments(Some(host), std::iter::once(share).chain(components)) + .or_else(|| Some(windows_opaque_path_uri(path))); + } + + None +} + +fn path_uri_from_segments<'a>( + host: Option<&str>, + segments: impl Iterator, +) -> Option { + let mut url = Url::parse("file:///").ok()?; + if let Some(host) = host { + url.set_host(Some(host)).ok()?; + } + { + let mut url_segments = url.path_segments_mut().ok()?; + url_segments.clear(); + for segment in segments { + url_segments.push(segment); + } + } + PathUri::try_from(url).ok() +} + +fn windows_opaque_path_uri(path: &str) -> PathUri { + let path_bytes = path + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(); + PathUri::from_opaque_path_bytes(&path_bytes) +} + +fn is_windows_separator_char(character: char) -> bool { + matches!(character, '\\' | '/') +} + +pub(crate) fn is_windows_separator_byte(character: u8) -> bool { + matches!(character, b'\\' | b'/') +} + /// Rejects URI metadata that has no defined meaning for `file:` URIs. fn validate_common_known_uri(url: &Url) -> Result<(), PathUriParseError> { if !url.username().is_empty() || url.password().is_some() { @@ -573,6 +664,41 @@ pub enum PathUriParseError { JoinPathMustBeRelative(String), } +/// Path syntax used to render a [`PathUri`] as an operating-system path. +/// +/// This describes path grammar rather than a specific operating system because +/// Linux and macOS share the POSIX representation relevant here. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum PathConvention { + Posix, + Windows, +} + +impl PathConvention { + /// Returns the path convention used by the current process. + #[cfg(windows)] + pub const fn native() -> Self { + Self::Windows + } + + /// Returns the path convention used by the current process. + #[cfg(unix)] + pub const fn native() -> Self { + Self::Posix + } +} + +impl fmt::Display for PathConvention { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Posix => f.write_str("POSIX"), + Self::Windows => f.write_str("Windows"), + } + } +} + #[cfg(test)] #[path = "tests.rs"] mod tests; From c274a83f8bb0f9b5520a3f1d475060bebbe3b728 Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Wed, 17 Jun 2026 17:20:53 -0700 Subject: [PATCH 46/70] feat(exec-server): add Noise rendezvous environment (#28774) ## Why Codex can run a remote exec server through the Noise relay, but the normal environment-manager path could not establish an environment-registry-backed harness connection. Signed rendezvous URLs and harness authorizations are short-lived, so reconnects must fetch a fresh bundle instead of retaining stale connection credentials. A stalled registry request must also fail within the regular remote connection deadline, without exposing these credentials in debug logs. Issue: N/A (internal environment-service integration). ## What Changed - Add environment-manager configuration for a registry-backed Noise rendezvous environment. - Request a fresh bundle from `/cloud/environment/{environment_id}/connect` for every physical harness connection, using the existing 10-second remote connection timeout. - Share the Environment Registry register, connect, and validate wire payloads through `codex-exec-server` and `codex-core-api`. - Redact the signed rendezvous URL and harness authorization from the public connect response's `Debug` output. - Add focused coverage for registry bundle retrieval, stalled requests, and credential redaction. --- codex-rs/core-api/src/lib.rs | 2 + codex-rs/exec-server/Cargo.toml | 1 + codex-rs/exec-server/src/environment.rs | 108 +++++++ .../exec-server/src/environment_registry.rs | 34 +++ .../src/environment_registry_tests.rs | 22 ++ codex-rs/exec-server/src/lib.rs | 2 + codex-rs/exec-server/src/remote.rs | 264 ++++++++++++++++++ 7 files changed, 433 insertions(+) create mode 100644 codex-rs/exec-server/src/environment_registry_tests.rs diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index 5a64a2b85e..de3a7de905 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -48,6 +48,8 @@ pub use codex_core::resolve_installation_id; pub use codex_core::skills::SkillsService; pub use codex_core::thread_store_from_config; pub use codex_exec_server::EnvironmentManager; +pub use codex_exec_server::EnvironmentRegistryConnectRequest; +pub use codex_exec_server::EnvironmentRegistryConnectResponse; pub use codex_exec_server::EnvironmentRegistryHarnessKeyValidationRequest; pub use codex_exec_server::EnvironmentRegistryHarnessKeyValidationResponse; pub use codex_exec_server::EnvironmentRegistryRegistrationRequest; diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 426567976c..31fc4ea0aa 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -28,6 +28,7 @@ codex-utils-path-uri = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-rustls-provider = { workspace = true } futures = { workspace = true } +http = { workspace = true } reqwest = { workspace = true, features = ["json", "rustls-tls", "stream"] } prost = "0.14.3" serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 0c703ecef3..6159fb2dd2 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -25,11 +25,19 @@ use crate::local_process::LocalProcess; use crate::process::ExecBackend; use crate::protocol::EnvironmentInfo; use crate::protocol::ShellInfo; +use crate::remote::NoiseRendezvousEnvironmentConfig; use crate::remote_file_system::RemoteFileSystem; use crate::remote_process::RemoteProcess; use codex_shell_command::shell_detect::DetectedShell; pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; +pub const CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR: &str = + "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL"; +pub const CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR: &str = + "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID"; +pub const CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR: &str = "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN"; +pub const CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR: &str = + "CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID"; /// Owns the execution/filesystem environments available to the Codex runtime. /// @@ -98,6 +106,9 @@ impl EnvironmentManager { codex_home: impl AsRef, local_runtime_paths: Option, ) -> Result { + if let Some(config) = noise_environment_config_from_env()? { + return Self::from_noise_environment_config(config, local_runtime_paths); + } let provider = environment_provider_from_codex_home(codex_home.as_ref())?; Self::from_snapshot(provider.snapshot().await?, local_runtime_paths) } @@ -107,6 +118,9 @@ impl EnvironmentManager { pub async fn from_env( local_runtime_paths: Option, ) -> Result { + if let Some(config) = noise_environment_config_from_env()? { + return Self::from_noise_environment_config(config, local_runtime_paths); + } let provider = DefaultEnvironmentProvider::from_env(); Self::from_snapshot(provider.snapshot().await?, local_runtime_paths) } @@ -122,6 +136,23 @@ impl EnvironmentManager { } } + fn from_noise_environment_config( + config: NoiseRendezvousEnvironmentConfig, + local_runtime_paths: Option, + ) -> Result { + let manager = Self { + default_environment: Some(REMOTE_ENVIRONMENT_ID.to_string()), + environments: RwLock::new(HashMap::new()), + local_environment: None, + local_runtime_paths, + }; + manager.upsert_noise_environment( + REMOTE_ENVIRONMENT_ID.to_string(), + config.connect_provider(), + )?; + Ok(manager) + } + /// Builds a test-only manager that keeps the provider default while also /// allowing tests to select the local environment explicitly. pub async fn create_for_tests_with_local( @@ -317,6 +348,53 @@ impl EnvironmentManager { } } +fn noise_environment_config_from_env() +-> Result, ExecServerError> { + noise_environment_config_from_values( + optional_environment_value(CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR), + optional_environment_value(CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR), + optional_environment_value(CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR), + optional_environment_value(CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR), + ) +} + +fn noise_environment_config_from_values( + registry_url: Option, + environment_id: Option, + auth_token: Option, + chatgpt_account_id: Option, +) -> Result, ExecServerError> { + let (registry_url, environment_id, auth_token) = + match (registry_url, environment_id, auth_token) { + (None, None, None) => return Ok(None), + (Some(registry_url), Some(environment_id), Some(auth_token)) => { + (registry_url, environment_id, auth_token) + } + _ => { + return Err(ExecServerError::EnvironmentRegistryConfig(format!( + "Noise environment requires {CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR}, \ +{CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR}, and \ +{CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR}" + ))); + } + }; + + let config = NoiseRendezvousEnvironmentConfig::new( + registry_url, + environment_id, + auth_token, + chatgpt_account_id, + )?; + Ok(Some(config)) +} + +fn optional_environment_value(name: &str) -> Option { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + /// Concrete execution/filesystem environment selected for a session. /// /// This bundles the selected backend metadata together with the local runtime @@ -528,6 +606,7 @@ mod tests { use super::EnvironmentManager; use super::LOCAL_ENVIRONMENT_ID; use super::REMOTE_ENVIRONMENT_ID; + use super::noise_environment_config_from_values; use crate::ExecServerRuntimePaths; use crate::ProcessId; use crate::environment_provider::EnvironmentDefault; @@ -547,6 +626,35 @@ mod tests { assert!(manager.try_local_environment().is_none()); } + #[test] + fn noise_environment_config_selects_remote_as_default() { + let config = noise_environment_config_from_values( + Some("http://registry.example/api".to_string()), + Some("environment-requested".to_string()), + Some("registry-token".to_string()), + Some("workspace-123".to_string()), + ) + .expect("parse noise environment configuration") + .expect("noise environment configuration"); + + let manager = EnvironmentManager::from_noise_environment_config( + config, /*local_runtime_paths*/ None, + ) + .expect("build environment manager"); + + assert_eq!( + manager.default_environment_id(), + Some(REMOTE_ENVIRONMENT_ID) + ); + assert!( + manager + .default_environment() + .expect("remote environment") + .is_remote() + ); + assert_local_environment_unavailable(&manager); + } + #[tokio::test] async fn create_local_environment_does_not_connect() { let environment = Environment::create(/*exec_server_url*/ None, test_runtime_paths()) diff --git a/codex-rs/exec-server/src/environment_registry.rs b/codex-rs/exec-server/src/environment_registry.rs index 09ac5415f5..bc0755fd68 100644 --- a/codex-rs/exec-server/src/environment_registry.rs +++ b/codex-rs/exec-server/src/environment_registry.rs @@ -19,6 +19,36 @@ pub struct EnvironmentRegistryRegistrationResponse { pub executor_registration_id: String, } +/// Request body for connecting a harness key with the environment registry. +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +pub struct EnvironmentRegistryConnectRequest { + pub harness_public_key: NoiseChannelPublicKey, +} + +/// Environment registry response returned after connecting a harness key. +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +pub struct EnvironmentRegistryConnectResponse { + pub environment_id: String, + pub url: String, + pub security_profile: String, + pub executor_registration_id: String, + pub executor_public_key: NoiseChannelPublicKey, + pub harness_key_authorization: String, +} + +impl std::fmt::Debug for EnvironmentRegistryConnectResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EnvironmentRegistryConnectResponse") + .field("environment_id", &self.environment_id) + .field("url", &"") + .field("security_profile", &self.security_profile) + .field("executor_registration_id", &self.executor_registration_id) + .field("executor_public_key", &self.executor_public_key) + .field("harness_key_authorization", &"") + .finish() + } +} + /// Request body for authorizing a harness key with the environment registry. #[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] pub struct EnvironmentRegistryHarnessKeyValidationRequest { @@ -32,3 +62,7 @@ pub struct EnvironmentRegistryHarnessKeyValidationRequest { pub struct EnvironmentRegistryHarnessKeyValidationResponse { pub valid: bool, } + +#[cfg(test)] +#[path = "environment_registry_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/environment_registry_tests.rs b/codex-rs/exec-server/src/environment_registry_tests.rs new file mode 100644 index 0000000000..8c32e0fb45 --- /dev/null +++ b/codex-rs/exec-server/src/environment_registry_tests.rs @@ -0,0 +1,22 @@ +use crate::EnvironmentRegistryConnectResponse; +use crate::NoiseChannelIdentity; + +#[test] +fn connect_response_debug_redacts_authorizations() { + let response = EnvironmentRegistryConnectResponse { + environment_id: "environment-1".to_string(), + url: "wss://rendezvous.test?sig=secret-url-authorization".to_string(), + security_profile: "noise_hybrid_ik_v1".to_string(), + executor_registration_id: "registration-1".to_string(), + executor_public_key: NoiseChannelIdentity::generate() + .expect("identity") + .public_key(), + harness_key_authorization: "secret-harness-authorization".to_string(), + }; + + let debug = format!("{response:?}"); + + assert!(debug.contains("")); + assert!(!debug.contains("secret-url-authorization")); + assert!(!debug.contains("secret-harness-authorization")); +} diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index df71eda95a..c96c443447 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -57,6 +57,8 @@ pub use environment::REMOTE_ENVIRONMENT_ID; pub use environment_provider::DefaultEnvironmentProvider; pub use environment_provider::EnvironmentProvider; pub use environment_provider::EnvironmentProviderFuture; +pub use environment_registry::EnvironmentRegistryConnectRequest; +pub use environment_registry::EnvironmentRegistryConnectResponse; pub use environment_registry::EnvironmentRegistryHarnessKeyValidationRequest; pub use environment_registry::EnvironmentRegistryHarnessKeyValidationResponse; pub use environment_registry::EnvironmentRegistryRegistrationRequest; diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index ace1578da8..e2111318eb 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -1,6 +1,12 @@ +use std::sync::Arc; use std::time::Duration; +use codex_api::AuthProvider; use codex_api::SharedAuthProvider; +use futures::FutureExt; +use http::HeaderMap; +use http::HeaderName; +use http::HeaderValue; use reqwest::StatusCode; use serde::Deserialize; use tokio::time::sleep; @@ -11,6 +17,8 @@ use tracing::warn; use codex_utils_rustls_provider::ensure_rustls_crypto_provider; +use crate::EnvironmentRegistryConnectRequest; +use crate::EnvironmentRegistryConnectResponse; use crate::EnvironmentRegistryHarnessKeyValidationRequest; use crate::EnvironmentRegistryHarnessKeyValidationResponse; use crate::EnvironmentRegistryRegistrationRequest; @@ -19,6 +27,9 @@ use crate::ExecServerError; use crate::ExecServerRuntimePaths; use crate::NoiseChannelIdentity; use crate::NoiseChannelPublicKey; +use crate::NoiseRendezvousConnectBundle; +use crate::NoiseRendezvousConnectProvider; +use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT; use crate::noise_relay::noise_relay_websocket_config; use crate::relay::HarnessKeyValidator; use crate::relay::run_multiplexed_environment; @@ -32,6 +43,7 @@ struct EnvironmentRegistryClient { base_url: String, auth_provider: SharedAuthProvider, http: reqwest::Client, + connect_timeout: Duration, } impl std::fmt::Debug for EnvironmentRegistryClient { @@ -52,6 +64,7 @@ impl EnvironmentRegistryClient { http: reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .build()?, + connect_timeout: DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, }) } @@ -102,6 +115,53 @@ impl EnvironmentRegistryClient { Ok(response) } + /// Authorize one Noise harness key and obtain the full rendezvous bundle. + async fn connect_environment( + &self, + environment_id: &str, + harness_public_key: NoiseChannelPublicKey, + ) -> Result { + let response = self + .http + .post(endpoint_url( + &self.base_url, + &format!("/cloud/environment/{environment_id}/connect"), + )) + .headers(self.auth_provider.to_auth_headers()) + .json(&EnvironmentRegistryConnectRequest { harness_public_key }) + .timeout(self.connect_timeout) + .send() + .await?; + let response: EnvironmentRegistryConnectResponse = + self.parse_json_response(response).await?; + if response.environment_id != environment_id { + return Err(ExecServerError::Protocol( + "environment registry returned a different environment id".to_string(), + )); + } + if response.security_profile != NOISE_RELAY_SECURITY_PROFILE { + return Err(ExecServerError::Protocol(format!( + "environment registry returned unsupported security profile `{}`", + response.security_profile + ))); + } + if response.url.trim().is_empty() + || response.executor_registration_id.trim().is_empty() + || response.harness_key_authorization.trim().is_empty() + { + return Err(ExecServerError::Protocol( + "environment registry returned incomplete Noise connection data".to_string(), + )); + } + Ok(NoiseRendezvousConnectBundle { + websocket_url: response.url, + environment_id: response.environment_id, + executor_registration_id: response.executor_registration_id, + executor_public_key: response.executor_public_key, + harness_key_authorization: response.harness_key_authorization, + }) + } + async fn parse_json_response( &self, response: reqwest::Response, @@ -182,6 +242,130 @@ impl HarnessKeyValidator for RegistryHarnessKeyValidator { } } +/// Noise connection configuration for a Codex harness. +/// +/// The provider holds the authenticated registry client so every reconnect +/// receives fresh URL and harness-key authorization material. +#[derive(Clone)] +pub(crate) struct NoiseRendezvousEnvironmentConfig { + provider: Arc, +} + +impl std::fmt::Debug for NoiseRendezvousEnvironmentConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NoiseRendezvousEnvironmentConfig") + .field("provider", &"") + .finish() + } +} + +impl NoiseRendezvousEnvironmentConfig { + pub(crate) fn new( + base_url: String, + environment_id: String, + bearer_token: String, + chatgpt_account_id: Option, + ) -> Result { + let environment_id = normalize_environment_id(environment_id)?; + let auth_provider = static_bearer_auth_provider(bearer_token, chatgpt_account_id)?; + let client = EnvironmentRegistryClient::new(base_url, auth_provider)?; + Ok(Self { + provider: Arc::new(EnvironmentRegistryNoiseConnectProvider { + client, + environment_id, + }), + }) + } + + pub(crate) fn connect_provider(&self) -> Arc { + Arc::clone(&self.provider) + } +} + +#[derive(Clone, Debug)] +struct EnvironmentRegistryNoiseConnectProvider { + client: EnvironmentRegistryClient, + environment_id: String, +} + +impl NoiseRendezvousConnectProvider for EnvironmentRegistryNoiseConnectProvider { + fn connect_bundle( + &self, + harness_public_key: NoiseChannelPublicKey, + ) -> futures::future::BoxFuture<'_, Result> { + async move { + self.client + .connect_environment(&self.environment_id, harness_public_key) + .await + } + .boxed() + } +} + +#[derive(Clone)] +struct StaticBearerAuthProvider { + authorization: HeaderValue, + chatgpt_account_id: Option, +} + +impl std::fmt::Debug for StaticBearerAuthProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StaticBearerAuthProvider") + .field("authorization", &"") + .field( + "chatgpt_account_id", + &self.chatgpt_account_id.as_ref().map(|_| ""), + ) + .finish() + } +} + +impl AuthProvider for StaticBearerAuthProvider { + fn add_auth_headers(&self, headers: &mut HeaderMap) { + headers.insert(http::header::AUTHORIZATION, self.authorization.clone()); + if let Some(chatgpt_account_id) = &self.chatgpt_account_id { + headers.insert( + HeaderName::from_static("chatgpt-account-id"), + chatgpt_account_id.clone(), + ); + } + } +} + +fn static_bearer_auth_provider( + bearer_token: String, + chatgpt_account_id: Option, +) -> Result { + let bearer_token = bearer_token.trim(); + if bearer_token.is_empty() { + return Err(ExecServerError::EnvironmentRegistryConfig( + "environment registry bearer token is required".to_string(), + )); + } + let authorization = + HeaderValue::try_from(format!("Bearer {bearer_token}")).map_err(|error| { + ExecServerError::EnvironmentRegistryConfig(format!( + "environment registry bearer token is not a valid HTTP header: {error}" + )) + })?; + let chatgpt_account_id = chatgpt_account_id + .as_deref() + .map(str::trim) + .filter(|account_id| !account_id.is_empty()) + .map(|account_id| { + HeaderValue::try_from(account_id).map_err(|error| { + ExecServerError::EnvironmentRegistryConfig(format!( + "ChatGPT account id is not a valid HTTP header: {error}" + )) + }) + }) + .transpose()?; + Ok(Arc::new(StaticBearerAuthProvider { + authorization, + chatgpt_account_id, + })) +} + /// Configuration for registering an exec-server for remote use. #[derive(Clone)] pub struct RemoteEnvironmentConfig { @@ -459,6 +643,86 @@ mod tests { ); } + #[tokio::test] + async fn noise_connect_provider_requests_and_validates_a_full_bundle() { + let server = MockServer::start().await; + let harness_public_key = NoiseChannelIdentity::generate() + .expect("identity") + .public_key(); + let executor_public_key = NoiseChannelIdentity::generate() + .expect("identity") + .public_key(); + Mock::given(method("POST")) + .and(path("/cloud/environment/environment-requested/connect")) + .and(header("authorization", "Bearer registry-token")) + .and(header("chatgpt-account-id", "workspace-123")) + .and(body_partial_json(serde_json::json!({ + "harness_public_key": harness_public_key.clone(), + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "environment_id": "environment-requested", + "url": "wss://rendezvous.test/cloud-agent/default/ws/environment/environment-requested?role=harness&sig=abc", + "security_profile": NOISE_RELAY_SECURITY_PROFILE, + "executor_registration_id": "registration-1", + "executor_public_key": executor_public_key.clone(), + "harness_key_authorization": "authorization-1", + }))) + .mount(&server) + .await; + let config = NoiseRendezvousEnvironmentConfig::new( + server.uri(), + "environment-requested".to_string(), + "registry-token".to_string(), + Some("workspace-123".to_string()), + ) + .expect("noise configuration"); + + let bundle = config + .connect_provider() + .connect_bundle(harness_public_key) + .await + .expect("Noise connect bundle"); + + assert_eq!( + bundle.websocket_url, + "wss://rendezvous.test/cloud-agent/default/ws/environment/environment-requested?role=harness&sig=abc" + ); + assert_eq!(bundle.environment_id, "environment-requested"); + assert_eq!(bundle.executor_registration_id, "registration-1"); + assert_eq!(bundle.executor_public_key, executor_public_key); + assert_eq!(bundle.harness_key_authorization, "authorization-1"); + } + + #[tokio::test] + async fn connect_environment_times_out_when_registry_stalls() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/cloud/environment/environment-requested/connect")) + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(1))) + .mount(&server) + .await; + let mut client = + EnvironmentRegistryClient::new(server.uri(), static_registry_auth_provider()) + .expect("client"); + client.connect_timeout = Duration::from_millis(50); + let harness_public_key = NoiseChannelIdentity::generate() + .expect("identity") + .public_key(); + + let error = match client + .connect_environment("environment-requested", harness_public_key) + .await + { + Ok(_) => panic!("stalled connect response should time out"), + Err(error) => error, + }; + + assert!(matches!( + error, + ExecServerError::EnvironmentRegistryRequest(error) if error.is_timeout() + )); + } + #[tokio::test] async fn register_environment_does_not_follow_redirects_with_auth_headers() { let server = MockServer::start().await; From dbd2857f4bdd220b986f7d6bf69fbcd7dc34b520 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 17 Jun 2026 18:27:43 -0700 Subject: [PATCH 47/70] [codex] Add optional IDs to response items (#28812) ## Why `ResponseItem` variants do not have a consistent internal ID shape: some variants carry required IDs, some carry optional IDs, and some cannot represent an ID at all. The existing fields also use inconsistent serde, TypeScript, and JSON-schema annotations. A single enum-level access path is needed before history recording can assign and retain IDs. This PR establishes that internal model only. It intentionally does not generate or serialize IDs; allocation and wire persistence are isolated in the stacked follow-up. ## What changed - Give every concrete `ResponseItem` variant an `Option` ID field. - Apply the same internal-only annotations to every ID field: `#[serde(default, skip_serializing)]`, `#[ts(skip)]`, and `#[schemars(skip)]`. - Add `ResponseItem::id()` and `ResponseItem::set_id()` as the shared accessors. - Preserve IDs when history items are rewritten for truncation. - Adapt consumers that previously assumed reasoning and image-generation IDs were required. - Regenerate app-server schemas so the hidden fields are represented consistently. The serde catch-all `ResponseItem::Other` remains ID-less because it must remain a unit variant. ## Test plan - `cargo check --tests -p codex-core -p codex-api -p codex-rollout-trace -p codex-image-generation-extension` - `just test -p codex-protocol` - `just test -p codex-app-server-protocol` - `just test -p codex-api -p codex-rollout-trace -p codex-image-generation-extension` - `just test -p codex-core event_mapping` --- .../schema/json/ClientRequest.json | 50 +------ .../codex_app_server_protocol.schemas.json | 50 +------ .../codex_app_server_protocol.v2.schemas.json | 50 +------ .../RawResponseItemCompletedNotification.json | 50 +------ .../schema/json/v2/ThreadResumeParams.json | 50 +------ .../schema/typescript/ResponseItem.ts | 6 +- .../app-server/tests/suite/v2/compaction.rs | 1 + codex-rs/codex-api/src/requests/responses.rs | 2 +- codex-rs/core/src/agent/control_tests.rs | 2 +- codex-rs/core/src/client_common_tests.rs | 4 + codex-rs/core/src/compact_remote.rs | 5 + codex-rs/core/src/compact_remote_v2.rs | 10 +- codex-rs/core/src/compact_tests.rs | 2 + codex-rs/core/src/context_manager/history.rs | 4 + .../core/src/context_manager/history_tests.rs | 55 +++++++- .../core/src/context_manager/normalize.rs | 4 + codex-rs/core/src/event_mapping.rs | 4 +- codex-rs/core/src/event_mapping_tests.rs | 4 +- codex-rs/core/src/guardian/tests.rs | 2 + codex-rs/core/src/image_preparation_tests.rs | 2 + codex-rs/core/src/session/tests.rs | 8 +- codex-rs/core/src/stream_events_utils.rs | 4 + .../core/src/stream_events_utils_tests.rs | 5 +- codex-rs/core/src/thread_manager_tests.rs | 2 +- .../src/thread_rollout_truncation_tests.rs | 2 +- codex-rs/core/src/tools/code_mode/delegate.rs | 1 + codex-rs/core/src/turn_timing_tests.rs | 1 + codex-rs/core/tests/suite/client.rs | 7 +- codex-rs/core/tests/suite/compact.rs | 3 + codex-rs/core/tests/suite/compact_remote.rs | 6 + codex-rs/ext/image-generation/src/tests.rs | 5 +- codex-rs/memories/write/src/phase1.rs | 1 + codex-rs/protocol/src/models.rs | 129 ++++++++++++++++-- codex-rs/protocol/src/protocol.rs | 2 + codex-rs/rollout-trace/src/inference.rs | 2 +- codex-rs/thread-store/src/local/mod.rs | 1 + 36 files changed, 278 insertions(+), 258 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 509670f61b..7498994980 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2289,13 +2289,6 @@ }, "type": "array" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -2437,14 +2430,6 @@ "null" ] }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -2482,13 +2467,6 @@ "call_id": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -2537,13 +2515,6 @@ "execution": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -2615,13 +2586,6 @@ "call_id": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "input": { "type": "string" }, @@ -2758,13 +2722,6 @@ } ] }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -2798,7 +2755,11 @@ { "properties": { "id": { - "type": "string" + "description": "Existing provider ID retained on serialized history for compatibility.", + "type": [ + "string", + "null" + ] }, "metadata": { "anyOf": [ @@ -2831,7 +2792,6 @@ } }, "required": [ - "id", "result", "status", "type" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 99a8a24e39..5b0c59c425 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14791,13 +14791,6 @@ }, "type": "array" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -14939,14 +14932,6 @@ "null" ] }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -14984,13 +14969,6 @@ "call_id": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -15039,13 +15017,6 @@ "execution": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -15117,13 +15088,6 @@ "call_id": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "input": { "type": "string" }, @@ -15260,13 +15224,6 @@ } ] }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -15300,7 +15257,11 @@ { "properties": { "id": { - "type": "string" + "description": "Existing provider ID retained on serialized history for compatibility.", + "type": [ + "string", + "null" + ] }, "metadata": { "anyOf": [ @@ -15333,7 +15294,6 @@ } }, "required": [ - "id", "result", "status", "type" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index b0399a2232..45cfbd95c2 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -11236,13 +11236,6 @@ }, "type": "array" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -11384,14 +11377,6 @@ "null" ] }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -11429,13 +11414,6 @@ "call_id": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -11484,13 +11462,6 @@ "execution": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -11562,13 +11533,6 @@ "call_id": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "input": { "type": "string" }, @@ -11705,13 +11669,6 @@ } ] }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -11745,7 +11702,11 @@ { "properties": { "id": { - "type": "string" + "description": "Existing provider ID retained on serialized history for compatibility.", + "type": [ + "string", + "null" + ] }, "metadata": { "anyOf": [ @@ -11778,7 +11739,6 @@ } }, "required": [ - "id", "result", "status", "type" diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index 3efca03224..f77a68dc1d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -377,13 +377,6 @@ }, "type": "array" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -525,14 +518,6 @@ "null" ] }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -570,13 +555,6 @@ "call_id": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -625,13 +603,6 @@ "execution": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -703,13 +674,6 @@ "call_id": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "input": { "type": "string" }, @@ -846,13 +810,6 @@ } ] }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -886,7 +843,11 @@ { "properties": { "id": { - "type": "string" + "description": "Existing provider ID retained on serialized history for compatibility.", + "type": [ + "string", + "null" + ] }, "metadata": { "anyOf": [ @@ -919,7 +880,6 @@ } }, "required": [ - "id", "result", "status", "type" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 093c27044d..e09dd2c6fc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -448,13 +448,6 @@ }, "type": "array" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -596,14 +589,6 @@ "null" ] }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -641,13 +626,6 @@ "call_id": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -696,13 +674,6 @@ "execution": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -774,13 +745,6 @@ "call_id": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "input": { "type": "string" }, @@ -917,13 +881,6 @@ } ] }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -957,7 +914,11 @@ { "properties": { "id": { - "type": "string" + "description": "Existing provider ID retained on serialized history for compatibility.", + "type": [ + "string", + "null" + ] }, "metadata": { "anyOf": [ @@ -990,7 +951,6 @@ } }, "required": [ - "id", "result", "status", "type" diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index 5bd9a1032b..f0cd1c8ee6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -16,4 +16,8 @@ export type ResponseItem = { "type": "message", role: string, content: Array Result<() metadata: None, }, ResponseItem::Compaction { + id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), metadata: None, }, diff --git a/codex-rs/codex-api/src/requests/responses.rs b/codex-rs/codex-api/src/requests/responses.rs index 5f3e1ba876..836f525c49 100644 --- a/codex-rs/codex-api/src/requests/responses.rs +++ b/codex-rs/codex-api/src/requests/responses.rs @@ -17,7 +17,7 @@ pub(crate) fn attach_item_ids(payload_json: &mut Value, original_items: &[Respon }; for (value, item) in items.iter_mut().zip(original_items.iter()) { - if let ResponseItem::Reasoning { id, .. } + if let ResponseItem::Reasoning { id: Some(id), .. } | ResponseItem::Message { id: Some(id), .. } | ResponseItem::WebSearchCall { id: Some(id), .. } | ResponseItem::FunctionCall { id: Some(id), .. } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 94f7b01324..2d4b221a9e 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -878,7 +878,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), assistant_message("parent unknown phase", /*phase*/ None), ResponseItem::Reasoning { - id: "parent-reasoning".to_string(), + id: Some("parent-reasoning".to_string()), summary: Vec::new(), content: None, encrypted_content: None, diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index b71acc3989..8107049ab3 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -23,6 +23,7 @@ fn prompt_with_image_outputs() -> Prompt { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "function-call".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -33,6 +34,7 @@ fn prompt_with_image_outputs() -> Prompt { metadata: None, }, ResponseItem::CustomToolCallOutput { + id: None, call_id: "custom-call".to_string(), name: None, output: FunctionCallOutputPayload::from_content_items(vec![ @@ -69,6 +71,7 @@ fn responses_lite_request_copies_strip_image_details() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "function-call".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -79,6 +82,7 @@ fn responses_lite_request_copies_strip_image_details() { metadata: None, }, ResponseItem::CustomToolCallOutput { + id: None, call_id: "custom-call".to_string(), name: None, output: FunctionCallOutputPayload::from_content_items(vec![ diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 08ca7efcdf..c88d8062d6 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -403,20 +403,24 @@ pub(crate) fn trim_function_call_history_to_fit_context_window( fn rewritten_output_for_context_window(item: &ResponseItem) -> Option { Some(match item { ResponseItem::FunctionCallOutput { + id, call_id, output, metadata, } => ResponseItem::FunctionCallOutput { + id: id.clone(), call_id: call_id.clone(), output: truncated_output_payload(output), metadata: metadata.clone(), }, ResponseItem::CustomToolCallOutput { + id, call_id, name, output, metadata, } => ResponseItem::CustomToolCallOutput { + id: id.clone(), call_id: call_id.clone(), name: name.clone(), output: truncated_output_payload(output), @@ -429,6 +433,7 @@ fn rewritten_output_for_context_window(item: &ResponseItem) -> Option ResponseItem::ToolSearchOutput { + id: item.id().map(str::to_string), call_id: call_id.clone(), status: status.clone(), execution: execution.clone(), diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 4b4071a0d9..6c3a939024 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -231,7 +231,10 @@ async fn run_remote_compact_task_inner_impl( ) .await?; let mut input = prompt_input.clone(); - input.push(ResponseItem::CompactionTrigger { metadata: None }); + input.push(ResponseItem::CompactionTrigger { + id: None, + metadata: None, + }); let prompt = Prompt { input, tools: tool_router.model_visible_specs(), @@ -609,11 +612,13 @@ mod tests { metadata: None, }, ResponseItem::Compaction { + id: None, encrypted_content: "old".to_string(), metadata: None, }, ]; let output = ResponseItem::Compaction { + id: None, encrypted_content: "new".to_string(), metadata: None, }; @@ -642,6 +647,7 @@ mod tests { new.clone(), ]; let output = ResponseItem::Compaction { + id: None, encrypted_content: "new".to_string(), metadata: None, }; @@ -673,6 +679,7 @@ mod tests { metadata: None, }]; let output = ResponseItem::Compaction { + id: None, encrypted_content: "new".to_string(), metadata: None, }; @@ -800,6 +807,7 @@ mod tests { #[tokio::test] async fn collect_compaction_output_accepts_additional_output_items() { let compaction = ResponseItem::Compaction { + id: None, encrypted_content: "encrypted".to_string(), metadata: None, }; diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs index c1004004ad..a09a6dbb3e 100644 --- a/codex-rs/core/src/compact_tests.rs +++ b/codex-rs/core/src/compact_tests.rs @@ -654,6 +654,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() #[test] fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last() { let compacted_history = vec![ResponseItem::Compaction { + id: None, encrypted_content: "encrypted".to_string(), metadata: None, }]; @@ -680,6 +681,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last metadata: None, }, ResponseItem::Compaction { + id: None, encrypted_content: "encrypted".to_string(), metadata: None, }, diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 47f37a32f9..6ce1c6148f 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -339,20 +339,24 @@ impl ContextManager { let policy_with_serialization_budget = policy * 1.2; match item { ResponseItem::FunctionCallOutput { + id, call_id, output, metadata, } => ResponseItem::FunctionCallOutput { + id: id.clone(), call_id: call_id.clone(), output: truncate_function_output_payload(output, policy_with_serialization_budget), metadata: metadata.clone(), }, ResponseItem::CustomToolCallOutput { + id, call_id, name, output, metadata, } => ResponseItem::CustomToolCallOutput { + id: id.clone(), call_id: call_id.clone(), name: name.clone(), output: truncate_function_output_payload(output, policy_with_serialization_budget), diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index bf5bfc3b7a..8b9609a25a 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -154,6 +154,7 @@ fn reference_context_item() -> TurnContextItem { fn custom_tool_call_output(call_id: &str, output: &str) -> ResponseItem { ResponseItem::CustomToolCallOutput { + id: None, call_id: call_id.to_string(), name: None, output: FunctionCallOutputPayload::from_text(output.to_string()), @@ -163,7 +164,7 @@ fn custom_tool_call_output(call_id: &str, output: &str) -> ResponseItem { fn reasoning_msg(text: &str) -> ResponseItem { ResponseItem::Reasoning { - id: String::new(), + id: None, summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "summary".to_string(), }], @@ -177,7 +178,7 @@ fn reasoning_msg(text: &str) -> ResponseItem { fn reasoning_with_encrypted_content(len: usize) -> ResponseItem { ResponseItem::Reasoning { - id: String::new(), + id: None, summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "summary".to_string(), }], @@ -222,7 +223,7 @@ fn filters_non_api_messages() { items, vec![ ResponseItem::Reasoning { - id: String::new(), + id: None, summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "summary".to_string(), }], @@ -409,6 +410,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputText { @@ -430,6 +432,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { metadata: None, }, ResponseItem::CustomToolCallOutput { + id: None, call_id: "tool-1".to_string(), name: None, output: FunctionCallOutputPayload::from_content_items(vec![ @@ -476,6 +479,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputText { @@ -497,6 +501,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { metadata: None, }, ResponseItem::CustomToolCallOutput { + id: None, call_id: "tool-1".to_string(), name: None, output: FunctionCallOutputPayload::from_content_items(vec![ @@ -544,7 +549,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { let history = create_history_with_items(vec![ ResponseItem::ImageGenerationCall { - id: "ig_123".to_string(), + id: Some("ig_123".to_string()), status: "generating".to_string(), revised_prompt: Some("lobster".to_string()), result: "Zm9v".to_string(), @@ -565,7 +570,7 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { history.for_prompt(&default_input_modalities()), vec![ ResponseItem::ImageGenerationCall { - id: "ig_123".to_string(), + id: Some("ig_123".to_string()), status: "generating".to_string(), revised_prompt: Some("lobster".to_string()), result: "Zm9v".to_string(), @@ -597,7 +602,7 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() { metadata: None, }, ResponseItem::ImageGenerationCall { - id: "ig_123".to_string(), + id: Some("ig_123".to_string()), status: "completed".to_string(), revised_prompt: Some("lobster".to_string()), result: "Zm9v".to_string(), @@ -618,7 +623,7 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() { metadata: None, }, ResponseItem::ImageGenerationCall { - id: "ig_123".to_string(), + id: Some("ig_123".to_string()), status: "completed".to_string(), revised_prompt: Some("lobster".to_string()), result: String::new(), @@ -662,6 +667,7 @@ fn remove_first_item_removes_matching_output_for_function_call() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), metadata: None, @@ -676,6 +682,7 @@ fn remove_first_item_removes_matching_output_for_function_call() { fn remove_first_item_removes_matching_call_for_output() { let items = vec![ ResponseItem::FunctionCallOutput { + id: None, call_id: "call-2".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), metadata: None, @@ -699,6 +706,7 @@ fn replace_last_turn_images_replaces_tool_output_images() { let items = vec![ user_input_text_msg("hi"), ResponseItem::FunctionCallOutput { + id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload { body: FunctionCallOutputBody::ContentItems(vec![ @@ -721,6 +729,7 @@ fn replace_last_turn_images_replaces_tool_output_images() { vec![ user_input_text_msg("hi"), ResponseItem::FunctionCallOutput { + id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload { body: FunctionCallOutputBody::ContentItems(vec![ @@ -771,6 +780,7 @@ fn remove_first_item_handles_local_shell_pair() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "call-3".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), metadata: None, @@ -998,6 +1008,7 @@ fn remove_first_item_handles_custom_tool_pair() { metadata: None, }, ResponseItem::CustomToolCallOutput { + id: None, call_id: "tool-1".to_string(), name: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), @@ -1026,6 +1037,7 @@ fn normalization_retains_local_shell_outputs() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "shell-1".to_string(), output: FunctionCallOutputPayload::from_text("Total output lines: 1\n\nok".to_string()), metadata: None, @@ -1047,6 +1059,7 @@ fn record_items_truncates_function_call_output_content() { let long_line = "a very long line to trigger truncation\n"; let long_output = long_line.repeat(2_500); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-100".to_string(), output: FunctionCallOutputPayload { body: FunctionCallOutputBody::Text(long_output.clone()), @@ -1086,6 +1099,7 @@ fn record_items_truncates_custom_tool_call_output_content() { let line = "custom output that is very long\n"; let long_output = line.repeat(2_500); let item = ResponseItem::CustomToolCallOutput { + id: None, call_id: "tool-200".to_string(), name: None, output: FunctionCallOutputPayload::from_text(long_output.clone()), @@ -1118,6 +1132,7 @@ fn record_items_respects_custom_token_limit() { let policy = TruncationPolicy::Tokens(10); let long_output = "tokenized content repeated many times ".repeat(200); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-custom-limit".to_string(), output: FunctionCallOutputPayload { body: FunctionCallOutputBody::Text(long_output), @@ -1261,6 +1276,7 @@ fn normalize_adds_missing_output_for_function_call() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "call-x".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), metadata: None, @@ -1296,6 +1312,7 @@ fn normalize_adds_missing_output_for_custom_tool_call() { metadata: None, }, ResponseItem::CustomToolCallOutput { + id: None, call_id: "tool-x".to_string(), name: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), @@ -1342,6 +1359,7 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "shell-1".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), metadata: None, @@ -1354,6 +1372,7 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id() { #[test] fn normalize_removes_orphan_function_call_output() { let items = vec![ResponseItem::FunctionCallOutput { + id: None, call_id: "orphan-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), metadata: None, @@ -1369,6 +1388,7 @@ fn normalize_removes_orphan_function_call_output() { #[test] fn normalize_removes_orphan_custom_tool_call_output() { let items = vec![ResponseItem::CustomToolCallOutput { + id: None, call_id: "orphan-2".to_string(), name: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), @@ -1396,6 +1416,7 @@ fn normalize_mixed_inserts_and_removals() { }, // Orphan output that should be removed ResponseItem::FunctionCallOutput { + id: None, call_id: "c2".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), metadata: None, @@ -1440,6 +1461,7 @@ fn normalize_mixed_inserts_and_removals() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "c1".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), metadata: None, @@ -1453,6 +1475,7 @@ fn normalize_mixed_inserts_and_removals() { metadata: None, }, ResponseItem::CustomToolCallOutput { + id: None, call_id: "t1".to_string(), name: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), @@ -1472,6 +1495,7 @@ fn normalize_mixed_inserts_and_removals() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "s1".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), metadata: None, @@ -1504,6 +1528,7 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "call-x".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), metadata: None, @@ -1538,6 +1563,7 @@ fn normalize_adds_missing_output_for_tool_search_call() { metadata: None, }, ResponseItem::ToolSearchOutput { + id: None, call_id: Some("search-call-x".to_string()), status: "completed".to_string(), execution: "client".to_string(), @@ -1590,6 +1616,7 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id_panics_in_debug() #[should_panic] fn normalize_removes_orphan_function_call_output_panics_in_debug() { let items = vec![ResponseItem::FunctionCallOutput { + id: None, call_id: "orphan-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), metadata: None, @@ -1603,6 +1630,7 @@ fn normalize_removes_orphan_function_call_output_panics_in_debug() { #[should_panic] fn normalize_removes_orphan_custom_tool_call_output_panics_in_debug() { let items = vec![ResponseItem::CustomToolCallOutput { + id: None, call_id: "orphan-2".to_string(), name: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), @@ -1616,6 +1644,7 @@ fn normalize_removes_orphan_custom_tool_call_output_panics_in_debug() { #[test] fn normalize_removes_orphan_client_tool_search_output() { let items = vec![ResponseItem::ToolSearchOutput { + id: None, call_id: Some("orphan-search".to_string()), status: "completed".to_string(), execution: "client".to_string(), @@ -1634,6 +1663,7 @@ fn normalize_removes_orphan_client_tool_search_output() { #[should_panic] fn normalize_removes_orphan_client_tool_search_output_panics_in_debug() { let items = vec![ResponseItem::ToolSearchOutput { + id: None, call_id: Some("orphan-search".to_string()), status: "completed".to_string(), execution: "client".to_string(), @@ -1647,6 +1677,7 @@ fn normalize_removes_orphan_client_tool_search_output_panics_in_debug() { #[test] fn normalize_keeps_server_tool_search_output_without_matching_call() { let items = vec![ResponseItem::ToolSearchOutput { + id: None, call_id: Some("server-search".to_string()), status: "completed".to_string(), execution: "server".to_string(), @@ -1660,6 +1691,7 @@ fn normalize_keeps_server_tool_search_output_without_matching_call() { assert_eq!( h.raw_items(), vec![ResponseItem::ToolSearchOutput { + id: None, call_id: Some("server-search".to_string()), status: "completed".to_string(), execution: "server".to_string(), @@ -1683,6 +1715,7 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "c2".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), metadata: None, @@ -1757,6 +1790,7 @@ fn image_data_url_payload_does_not_dominate_function_call_output_estimate() { let payload = "B".repeat(50_000); let image_url = format!("data:image/png;base64,{payload}"); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-abc".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputText { @@ -1783,6 +1817,7 @@ fn image_data_url_payload_does_not_dominate_custom_tool_call_output_estimate() { let payload = "C".repeat(50_000); let image_url = format!("data:image/png;base64,{payload}"); let item = ResponseItem::CustomToolCallOutput { + id: None, call_id: "call-js-repl".to_string(), name: None, output: FunctionCallOutputPayload::from_content_items(vec![ @@ -1818,6 +1853,7 @@ fn non_base64_image_urls_are_unchanged() { metadata: None, }; let function_output_item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -1842,6 +1878,7 @@ fn non_base64_image_urls_are_unchanged() { fn encrypted_function_output_uses_plaintext_byte_estimate() { let encrypted_content = "A".repeat(1_868); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-encrypted".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::EncryptedContent { @@ -1883,6 +1920,7 @@ fn non_image_base64_data_url_is_unchanged() { let payload = "C".repeat(4_096); let image_url = format!("data:application/octet-stream;base64,{payload}"); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-octet".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -1971,6 +2009,7 @@ fn original_detail_images_scale_with_dimensions() { let payload = BASE64_STANDARD.encode(bytes.get_ref()); let image_url = format!("data:image/png;base64,{payload}"); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-original".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -2002,6 +2041,7 @@ fn original_detail_images_are_capped_at_max_patch_count() { let payload = BASE64_STANDARD.encode(bytes.get_ref()); let image_url = format!("data:image/png;base64,{payload}"); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-original-capped".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -2036,6 +2076,7 @@ fn original_detail_webp_images_scale_with_dimensions() { let payload = BASE64_STANDARD.encode(bytes.get_ref()); let image_url = format!("data:image/webp;base64,{payload}"); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-original-webp".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { diff --git a/codex-rs/core/src/context_manager/normalize.rs b/codex-rs/core/src/context_manager/normalize.rs index 1da58b6192..13aacca1e6 100644 --- a/codex-rs/core/src/context_manager/normalize.rs +++ b/codex-rs/core/src/context_manager/normalize.rs @@ -47,6 +47,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { missing_outputs_to_insert.push(( idx, ResponseItem::FunctionCallOutput { + id: None, call_id: call_id.clone(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), metadata: None, @@ -61,6 +62,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { missing_outputs_to_insert.push(( idx, ResponseItem::ToolSearchOutput { + id: None, call_id: Some(call_id.clone()), status: "completed".to_string(), execution: "client".to_string(), @@ -78,6 +80,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { missing_outputs_to_insert.push(( idx, ResponseItem::CustomToolCallOutput { + id: None, call_id: call_id.clone(), name: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), @@ -96,6 +99,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { missing_outputs_to_insert.push(( idx, ResponseItem::FunctionCallOutput { + id: None, call_id: call_id.clone(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), metadata: None, diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 11e0294c33..9e96ebff52 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -178,7 +178,7 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option { }) .collect(); Some(TurnItem::Reasoning(ReasoningItem { - id: id.clone(), + id: id.clone().unwrap_or_default(), summary_text, raw_content, })) @@ -202,7 +202,7 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option { .. } => Some(TurnItem::ImageGeneration( codex_protocol::items::ImageGenerationItem { - id: id.clone(), + id: id.clone()?, status: status.clone(), revised_prompt: revised_prompt.clone(), result: result.clone(), diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index ad5b4c877d..b7453c902f 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -391,7 +391,7 @@ fn parses_agent_message() { #[test] fn parses_reasoning_summary_and_raw_content() { let item = ResponseItem::Reasoning { - id: "reasoning_1".to_string(), + id: Some("reasoning_1".to_string()), summary: vec![ ReasoningItemReasoningSummary::SummaryText { text: "Step 1".to_string(), @@ -424,7 +424,7 @@ fn parses_reasoning_summary_and_raw_content() { #[test] fn parses_reasoning_including_raw_content() { let item = ResponseItem::Reasoning { - id: "reasoning_2".to_string(), + id: Some("reasoning_2".to_string()), summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "Summarized step".to_string(), }], diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index b19cd9d35e..d6eb93dd14 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -309,6 +309,7 @@ async fn seed_guardian_parent_history(session: &Arc, turn: &Arc Opti match input { ResponseInputItem::FunctionCallOutput { call_id, output } => { Some(ResponseItem::FunctionCallOutput { + id: None, call_id: call_id.clone(), output: output.clone(), metadata: None, @@ -634,6 +635,7 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti name, output, } => Some(ResponseItem::CustomToolCallOutput { + id: None, call_id: call_id.clone(), name: name.clone(), output: output.clone(), @@ -642,6 +644,7 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti ResponseInputItem::McpToolCallOutput { call_id, output } => { let output = output.as_function_call_output_payload(); Some(ResponseItem::FunctionCallOutput { + id: None, call_id: call_id.clone(), output, metadata: None, @@ -653,6 +656,7 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti execution, tools, } => Some(ResponseItem::ToolSearchOutput { + id: None, call_id: Some(call_id.clone()), status: status.clone(), execution: execution.clone(), diff --git a/codex-rs/core/src/stream_events_utils_tests.rs b/codex-rs/core/src/stream_events_utils_tests.rs index b89d4f5c6e..45c5d18e68 100644 --- a/codex-rs/core/src/stream_events_utils_tests.rs +++ b/codex-rs/core/src/stream_events_utils_tests.rs @@ -64,6 +64,7 @@ fn external_context_pollution_items_include_web_search_and_tool_search() { metadata: None, }, ResponseItem::ToolSearchOutput { + id: None, call_id: Some("search-1".to_string()), status: "completed".to_string(), execution: "client".to_string(), @@ -104,6 +105,7 @@ fn external_context_pollution_items_exclude_local_tool_calls() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), metadata: None, @@ -117,6 +119,7 @@ fn external_context_pollution_items_exclude_local_tool_calls() { metadata: None, }, ResponseItem::CustomToolCallOutput { + id: None, call_id: "custom-1".to_string(), name: Some("apply_patch".to_string()), output: FunctionCallOutputPayload::from_text("ok".to_string()), @@ -418,7 +421,7 @@ fn completed_item_keeps_mailbox_delivery_open_for_commentary_messages() { #[test] fn completed_item_defers_mailbox_delivery_for_image_generation_calls() { let item = ResponseItem::ImageGenerationCall { - id: "ig-1".to_string(), + id: Some("ig-1".to_string()), status: "completed".to_string(), revised_prompt: None, result: "Zm9v".to_string(), diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index cec54b5e44..be952bbcbc 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -76,7 +76,7 @@ fn truncates_before_requested_user_message() { user_msg("u2"), assistant_msg("a3"), ResponseItem::Reasoning { - id: "r1".to_string(), + id: Some("r1".to_string()), summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "s".to_string(), }], diff --git a/codex-rs/core/src/thread_rollout_truncation_tests.rs b/codex-rs/core/src/thread_rollout_truncation_tests.rs index bb6c3ac03e..bdbdbc4c75 100644 --- a/codex-rs/core/src/thread_rollout_truncation_tests.rs +++ b/codex-rs/core/src/thread_rollout_truncation_tests.rs @@ -73,7 +73,7 @@ fn truncates_rollout_from_start_before_nth_user_only() { user_msg("u2"), assistant_msg("a3"), ResponseItem::Reasoning { - id: "r1".to_string(), + id: Some("r1".to_string()), summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "s".to_string(), }], diff --git a/codex-rs/core/src/tools/code_mode/delegate.rs b/codex-rs/core/src/tools/code_mode/delegate.rs index 15177944ca..7dd6615095 100644 --- a/codex-rs/core/src/tools/code_mode/delegate.rs +++ b/codex-rs/core/src/tools/code_mode/delegate.rs @@ -299,6 +299,7 @@ impl CoreTurnHost { self.exec .session .inject_if_running(vec![ResponseItem::CustomToolCallOutput { + id: None, call_id, name: Some(PUBLIC_TOOL_NAME.to_string()), output: FunctionCallOutputPayload::from_text(text), diff --git a/codex-rs/core/src/turn_timing_tests.rs b/codex-rs/core/src/turn_timing_tests.rs index af5a54fec8..9f1bcd31b1 100644 --- a/codex-rs/core/src/turn_timing_tests.rs +++ b/codex-rs/core/src/turn_timing_tests.rs @@ -149,6 +149,7 @@ fn response_item_records_turn_ttft_ignores_empty_non_output_items() { })); assert!(!response_item_records_turn_ttft( &ResponseItem::FunctionCallOutput { + id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), metadata: None, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index c5dfd96cf6..54e24d49cb 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -611,6 +611,7 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { RolloutLine { timestamp: "2024-01-01T00:00:02.000Z".to_string(), item: RolloutItem::ResponseItem(ResponseItem::CustomToolCallOutput { + id: None, call_id: "legacy-js-call".to_string(), name: None, output: FunctionCallOutputPayload::from_text("legacy js_repl stdout".to_string()), @@ -751,6 +752,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { RolloutLine { timestamp: "2024-01-01T00:00:01.500Z".to_string(), item: RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput { + id: None, call_id: function_call_id.to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -775,6 +777,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { RolloutLine { timestamp: "2024-01-01T00:00:02.500Z".to_string(), item: RolloutItem::ResponseItem(ResponseItem::CustomToolCallOutput { + id: None, call_id: custom_call_id.to_string(), name: None, output: FunctionCallOutputPayload::from_content_items(vec![ @@ -2518,7 +2521,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let mut prompt = Prompt::default(); prompt.input.push(ResponseItem::Reasoning { - id: "reasoning-id".into(), + id: Some("reasoning-id".into()), summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "summary".into(), }], @@ -2555,6 +2558,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { metadata: None, }); prompt.input.push(ResponseItem::FunctionCallOutput { + id: None, call_id: "function-call-id".into(), output: FunctionCallOutputPayload::from_text("ok".into()), metadata: None, @@ -2581,6 +2585,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { metadata: None, }); prompt.input.push(ResponseItem::CustomToolCallOutput { + id: None, call_id: "custom-tool-call-id".into(), name: None, output: FunctionCallOutputPayload::from_text("ok".into()), diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index cff88f57a8..3b709d54a2 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1995,6 +1995,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { metadata: None, }, codex_protocol::models::ResponseItem::Compaction { + id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), metadata: None, }, @@ -3994,6 +3995,7 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { metadata: None, }, codex_protocol::models::ResponseItem::Compaction { + id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), metadata: None, }, @@ -4121,6 +4123,7 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { metadata: None, }, codex_protocol::models::ResponseItem::Compaction { + id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), metadata: None, }, diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index f9b35f686a..365cf35b02 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -157,6 +157,7 @@ fn format_labeled_requests_snapshot( fn compacted_summary_only_output(summary: &str) -> Vec { vec![ResponseItem::Compaction { + id: None, encrypted_content: summary_with_prefix(summary), metadata: None, }] @@ -329,6 +330,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { .await; let compacted_history = vec![ResponseItem::Compaction { + id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), metadata: None, }]; @@ -2356,6 +2358,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> let compacted_history = vec![ ResponseItem::Compaction { + id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), metadata: None, }, @@ -2511,6 +2514,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res metadata: None, }, ResponseItem::Compaction { + id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), metadata: None, }, @@ -2653,6 +2657,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() metadata: None, }, ResponseItem::Compaction { + id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), metadata: None, }, @@ -4059,6 +4064,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject .await; let compacted_history = vec![ResponseItem::Compaction { + id: None, encrypted_content: summary_with_prefix("REMOTE_SUMMARY_ONLY"), metadata: None, }]; diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs index 449c7beb09..e2f5ac666c 100644 --- a/codex-rs/ext/image-generation/src/tests.rs +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -87,6 +87,7 @@ async fn recent_image_fallback_selects_newest_images_in_chronological_order() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "mcp-call".to_string(), output: image_output("mcp"), metadata: None, @@ -100,19 +101,21 @@ async fn recent_image_fallback_selects_newest_images_in_chronological_order() { metadata: None, }, ResponseItem::CustomToolCallOutput { + id: None, call_id: "code-mode-call".to_string(), name: Some("exec".to_string()), output: image_output("code-mode"), metadata: None, }, ResponseItem::ImageGenerationCall { - id: "generated-call".to_string(), + id: Some("generated-call".to_string()), status: "completed".to_string(), revised_prompt: None, result: "generated".to_string(), metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "orphan-call".to_string(), output: image_output("orphan"), metadata: None, diff --git a/codex-rs/memories/write/src/phase1.rs b/codex-rs/memories/write/src/phase1.rs index 775e898874..026826b7c9 100644 --- a/codex-rs/memories/write/src/phase1.rs +++ b/codex-rs/memories/write/src/phase1.rs @@ -742,6 +742,7 @@ mod tests { let serialized = job::serialize_filtered_rollout_response_items(&[RolloutItem::ResponseItem( ResponseItem::FunctionCallOutput { + id: None, call_id: "call_123".to_string(), output: codex_protocol::models::FunctionCallOutputPayload { body: codex_protocol::models::FunctionCallOutputBody::Text( diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 9443866c6f..3c0753cb43 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -920,6 +920,7 @@ pub enum ResponseItem { Message { #[serde(default, skip_serializing)] #[ts(skip)] + #[schemars(skip)] id: Option, role: String, content: Vec, @@ -934,6 +935,10 @@ pub enum ResponseItem { metadata: Option, }, AgentMessage { + #[serde(default, skip_serializing)] + #[ts(skip)] + #[schemars(skip)] + id: Option, author: String, recipient: String, content: Vec, @@ -945,7 +950,7 @@ pub enum ResponseItem { #[serde(default, skip_serializing)] #[ts(skip)] #[schemars(skip)] - id: String, + id: Option, summary: Vec, #[serde(default, skip_serializing_if = "should_serialize_reasoning_content")] #[ts(optional)] @@ -959,6 +964,7 @@ pub enum ResponseItem { /// Legacy id field retained for compatibility with older payloads. #[serde(default, skip_serializing)] #[ts(skip)] + #[schemars(skip)] id: Option, /// Set when using the Responses API. call_id: Option, @@ -971,6 +977,7 @@ pub enum ResponseItem { FunctionCall { #[serde(default, skip_serializing)] #[ts(skip)] + #[schemars(skip)] id: Option, name: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -988,6 +995,7 @@ pub enum ResponseItem { ToolSearchCall { #[serde(default, skip_serializing)] #[ts(skip)] + #[schemars(skip)] id: Option, call_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1006,6 +1014,10 @@ pub enum ResponseItem { // - an array of structured content items (`content_items`) // We keep this behavior centralized in `FunctionCallOutputPayload`. FunctionCallOutput { + #[serde(default, skip_serializing)] + #[ts(skip)] + #[schemars(skip)] + id: Option, call_id: String, #[ts(as = "FunctionCallOutputBody")] #[schemars(with = "FunctionCallOutputBody")] @@ -1017,6 +1029,7 @@ pub enum ResponseItem { CustomToolCall { #[serde(default, skip_serializing)] #[ts(skip)] + #[schemars(skip)] id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1033,6 +1046,10 @@ pub enum ResponseItem { // `function_call_output.output` so freeform tools can return either plain // text or structured content items. CustomToolCallOutput { + #[serde(default, skip_serializing)] + #[ts(skip)] + #[schemars(skip)] + id: Option, call_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1045,6 +1062,10 @@ pub enum ResponseItem { metadata: Option, }, ToolSearchOutput { + #[serde(default, skip_serializing)] + #[ts(skip)] + #[schemars(skip)] + id: Option, call_id: Option, status: String, execution: String, @@ -1065,6 +1086,7 @@ pub enum ResponseItem { WebSearchCall { #[serde(default, skip_serializing)] #[ts(skip)] + #[schemars(skip)] id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1086,7 +1108,10 @@ pub enum ResponseItem { // "result":"..." // } ImageGenerationCall { - id: String, + /// Existing provider ID retained on serialized history for compatibility. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + id: Option, status: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1098,17 +1123,29 @@ pub enum ResponseItem { }, #[serde(alias = "compaction_summary")] Compaction { + #[serde(default, skip_serializing)] + #[ts(skip)] + #[schemars(skip)] + id: Option, encrypted_content: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] metadata: Option, }, CompactionTrigger { + #[serde(default, skip_serializing)] + #[ts(skip)] + #[schemars(skip)] + id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] metadata: Option, }, ContextCompaction { + #[serde(default, skip_serializing)] + #[ts(skip)] + #[schemars(skip)] + id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] encrypted_content: Option, @@ -1126,6 +1163,50 @@ impl ResponseItem { matches!(self, Self::Message { role, .. } if role == "user") } + /// Returns the non-empty Responses API item ID, if present. + pub fn id(&self) -> Option<&str> { + match self { + Self::Message { id, .. } + | Self::AgentMessage { id, .. } + | Self::LocalShellCall { id, .. } + | Self::FunctionCall { id, .. } + | Self::ToolSearchCall { id, .. } + | Self::FunctionCallOutput { id, .. } + | Self::CustomToolCall { id, .. } + | Self::CustomToolCallOutput { id, .. } + | Self::ToolSearchOutput { id, .. } + | Self::WebSearchCall { id, .. } + | Self::Reasoning { id, .. } + | Self::ImageGenerationCall { id, .. } + | Self::Compaction { id, .. } + | Self::CompactionTrigger { id, .. } + | Self::ContextCompaction { id, .. } => id.as_deref().filter(|id| !id.is_empty()), + Self::Other => None, + } + } + + /// Sets the Responses API item ID for variants that carry one. + pub fn set_id(&mut self, new_id: String) { + match self { + Self::Message { id, .. } + | Self::AgentMessage { id, .. } + | Self::LocalShellCall { id, .. } + | Self::FunctionCall { id, .. } + | Self::ToolSearchCall { id, .. } + | Self::FunctionCallOutput { id, .. } + | Self::CustomToolCall { id, .. } + | Self::CustomToolCallOutput { id, .. } + | Self::ToolSearchOutput { id, .. } + | Self::WebSearchCall { id, .. } + | Self::Reasoning { id, .. } + | Self::ImageGenerationCall { id, .. } + | Self::Compaction { id, .. } + | Self::CompactionTrigger { id, .. } + | Self::ContextCompaction { id, .. } => *id = Some(new_id), + Self::Other => {} + } + } + /// Returns the non-empty turn ID stamped onto this item, if present. pub fn turn_id(&self) -> Option<&str> { self.metadata() @@ -1168,7 +1249,7 @@ impl ResponseItem { | Self::WebSearchCall { metadata, .. } | Self::ImageGenerationCall { metadata, .. } | Self::Compaction { metadata, .. } - | Self::CompactionTrigger { metadata } + | Self::CompactionTrigger { metadata, .. } | Self::ContextCompaction { metadata, .. } => metadata.as_ref(), Self::Other => None, } @@ -1189,7 +1270,7 @@ impl ResponseItem { | Self::WebSearchCall { metadata, .. } | Self::ImageGenerationCall { metadata, .. } | Self::Compaction { metadata, .. } - | Self::CompactionTrigger { metadata } + | Self::CompactionTrigger { metadata, .. } | Self::ContextCompaction { metadata, .. } => Some(metadata), Self::Other => None, } @@ -1435,6 +1516,7 @@ impl From for ResponseItem { metadata: None, }, ResponseInputItem::FunctionCallOutput { call_id, output } => Self::FunctionCallOutput { + id: None, call_id, output, metadata: None, @@ -1442,6 +1524,7 @@ impl From for ResponseItem { ResponseInputItem::McpToolCallOutput { call_id, output } => { let output = output.into_function_call_output_payload(); Self::FunctionCallOutput { + id: None, call_id, output, metadata: None, @@ -1452,6 +1535,7 @@ impl From for ResponseItem { name, output, } => Self::CustomToolCallOutput { + id: None, call_id, name, output, @@ -1467,6 +1551,7 @@ impl From for ResponseItem { status, execution, tools, + id: None, metadata: None, }, } @@ -2060,6 +2145,16 @@ mod tests { Ok(()) } + #[test] + fn response_item_id_getter_and_setter() { + let mut item = response_item_with_metadata(/*metadata*/ None); + assert_eq!(item.id(), None); + + item.set_id("msg_test".to_string()); + + assert_eq!(item.id(), Some("msg_test")); + } + fn response_item_with_metadata(metadata: Option) -> ResponseItem { ResponseItem::Message { id: None, @@ -2176,7 +2271,7 @@ mod tests { assert_eq!( item, ResponseItem::ImageGenerationCall { - id: "ig_123".to_string(), + id: Some("ig_123".to_string()), status: "completed".to_string(), revised_prompt: Some("A small blue square".to_string()), result: "Zm9v".to_string(), @@ -2198,7 +2293,7 @@ mod tests { assert_eq!( item, ResponseItem::ImageGenerationCall { - id: "ig_123".to_string(), + id: Some("ig_123".to_string()), status: "completed".to_string(), revised_prompt: None, result: "Zm9v".to_string(), @@ -2900,6 +2995,7 @@ mod tests { assert_eq!( item, ResponseItem::Compaction { + id: None, encrypted_content: "abc".into(), metadata: None, } @@ -2916,6 +3012,7 @@ mod tests { assert_eq!( item, ResponseItem::ContextCompaction { + id: None, encrypted_content: Some("abc".into()), metadata: None, } @@ -2925,7 +3022,10 @@ mod tests { #[test] fn serializes_compaction_trigger_without_payload() -> Result<()> { - let item = ResponseItem::CompactionTrigger { metadata: None }; + let item = ResponseItem::CompactionTrigger { + id: None, + metadata: None, + }; assert_eq!( serde_json::to_value(item)?, @@ -2938,7 +3038,10 @@ mod tests { #[test] fn serializes_stamped_compaction_trigger_metadata() -> Result<()> { - let mut item = ResponseItem::CompactionTrigger { metadata: None }; + let mut item = ResponseItem::CompactionTrigger { + id: None, + metadata: None, + }; item.stamp_turn_id_if_missing("turn-1"); assert_eq!( @@ -2959,7 +3062,13 @@ mod tests { let item: ResponseItem = serde_json::from_str(json)?; - assert_eq!(item, ResponseItem::CompactionTrigger { metadata: None }); + assert_eq!( + item, + ResponseItem::CompactionTrigger { + id: None, + metadata: None, + } + ); Ok(()) } @@ -3188,6 +3297,7 @@ mod tests { assert_eq!( ResponseItem::from(input.clone()), ResponseItem::ToolSearchOutput { + id: None, call_id: Some("search-1".to_string()), status: "completed".to_string(), execution: "client".to_string(), @@ -3275,6 +3385,7 @@ mod tests { assert_eq!( parsed_output, ResponseItem::ToolSearchOutput { + id: None, call_id: None, status: "completed".to_string(), execution: "server".to_string(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 0a9271c9f0..a0e5b24d47 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -762,6 +762,7 @@ impl InterAgentCommunication { }], }; ResponseItem::AgentMessage { + id: None, author: self.author.to_string(), recipient: self.recipient.to_string(), content, @@ -4284,6 +4285,7 @@ mod tests { assert_eq!( communication.to_model_input_item(), ResponseItem::AgentMessage { + id: None, author: "/root/worker".to_string(), recipient: "/root".to_string(), content: vec![ diff --git a/codex-rs/rollout-trace/src/inference.rs b/codex-rs/rollout-trace/src/inference.rs index f826d9f47c..7329fd3cf2 100644 --- a/codex-rs/rollout-trace/src/inference.rs +++ b/codex-rs/rollout-trace/src/inference.rs @@ -496,7 +496,7 @@ mod tests { #[test] fn traced_response_item_preserves_reasoning_content_omitted_by_normal_serializer() { let item = ResponseItem::Reasoning { - id: "rs-1".to_string(), + id: Some("rs-1".to_string()), summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "summary".to_string(), }], diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 550e7d56bb..19a0d2955b 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -510,6 +510,7 @@ mod tests { memory_citation: None, })), RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput { + id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_text("tool output".to_string()), metadata: None, From f22d15b679a13fa8be815249ba1508e73a8c81ed Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 17 Jun 2026 22:12:02 -0400 Subject: [PATCH 48/70] fix(install): support older awk checksum parsing (#28784) ## Why The standalone installer validates package checksums with an awk interval expression. Older mawk releases do not support that expression, so they reject valid 64-character digests and report that the release manifest is missing an entry. This affects both x64 and ARM64 systems on common Debian-derived environments. Fixes #24219. ## What Changed Replace the awk interval expression with an explicit length check plus rejection of non-hexadecimal characters. This preserves the existing SHA-256 validation and lowercase normalization while working with older awk implementations. ## How to Test 1. Build and run the checksum predicate with mawk 1.3.4 20121129. 2. Confirm the old interval predicate rejects a valid 64-character digest. 3. Confirm the updated predicate accepts that digest. 4. Put the old mawk binary first on PATH as awk and run scripts/install/install.sh with an isolated HOME, CODEX_HOME, and CODEX_INSTALL_DIR. 5. Confirm Codex installs successfully and the installed binary reports version 0.140.0. 6. Verify the predicate rejects wrong-length digests, non-hexadecimal digests, and entries for another asset while accepting uppercase hexadecimal digests. --- scripts/install/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install/install.sh b/scripts/install/install.sh index 23980b0c5e..7513efc429 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -213,7 +213,7 @@ package_archive_digest() { manifest_path="$2" digest="$(awk -v asset="$asset" ' - $2 == asset && $1 ~ /^[0-9a-fA-F]{64}$/ { + $2 == asset && length($1) == 64 && $1 !~ /[^0-9a-fA-F]/ { print tolower($1) found = 1 exit From a306ac4ee31aaf72c6f9e7cb75663efdcc19a6f1 Mon Sep 17 00:00:00 2001 From: guinness-oai Date: Wed, 17 Jun 2026 19:13:38 -0700 Subject: [PATCH 49/70] [codex] Use unique IDs for realtime-routed turns (#28826) ## Why A durable realtime voice orchestrator can reconnect and resume through multiple fresh `Session` instances. Realtime handoffs were using the Session-local `auto-compact-N` counter as their turn identity, but that counter restarts at zero for every resumed Session. The durable thread could therefore accumulate duplicate turn IDs, violating the uniqueness assumptions made by app-server and web clients. In Codex Apps, a new delegated response stream could be attached to an older turn with the same ID, placing live output higher in history and putting turn-scoped actions at risk. Persisted rollout and reconstructed model-context order were already correct because raw response items remain append-only and chronological. This change restores unique identity for reconstructed and live turn surfaces. ## What changed - Generate a UUIDv7 specifically for each realtime-routed delegation. - Leave the existing `auto-compact-N` identity path unchanged for actual internal auto-compaction turns. - Extend the inbound realtime handoff integration test to require a UUID turn ID from `turn/started`. ## Verification - `just test -p codex-core inbound_handoff_request_starts_turn` - `just fix -p codex-core` - `just fmt` --- codex-rs/core/src/session/mod.rs | 2 +- codex-rs/core/tests/suite/realtime_conversation.rs | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index cf19123591..e21b5394d9 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1134,7 +1134,7 @@ impl Session { pub(crate) async fn route_realtime_text_input(self: &Arc, text: String) { handlers::user_input_or_turn_inner( self, - self.next_internal_sub_id(), + Uuid::now_v7().to_string(), Op::UserInput { items: vec![UserInput::Text { text, diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 61fc38e8b1..a2aa6d61d1 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -50,6 +50,7 @@ use std::sync::Mutex; use std::time::Duration; use tokio::sync::oneshot; use tokio::time::timeout; +use uuid::Uuid; use wiremock::Match; use wiremock::Mock; use wiremock::Request as WiremockRequest; @@ -3019,6 +3020,14 @@ async fn inbound_handoff_request_starts_turn() -> Result<()> { }) .await; + let turn_id = loop { + let event = test.codex.next_event().await?; + if let EventMsg::TurnStarted(turn_started) = event.msg { + break turn_started.turn_id; + } + }; + Uuid::parse_str(&turn_id).context("realtime-routed turn ID should be a UUID")?; + wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) }) From 683bd170dc36812f73867aa6f421a4a8cf4bd133 Mon Sep 17 00:00:00 2001 From: jiayuhuang-openai Date: Wed, 17 Jun 2026 19:22:29 -0700 Subject: [PATCH 50/70] [codex] control automatic realtime handoff delivery (#27986) ## What Built on the realtime speech-control plumbing merged in #27917. - Add optional `codexResponseHandoffPrefix` to `thread/realtime/start`. - Apply that prefix only to automatic V1 commentary sent through `conversation.handoff.append`; final answers remain unprefixed. - Add opt-in `clientManagedHandoffs`. When true, core suppresses automatic response handoffs and completion output so delivery is controlled by explicit client append APIs. - Preserve existing automatic behavior by default. `codexResponsesAsItems: true` continues to select item routing when client-managed mode is disabled. ## Why Voice clients need two delivery policies: automatic background context with silent commentary instructions and fully client-owned handoffs. Phase-aware prefixing keeps routine commentary silent without suppressing the final answer, while client-managed mode lets an app decide exactly which updates to append. ## Validation - `just fmt` - `cargo test -p codex-app-server-protocol serialize_thread_realtime_start` - `RUST_MIN_STACK=16777216 cargo test -p codex-core --test all conversation_handoff_persists_across_item_done_until_turn_complete` - `RUST_MIN_STACK=16777216 cargo test -p codex-app-server --test all webrtc_v1_client_managed_handoffs_disable_automatic_output` - `RUST_MIN_STACK=16777216 cargo test -p codex-app-server --test all webrtc_v1_final_automatic_handoff_omits_silent_prefix` - `cargo build -p codex-cli --bin codex` - Local Codex Apps compatibility check: 43 focused webview tests passed, and a live voice session routed through the source-built app-server. The explicit `RUST_MIN_STACK` avoids a macOS Tokio test-worker stack overflow seen with the default test environment. --- .../src/protocol/common.rs | 14 ++ .../src/protocol/v2/realtime.rs | 9 + codex-rs/app-server/README.md | 11 +- .../src/request_processors/turn_processor.rs | 2 + .../tests/suite/v2/experimental_api.rs | 4 + .../tests/suite/v2/realtime_conversation.rs | 160 +++++++++++++++++- codex-rs/core/src/realtime_conversation.rs | 64 ++++++- .../core/src/realtime_conversation_tests.rs | 2 + codex-rs/core/src/session/mod.rs | 4 +- codex-rs/core/src/session/turn.rs | 6 +- codex-rs/core/tests/suite/compact_remote.rs | 2 + .../core/tests/suite/realtime_conversation.rs | 95 +++++++++-- codex-rs/protocol/src/protocol.rs | 6 + 13 files changed, 355 insertions(+), 24 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index f22cab25f1..edeabfc7ce 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -3082,8 +3082,10 @@ mod tests { request_id: RequestId::Integer(9), params: v2::ThreadRealtimeStartParams { architecture: Some(RealtimeConversationArchitecture::Avas), + client_managed_handoffs: Some(true), codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: Some("silent context".to_string()), thread_id: "thr_123".to_string(), model: Some("realtime-treatment-model".to_string()), output_modality: RealtimeOutputModality::Audio, @@ -3102,8 +3104,10 @@ mod tests { "params": { "architecture": "avas", "threadId": "thr_123", + "clientManagedHandoffs": true, "codexResponsesAsItems": null, "codexResponseItemPrefix": null, + "codexResponseHandoffPrefix": "silent context", "model": "realtime-treatment-model", "outputModality": "audio", "includeStartupContext": false, @@ -3125,8 +3129,10 @@ mod tests { request_id: RequestId::Integer(9), params: v2::ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: "thr_123".to_string(), model: None, output_modality: RealtimeOutputModality::Audio, @@ -3145,8 +3151,10 @@ mod tests { "params": { "architecture": null, "threadId": "thr_123", + "clientManagedHandoffs": null, "codexResponsesAsItems": null, "codexResponseItemPrefix": null, + "codexResponseHandoffPrefix": null, "model": null, "outputModality": "audio", "includeStartupContext": null, @@ -3163,8 +3171,10 @@ mod tests { request_id: RequestId::Integer(9), params: v2::ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: "thr_123".to_string(), model: None, output_modality: RealtimeOutputModality::Audio, @@ -3183,8 +3193,10 @@ mod tests { "params": { "architecture": null, "threadId": "thr_123", + "clientManagedHandoffs": null, "codexResponsesAsItems": null, "codexResponseItemPrefix": null, + "codexResponseHandoffPrefix": null, "model": null, "outputModality": "audio", "includeStartupContext": null, @@ -3367,8 +3379,10 @@ mod tests { request_id: RequestId::Integer(1), params: v2::ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: "thr_123".to_string(), model: None, output_modality: RealtimeOutputModality::Audio, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs index 793b316f53..b30ecd7b62 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs @@ -70,12 +70,21 @@ pub struct ThreadRealtimeStartParams { /// Overrides the configured realtime architecture for this session only. #[ts(optional = nullable)] pub architecture: Option, + /// Leaves Codex response handoffs to the client's explicit append calls instead of forwarding + /// them automatically. Defaults to false. + #[ts(optional = nullable)] + pub client_managed_handoffs: Option, /// Sends automatic Codex responses as realtime conversation items instead of handoff appends. #[ts(optional = nullable)] pub codex_responses_as_items: Option, /// Optional prefix added to automatic Codex response items when `codexResponsesAsItems` is true. #[ts(optional = nullable)] pub codex_response_item_prefix: Option, + /// Optional prefix added to automatic V1 Codex commentary sent with + /// `conversation.handoff.append` when `codexResponsesAsItems` is not true. Final answers are + /// sent without the prefix. + #[ts(optional = nullable)] + pub codex_response_handoff_prefix: Option, /// Overrides the configured realtime model for this session only. #[ts(optional = nullable)] pub model: Option, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d342313917..7dde50d193 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -165,7 +165,7 @@ Example with notification opt-out: - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. -- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, optionally pass `model` and `version` to override configured realtime selection for this session only, and pass `includeStartupContext: false` to omit Codex's generated startup context. By default, automatic Codex text follows the protocol's speakable output path. Pass `codexResponsesAsItems: true` to send automatic Codex responses as realtime conversation items instead, and optionally pass `codexResponseItemPrefix` to prepend experiment instructions to those items. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. +- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, optionally pass `model` and `version` to override configured realtime selection for this session only, and pass `includeStartupContext: false` to omit Codex's generated startup context. By default, automatic Codex text follows the protocol's speakable output path. Pass `clientManagedHandoffs: true` to disable automatic Codex response delivery so only the client's explicit append calls produce handoffs. Pass `codexResponsesAsItems: true` to send automatic Codex responses as realtime conversation items instead, and optionally pass `codexResponseItemPrefix` to prepend experiment instructions to those items. For V1 sessions, pass `codexResponseHandoffPrefix` while item mode is disabled to route automatic Codex commentary through `conversation.handoff.append` with that prefix; final answers remain unprefixed. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. - `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`. - `thread/realtime/appendText` — append text input to the active realtime session with a required `role` of `user` or `developer` (experimental); returns `{}`. Older clients that omit `role` default to `user`. - `thread/realtime/appendSpeech` — append text that the realtime model should speak to the user (experimental); returns `{}`. @@ -882,12 +882,19 @@ Clients may also pass `model` and `version` on `thread/realtime/start` to select different realtime session configuration without changing thread or user config. Pass `includeStartupContext: false` to skip Codex's startup context for this session while still using the selected backend prompt. +Pass `clientManagedHandoffs: true` to suppress automatic Codex response handoffs +and items. The client can then choose which updates to deliver with +`thread/realtime/appendText` or `thread/realtime/appendSpeech`. Pass `codexResponsesAsItems: true` to inject automatic Codex responses with `conversation.item.create` instead of the protocol's default speakable output path. When using that mode, `codexResponseItemPrefix` can prepend short experiment instructions to each automatic Codex response item. Omit `codexResponsesAsItems`, or pass `false`, to preserve the default speakable -behavior. Call +behavior. For V1 sessions, `codexResponseHandoffPrefix` instead routes automatic +Codex commentary through `conversation.handoff.append` and prepends the provided +text. Final answers remain unprefixed. Item mode takes precedence when +`codexResponsesAsItems` is true. +Call `thread/realtime/appendText` to append app-provided realtime text items, or `thread/realtime/appendSpeech` when the app decides a realtime update should be spoken. diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index d48e8c04b5..fbfcb797b6 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -931,8 +931,10 @@ impl TurnRequestProcessor { thread.as_ref(), Op::RealtimeConversationStart(ConversationStartParams { architecture: params.architecture, + client_managed_handoffs: params.client_managed_handoffs.unwrap_or(false), codex_responses_as_items: params.codex_responses_as_items.unwrap_or(false), codex_response_item_prefix: params.codex_response_item_prefix, + codex_response_handoff_prefix: params.codex_response_handoff_prefix, model: params.model, output_modality: params.output_modality, include_startup_context: params.include_startup_context.unwrap_or(true), diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index b12bd43b43..3b4f6e949a 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -80,8 +80,10 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R let request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: "thr_123".to_string(), model: None, output_modality: RealtimeOutputModality::Audio, @@ -192,8 +194,10 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result< let request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: "thr_123".to_string(), model: None, output_modality: RealtimeOutputModality::Audio, diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index f88ed799a0..274ee9b90c 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -86,6 +86,8 @@ const V2_HANDOFF_COMPLETE_ACKNOWLEDGEMENT: &str = "Background agent finished. Use the preceding [BACKEND] messages as the result."; const RESPONSE_ITEM_PREFIX: &str = "Use the following context to inform future responses, but do not speak it to the user."; +const RESPONSE_HANDOFF_PREFIX: &str = + "Silent Codex context. Do not speak, acknowledge, or summarize this item."; #[derive(Debug, Clone, Copy)] enum StartupContextConfig<'a> { @@ -313,8 +315,9 @@ impl RealtimeE2eHarness { } async fn start_webrtc_realtime(&mut self, offer_sdp: &str) -> Result { - self.start_webrtc_realtime_with_codex_responses_as_items( - offer_sdp, /*codex_responses_as_items*/ None, + self.start_webrtc_realtime_with_codex_response_routing( + offer_sdp, /*client_managed_handoffs*/ None, + /*codex_responses_as_items*/ None, /*codex_response_handoff_prefix*/ None, ) .await } @@ -323,17 +326,21 @@ impl RealtimeE2eHarness { &mut self, offer_sdp: &str, ) -> Result { - self.start_webrtc_realtime_with_codex_responses_as_items( + self.start_webrtc_realtime_with_codex_response_routing( offer_sdp, + /*client_managed_handoffs*/ None, /*codex_responses_as_items*/ Some(true), + /*codex_response_handoff_prefix*/ None, ) .await } - async fn start_webrtc_realtime_with_codex_responses_as_items( + async fn start_webrtc_realtime_with_codex_response_routing( &mut self, offer_sdp: &str, + client_managed_handoffs: Option, codex_responses_as_items: Option, + codex_response_handoff_prefix: Option<&str>, ) -> Result { // Starts realtime through the public JSON-RPC method, then waits for the same client-visible // notifications a desktop app needs: started first, SDP answer second. @@ -341,10 +348,12 @@ impl RealtimeE2eHarness { .mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs, thread_id: self.thread_id.clone(), codex_response_item_prefix: codex_responses_as_items .unwrap_or(false) .then(|| RESPONSE_ITEM_PREFIX.to_string()), + codex_response_handoff_prefix: codex_response_handoff_prefix.map(str::to_string), codex_responses_as_items, model: None, output_modality: RealtimeOutputModality::Audio, @@ -611,8 +620,10 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: thread_start.thread.id.clone(), model: Some("realtime-treatment-model".to_string()), output_modality: RealtimeOutputModality::Audio, @@ -867,8 +878,10 @@ async fn realtime_start_can_skip_startup_context() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: thread_start.thread.id.clone(), model: None, output_modality: RealtimeOutputModality::Audio, @@ -964,8 +977,10 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: thread_start.thread.id.clone(), model: None, output_modality: RealtimeOutputModality::Text, @@ -1144,8 +1159,10 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: thread_start.thread.id.clone(), model: None, output_modality: RealtimeOutputModality::Audio, @@ -1247,8 +1264,10 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: thread_id.clone(), model: None, output_modality: RealtimeOutputModality::Audio, @@ -1480,6 +1499,135 @@ async fn webrtc_v1_default_automatic_output_uses_handoff_append() -> Result<()> Ok(()) } +#[tokio::test] +async fn webrtc_v1_client_managed_handoffs_disable_automatic_output() -> Result<()> { + skip_if_no_network!(Ok(())); + + let mut harness = RealtimeE2eHarness::new( + RealtimeTestVersion::V1, + main_loop_responses(vec![create_final_assistant_message_sse_response( + "client-managed output", + )?]), + realtime_sideband(vec![realtime_sideband_connection(vec![ + vec![session_updated("sess_v1_client_managed_handoffs")], + vec![], + ])]), + ) + .await?; + + let started = harness + .start_webrtc_realtime_with_codex_response_routing( + "v=offer\r\n", + /*client_managed_handoffs*/ Some(true), + /*codex_responses_as_items*/ None, + /*codex_response_handoff_prefix*/ None, + ) + .await?; + assert_eq!(started.started.version, RealtimeConversationVersion::V1); + assert_v1_session_update(&harness.sideband_outbound_request(/*request_index*/ 0).await)?; + + let turn_request_id = harness + .mcp + .send_turn_start_request(TurnStartParams { + thread_id: harness.thread_id.clone(), + input: vec![V2UserInput::Text { + text: "leave realtime delivery to the client".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + harness + .mcp + .read_stream_until_response_message(RequestId::Integer(turn_request_id)), + ) + .await??; + let _: TurnStartResponse = to_response(turn_response)?; + let _ = harness + .read_notification::("turn/completed") + .await?; + + let automatic_handoff = timeout( + Duration::from_millis(200), + harness + .realtime_server + .wait_for_request(/*connection_index*/ 0, /*request_index*/ 1), + ) + .await; + assert!( + automatic_handoff.is_err(), + "automatic Codex output should not reach realtime in client-managed handoff mode" + ); + + harness + .append_speech(harness.thread_id.clone(), "client-selected speech") + .await?; + assert_eq!( + harness.sideband_outbound_request(/*request_index*/ 1).await, + json!({ + "type": "conversation.handoff.append", + "handoff_id": "codex", + "output_text": "client-selected speech", + }) + ); + + harness.shutdown().await; + Ok(()) +} + +#[tokio::test] +async fn webrtc_v1_final_automatic_handoff_omits_silent_prefix() -> Result<()> { + skip_if_no_network!(Ok(())); + + let mut harness = RealtimeE2eHarness::new( + RealtimeTestVersion::V1, + main_loop_responses(vec![create_final_assistant_message_sse_response( + "background progress", + )?]), + realtime_sideband(vec![realtime_sideband_connection(vec![ + vec![ + session_updated("sess_v1_prefixed_handoff"), + json!({ + "type": "conversation.handoff.requested", + "handoff_id": "handoff_prefixed", + "item_id": "item_prefixed", + "input_transcript": "run the background task" + }), + ], + vec![], + vec![], + ])]), + ) + .await?; + + let started = harness + .start_webrtc_realtime_with_codex_response_routing( + "v=offer\r\n", + /*client_managed_handoffs*/ None, + /*codex_responses_as_items*/ None, + Some(RESPONSE_HANDOFF_PREFIX), + ) + .await?; + assert_eq!(started.started.version, RealtimeConversationVersion::V1); + let _ = harness + .read_notification::("turn/completed") + .await?; + + assert_eq!( + harness.sideband_outbound_request(/*request_index*/ 1).await, + json!({ + "type": "conversation.handoff.append", + "handoff_id": "handoff_prefixed", + "output_text": "background progress", + }) + ); + + harness.shutdown().await; + Ok(()) +} + #[tokio::test] async fn webrtc_v1_handoff_request_delegates_context_and_manual_append_speaks() -> Result<()> { skip_if_no_network!(Ok(())); @@ -2468,8 +2616,10 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: thread_start.thread.id, model: None, output_modality: RealtimeOutputModality::Audio, @@ -2533,8 +2683,10 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: thread_start.thread.id.clone(), model: None, output_modality: RealtimeOutputModality::Audio, diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 2b5eb91a23..b684228466 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -30,6 +30,7 @@ use codex_login::read_openai_api_key_from_env; use codex_model_provider_info::ModelProviderInfo; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; +use codex_protocol::models::MessagePhase; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::ConversationSpeechParams; @@ -107,8 +108,10 @@ struct RealtimeHandoffState { output_tx: Sender, active_handoff: Arc>>, last_output_text: Arc>>, + client_managed_handoffs: bool, codex_responses_as_items: bool, codex_response_item_prefix: Option, + codex_response_handoff_prefix: Option, session_kind: RealtimeSessionKind, } @@ -116,6 +119,7 @@ struct RealtimeHandoffState { enum RealtimeOutbound { StandaloneHandoff { text: String }, HandoffUpdate { handoff_id: String, text: String }, + HandoffAppend { handoff_id: String, text: String }, CompletedHandoff { handoff_id: String, text: String }, ConversationItem { text: String }, HandoffCompleteAck { handoff_id: String }, @@ -210,16 +214,20 @@ struct RealtimeInputChannels { impl RealtimeHandoffState { fn new( output_tx: Sender, + client_managed_handoffs: bool, codex_responses_as_items: bool, codex_response_item_prefix: Option, + codex_response_handoff_prefix: Option, session_kind: RealtimeSessionKind, ) -> Self { Self { output_tx, active_handoff: Arc::new(Mutex::new(None)), last_output_text: Arc::new(Mutex::new(None)), + client_managed_handoffs, codex_responses_as_items, codex_response_item_prefix, + codex_response_handoff_prefix, session_kind, } } @@ -240,8 +248,10 @@ struct RealtimeStart { api_provider: ApiProvider, architecture: RealtimeConversationArchitecture, extra_headers: Option, + client_managed_handoffs: bool, codex_responses_as_items: bool, codex_response_item_prefix: Option, + codex_response_handoff_prefix: Option, realtime_call_api_provider: Option, session_config: RealtimeSessionConfig, model_client: ModelClient, @@ -296,8 +306,10 @@ impl RealtimeConversationManager { api_provider, architecture, extra_headers, + client_managed_handoffs, codex_responses_as_items, codex_response_item_prefix, + codex_response_handoff_prefix, realtime_call_api_provider, session_config, model_client, @@ -321,8 +333,10 @@ impl RealtimeConversationManager { let realtime_active = Arc::new(AtomicBool::new(true)); let handoff = RealtimeHandoffState::new( handoff_output_tx, + client_managed_handoffs, codex_responses_as_items, codex_response_item_prefix, + codex_response_handoff_prefix, session_kind, ); let input_channels = RealtimeInputChannels { @@ -479,7 +493,11 @@ impl RealtimeConversationManager { Ok(()) } - pub(crate) async fn handoff_out(&self, output_text: String) -> CodexResult<()> { + pub(crate) async fn handoff_out( + &self, + output_text: String, + phase: Option, + ) -> CodexResult<()> { let handoff = { let guard = self.state.lock().await; let Some(state) = guard.as_ref() else { @@ -490,6 +508,13 @@ impl RealtimeConversationManager { state.handoff.clone() }; + if handoff.client_managed_handoffs { + return Ok(()); + } + let response_handoff_prefix = match phase { + Some(MessagePhase::Commentary) => handoff.codex_response_handoff_prefix.clone(), + Some(MessagePhase::FinalAnswer) | None => None, + }; let active_handoff = handoff.active_handoff.lock().await.clone(); let output = match active_handoff { Some(handoff_id) => { @@ -502,6 +527,16 @@ impl RealtimeConversationManager { handoff.codex_response_item_prefix.as_deref(), ), } + } else if handoff.session_kind == RealtimeSessionKind::V1 + && handoff.codex_response_handoff_prefix.is_some() + { + RealtimeOutbound::HandoffAppend { + handoff_id, + text: realtime_backend_item( + output_text, + response_handoff_prefix.as_deref(), + ), + } } else { RealtimeOutbound::HandoffUpdate { handoff_id, @@ -520,7 +555,13 @@ impl RealtimeConversationManager { ), } } else { - RealtimeOutbound::StandaloneHandoff { text: output_text } + RealtimeOutbound::StandaloneHandoff { + text: if handoff.session_kind == RealtimeSessionKind::V1 { + realtime_backend_item(output_text, response_handoff_prefix.as_deref()) + } else { + output_text + }, + } } } }; @@ -565,6 +606,9 @@ impl RealtimeConversationManager { let Some(handoff) = handoff else { return Ok(()); }; + if handoff.client_managed_handoffs { + return Ok(()); + } match handoff.session_kind { RealtimeSessionKind::V1 => return Ok(()), RealtimeSessionKind::V2 => {} @@ -675,8 +719,10 @@ struct PreparedRealtimeConversationStart { api_provider: ApiProvider, architecture: RealtimeConversationArchitecture, extra_headers: Option, + client_managed_handoffs: bool, codex_responses_as_items: bool, codex_response_item_prefix: Option, + codex_response_handoff_prefix: Option, realtime_call_api_provider: Option, requested_realtime_session_id: Option, version: RealtimeWsVersion, @@ -744,8 +790,10 @@ async fn prepare_realtime_start( api_provider, architecture, extra_headers, + client_managed_handoffs: params.client_managed_handoffs, codex_responses_as_items: params.codex_responses_as_items, codex_response_item_prefix: params.codex_response_item_prefix, + codex_response_handoff_prefix: params.codex_response_handoff_prefix, realtime_call_api_provider, requested_realtime_session_id, version, @@ -914,8 +962,10 @@ async fn handle_start_inner( api_provider, architecture, extra_headers, + client_managed_handoffs, codex_responses_as_items, codex_response_item_prefix, + codex_response_handoff_prefix, realtime_call_api_provider, requested_realtime_session_id, version, @@ -931,8 +981,10 @@ async fn handle_start_inner( api_provider, architecture, extra_headers, + client_managed_handoffs, codex_responses_as_items, codex_response_item_prefix, + codex_response_handoff_prefix, realtime_call_api_provider, session_config, model_client: sess.services.model_client.clone(), @@ -1368,6 +1420,11 @@ async fn handle_handoff_output( .send_conversation_function_call_output(handoff_id, text) .await } + RealtimeOutbound::HandoffAppend { handoff_id, text } => { + writer + .send_conversation_handoff_append(handoff_id, text) + .await + } RealtimeOutbound::ConversationItem { text } => { writer .send_conversation_item_create(text, ConversationTextRole::Developer) @@ -1388,7 +1445,8 @@ async fn handle_handoff_output( .await; } } - RealtimeOutbound::HandoffUpdate { handoff_id, text } => { + RealtimeOutbound::HandoffUpdate { handoff_id, text } + | RealtimeOutbound::HandoffAppend { handoff_id, text } => { let active_handoff = handoff_state.active_handoff.lock().await.clone(); match active_handoff { Some(active_handoff) if active_handoff == handoff_id => {} diff --git a/codex-rs/core/src/realtime_conversation_tests.rs b/codex-rs/core/src/realtime_conversation_tests.rs index 147e0cc123..55525774b3 100644 --- a/codex-rs/core/src/realtime_conversation_tests.rs +++ b/codex-rs/core/src/realtime_conversation_tests.rs @@ -130,8 +130,10 @@ async fn clears_active_handoff_explicitly() { let (tx, _rx) = bounded(1); let state = RealtimeHandoffState::new( tx, + /*client_managed_handoffs*/ false, /*codex_responses_as_items*/ false, /*codex_response_item_prefix*/ None, + /*codex_response_handoff_prefix*/ None, RealtimeSessionKind::V1, ); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index e21b5394d9..0ecc706818 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1809,13 +1809,13 @@ impl Session { } async fn maybe_mirror_event_text_to_realtime(&self, msg: &EventMsg) { - let Some(text) = realtime_text_for_event(msg) else { + let Some((text, phase)) = realtime_text_for_event(msg) else { return; }; if self.conversation.running_state().await.is_none() { return; } - if let Err(err) = self.conversation.handoff_out(text).await { + if let Err(err) = self.conversation.handoff_out(text, phase).await { debug!("failed to mirror event text to realtime conversation: {err}"); } } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 4beef792bb..257e741203 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1460,11 +1460,11 @@ fn agent_message_text(item: &codex_protocol::items::AgentMessageItem) -> String .collect() } -pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { +pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<(String, Option)> { match msg { - EventMsg::AgentMessage(event) => Some(event.message.clone()), + EventMsg::AgentMessage(event) => Some((event.message.clone(), event.phase.clone())), EventMsg::ItemCompleted(event) => match &event.item { - TurnItem::AgentMessage(item) => Some(agent_message_text(item)), + TurnItem::AgentMessage(item) => Some((agent_message_text(item), item.phase.clone())), _ => None, }, EventMsg::Error(_) diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 365cf35b02..40c01fa1bc 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -204,8 +204,10 @@ async fn start_realtime_conversation(codex: &codex_core::CodexThread) -> Result< codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index a2aa6d61d1..dccf460f0b 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -67,6 +67,7 @@ const MEMORY_PROMPT_PHRASE: &str = "You have access to a memory folder with guidance from prior runs."; const REALTIME_CONVERSATION_TEST_SUBPROCESS_ENV_VAR: &str = "CODEX_REALTIME_CONVERSATION_TEST_SUBPROCESS"; +const SILENT_CONTEXT_PREFIX: &str = "[BACKEND] Silent Codex context. Do not speak, acknowledge, or summarize this item. Wait for an explicit speakable handoff or direct user request."; #[derive(Debug, Clone)] struct RealtimeCallRequestCapture { @@ -285,8 +286,10 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -430,8 +433,10 @@ async fn conversation_start_defaults_to_v2_and_gpt_realtime_1_5() -> Result<()> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -524,8 +529,10 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: Some("session-override-model".to_string()), output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -707,8 +714,10 @@ async fn conversation_webrtc_start_uses_avas_architecture_query() -> Result<()> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: Some(RealtimeConversationArchitecture::Avas), + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -808,8 +817,10 @@ async fn conversation_webrtc_start_uses_configured_call_base_url_for_avas() -> R test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: Some(RealtimeConversationArchitecture::Avas), + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -901,8 +912,10 @@ async fn conversation_webrtc_close_while_sideband_connecting_drops_pending_join( test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -991,8 +1004,10 @@ async fn conversation_webrtc_sideband_connect_failure_closes_with_error() -> Res test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1083,8 +1098,10 @@ async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1155,8 +1172,10 @@ async fn conversation_transport_close_emits_closed_event() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1251,8 +1270,10 @@ async fn conversation_start_preflight_failure_emits_realtime_error_only() -> Res test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1301,8 +1322,10 @@ async fn conversation_start_connect_failure_emits_realtime_error_only() -> Resul test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1399,8 +1422,10 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1428,8 +1453,10 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1528,8 +1555,10 @@ async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Resul test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1596,8 +1625,10 @@ async fn conversation_uses_default_realtime_backend_prompt() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1672,8 +1703,10 @@ async fn conversation_uses_empty_instructions_for_null_or_empty_prompt() -> Resu test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1741,8 +1774,10 @@ async fn conversation_uses_explicit_start_voice() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1802,8 +1837,10 @@ async fn conversation_uses_configured_realtime_voice() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1851,8 +1888,10 @@ async fn conversation_rejects_voice_for_wrong_realtime_version() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1901,8 +1940,10 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1977,8 +2018,10 @@ async fn conversation_uses_experimental_realtime_ws_startup_context_override() - test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2047,8 +2090,10 @@ async fn conversation_disables_realtime_startup_context_with_empty_override() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2110,8 +2155,10 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2227,8 +2274,10 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2338,8 +2387,10 @@ async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<() test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2401,8 +2452,10 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2485,8 +2538,10 @@ async fn conversation_user_text_turn_is_not_sent_to_realtime() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2585,8 +2640,10 @@ async fn realtime_v2_noop_tool_call_returns_empty_function_output_without_respon test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2687,8 +2744,10 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Re test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2764,6 +2823,10 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> skip_if_no_network!(Ok(())); let (gate_second_message_tx, gate_second_message_rx) = oneshot::channel(); + let mut commentary_message = responses::ev_assistant_message("msg-1", "assistant message 1"); + commentary_message["item"]["phase"] = json!("commentary"); + let mut final_message = responses::ev_assistant_message("msg-2", "assistant message 2"); + final_message["item"]["phase"] = json!("final_answer"); let first_chunks = vec![ StreamingSseChunk { gate: None, @@ -2771,17 +2834,11 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> }, StreamingSseChunk { gate: None, - body: sse_event(responses::ev_assistant_message( - "msg-1", - "assistant message 1", - )), + body: sse_event(commentary_message), }, StreamingSseChunk { gate: Some(gate_second_message_rx), - body: sse_event(responses::ev_assistant_message( - "msg-2", - "assistant message 2", - )), + body: sse_event(final_message), }, StreamingSseChunk { gate: None, @@ -2827,8 +2884,10 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: Some(SILENT_CONTEXT_PREFIX.to_string()), model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2873,7 +2932,7 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> ); assert_eq!( first_append.body_json()["output_text"].as_str(), - Some("\"Agent Final Message\":\n\nassistant message 1") + Some(format!("{SILENT_CONTEXT_PREFIX}\n\nassistant message 1").as_str()) ); let _ = wait_for_event_match(&test.codex, |msg| match msg { @@ -2899,7 +2958,7 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> ); assert_eq!( second_append.body_json()["output_text"].as_str(), - Some("\"Agent Final Message\":\n\nassistant message 2") + Some("assistant message 2") ); let completion = completions @@ -2982,8 +3041,10 @@ async fn inbound_handoff_request_starts_turn() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -3095,8 +3156,10 @@ async fn inbound_handoff_request_uses_active_transcript() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -3201,8 +3264,10 @@ async fn inbound_handoff_request_sends_transcript_delta_after_each_handoff() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -3305,8 +3370,10 @@ async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio( test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -3431,8 +3498,10 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -3587,8 +3656,10 @@ async fn inbound_handoff_request_does_not_block_realtime_event_forwarding() -> R test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -3732,8 +3803,10 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -3888,8 +3961,10 @@ async fn inbound_handoff_request_starts_turn_and_does_not_block_realtime_audio() test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index a0e5b24d47..f87d71b216 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -182,10 +182,16 @@ pub struct McpServerRefreshConfig { pub struct ConversationStartParams { /// Overrides the configured realtime architecture for this session only. pub architecture: Option, + /// Whether Codex response handoffs are managed through explicit client append calls. + pub client_managed_handoffs: bool, /// Sends automatic Codex responses as realtime conversation items instead of handoff appends. pub codex_responses_as_items: bool, /// Optional prefix added to automatic Codex response items when `codex_responses_as_items` is set. pub codex_response_item_prefix: Option, + /// Optional prefix added to automatic V1 Codex commentary sent with + /// `conversation.handoff.append` when `codex_responses_as_items` is not set. Final answers are + /// sent without the prefix. + pub codex_response_handoff_prefix: Option, /// Overrides the configured realtime model for this session only. pub model: Option, /// Selects whether the realtime session should produce text or audio output. From e922f46a0f863a9630ddf7dc60dcbadb36a6c28a Mon Sep 17 00:00:00 2001 From: guinness-oai Date: Wed, 17 Jun 2026 20:57:13 -0700 Subject: [PATCH 51/70] [codex] Support assistant realtime append text (#28836) ## Why Frontend realtime voice continuity needs to replay a tiny previous-session overlap as actual conversation items, including assistant text. The app-server `thread/realtime/appendText` API already carries a role through to the Rust realtime websocket layer, but the shared role enum only accepted `user` and `developer`. ## What Changed - Added `assistant` to `ConversationTextRole` and regenerated the app-server schema/type fixtures. - Added `output_text` as a realtime conversation content type. - Updated realtime websocket item creation so assistant appendText emits `content: [{ type: "output_text", text }]`, while user and developer continue to emit `input_text`. - Updated app-server docs and tests to cover assistant appendText alongside the existing developer role behavior. ## Validation - `just write-app-server-schema` - `just fmt` (first sandboxed attempt failed because `uv` could not access `~/.cache/uv`; reran with filesystem access and passed) - `just test -p codex-api` passed: 126/126 - `just test -p codex-app-server-protocol` passed: 239/239, including generated JSON/TypeScript fixture checks - `just test -p codex-app-server` was started locally but stopped per request after unrelated local sandbox/Seatbelt failures (`sandbox-exec: sandbox_apply: Operation not permitted`) and one missing local `codex` binary failure; CI should be faster and more authoritative for the full suite. --- .../schema/json/ClientRequest.json | 3 +- .../codex_app_server_protocol.schemas.json | 3 +- .../codex_app_server_protocol.v2.schemas.json | 3 +- .../schema/typescript/ConversationTextRole.ts | 2 +- codex-rs/app-server/README.md | 2 +- .../tests/suite/v2/realtime_conversation.rs | 44 +++++++++++-- .../endpoint/realtime_websocket/methods.rs | 64 +++++++++++++++++-- .../endpoint/realtime_websocket/methods_v1.rs | 9 ++- .../endpoint/realtime_websocket/methods_v2.rs | 9 ++- .../endpoint/realtime_websocket/protocol.rs | 1 + codex-rs/protocol/src/protocol.rs | 1 + 11 files changed, 123 insertions(+), 18 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7498994980..5ebb861de0 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -643,7 +643,8 @@ "ConversationTextRole": { "enum": [ "user", - "developer" + "developer", + "assistant" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 5b0c59c425..9a13e86523 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -8707,7 +8707,8 @@ "ConversationTextRole": { "enum": [ "user", - "developer" + "developer", + "assistant" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 45cfbd95c2..0f6084a993 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4992,7 +4992,8 @@ "ConversationTextRole": { "enum": [ "user", - "developer" + "developer", + "assistant" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/ConversationTextRole.ts b/codex-rs/app-server-protocol/schema/typescript/ConversationTextRole.ts index 9cba89f837..a4d574b4db 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ConversationTextRole.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ConversationTextRole.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ConversationTextRole = "user" | "developer"; +export type ConversationTextRole = "user" | "developer" | "assistant"; diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 7dde50d193..a6c5ddab7d 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -167,7 +167,7 @@ Example with notification opt-out: - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. - `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, optionally pass `model` and `version` to override configured realtime selection for this session only, and pass `includeStartupContext: false` to omit Codex's generated startup context. By default, automatic Codex text follows the protocol's speakable output path. Pass `clientManagedHandoffs: true` to disable automatic Codex response delivery so only the client's explicit append calls produce handoffs. Pass `codexResponsesAsItems: true` to send automatic Codex responses as realtime conversation items instead, and optionally pass `codexResponseItemPrefix` to prepend experiment instructions to those items. For V1 sessions, pass `codexResponseHandoffPrefix` while item mode is disabled to route automatic Codex commentary through `conversation.handoff.append` with that prefix; final answers remain unprefixed. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. - `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`. -- `thread/realtime/appendText` — append text input to the active realtime session with a required `role` of `user` or `developer` (experimental); returns `{}`. Older clients that omit `role` default to `user`. +- `thread/realtime/appendText` — append text input to the active realtime session with a required `role` of `user`, `developer`, or `assistant` (experimental); returns `{}`. Older clients that omit `role` default to `user`. - `thread/realtime/appendSpeech` — append text that the realtime model should speak to the user (experimental); returns `{}`. - `thread/realtime/stop` — stop the active realtime session for the thread (experimental); returns `{}`. - `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review. diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 274ee9b90c..c09278bb88 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -709,6 +709,20 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { .await??; let _: ThreadRealtimeAppendTextResponse = to_response(text_append_response)?; + let assistant_append_request_id = mcp + .send_thread_realtime_append_text_request(ThreadRealtimeAppendTextParams { + thread_id: started.thread_id.clone(), + text: "welcome back".to_string(), + role: ConversationTextRole::Assistant, + }) + .await?; + let assistant_append_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(assistant_append_request_id)), + ) + .await??; + let _: ThreadRealtimeAppendTextResponse = to_response(assistant_append_response)?; + let output_audio = read_notification::( &mut mcp, "thread/realtime/outputAudio/delta", @@ -790,7 +804,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { let connections = realtime_server.connections(); assert_eq!(connections.len(), 1); let connection = &connections[0]; - assert_eq!(connection.len(), 3); + assert_eq!(connection.len(), 4); assert_eq!( connection[0].body_json()["type"].as_str(), Some("session.update") @@ -799,13 +813,14 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { connection[0].body_json()["session"]["instructions"].as_str(), Some(startup_context_instructions.as_str()), ); - let text_request = connection + let text_requests = connection .iter() .map(WebSocketRequest::body_json) - .find(|request| request["type"] == "conversation.item.create") - .context("expected conversation item request")?; + .filter(|request| request["type"] == "conversation.item.create") + .collect::>(); + assert_eq!(text_requests.len(), 2); assert_eq!( - text_request, + text_requests[0], json!({ "type": "conversation.item.create", "item": { @@ -818,6 +833,20 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { }, }) ); + assert_eq!( + text_requests[1], + json!({ + "type": "conversation.item.create", + "item": { + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "welcome back", + }], + }, + }) + ); let mut request_types = [ connection[1].body_json()["type"] .as_str() @@ -827,11 +856,16 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { .as_str() .context("expected websocket request type")? .to_string(), + connection[3].body_json()["type"] + .as_str() + .context("expected websocket request type")? + .to_string(), ]; request_types.sort(); assert_eq!( request_types, [ + "conversation.item.create".to_string(), "conversation.item.create".to_string(), "input_audio_buffer.append".to_string(), ] diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs index 0aa2feb04e..499635579f 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs @@ -1637,10 +1637,29 @@ mod tests { .into_text() .expect("text"); let fourth_json: Value = serde_json::from_str(&fourth).expect("json"); - assert_eq!(fourth_json["type"], "conversation.handoff.append"); - assert_eq!(fourth_json["handoff_id"], "handoff_1"); + assert_eq!(fourth_json["type"], "conversation.item.create"); + assert_eq!(fourth_json["item"]["role"], "assistant"); assert_eq!( - fourth_json["output_text"], + fourth_json["item"]["content"][0]["type"], + Value::String("output_text".to_string()) + ); + assert_eq!( + fourth_json["item"]["content"][0]["text"], + Value::String("assistant context".to_string()) + ); + + let fifth = ws + .next() + .await + .expect("fifth msg") + .expect("fifth msg ok") + .into_text() + .expect("text"); + let fifth_json: Value = serde_json::from_str(&fifth).expect("json"); + assert_eq!(fifth_json["type"], "conversation.handoff.append"); + assert_eq!(fifth_json["handoff_id"], "handoff_1"); + assert_eq!( + fifth_json["output_text"], "\"Agent Final Message\":\n\nhello from background agent" ); @@ -1766,6 +1785,13 @@ mod tests { ) .await .expect("send item"); + connection + .send_conversation_item_create( + "assistant context".to_string(), + ConversationTextRole::Assistant, + ) + .await + .expect("send assistant item"); connection .send_conversation_function_call_output( "handoff_1".to_string(), @@ -1988,16 +2014,35 @@ mod tests { .expect("text"); let third_json: Value = serde_json::from_str(&third).expect("json"); assert_eq!(third_json["type"], "conversation.item.create"); + assert_eq!(third_json["item"]["role"], "assistant"); assert_eq!( - third_json["item"]["type"], + third_json["item"]["content"][0]["type"], + Value::String("output_text".to_string()) + ); + assert_eq!( + third_json["item"]["content"][0]["text"], + Value::String("assistant context".to_string()) + ); + + let fourth = ws + .next() + .await + .expect("fourth msg") + .expect("fourth msg ok") + .into_text() + .expect("text"); + let fourth_json: Value = serde_json::from_str(&fourth).expect("json"); + assert_eq!(fourth_json["type"], "conversation.item.create"); + assert_eq!( + fourth_json["item"]["type"], Value::String("function_call_output".to_string()) ); assert_eq!( - third_json["item"]["call_id"], + fourth_json["item"]["call_id"], Value::String("call_1".to_string()) ); assert_eq!( - third_json["item"]["output"], + fourth_json["item"]["output"], Value::String("delegated result".to_string()) ); }); @@ -2054,6 +2099,13 @@ mod tests { ) .await .expect("send text item"); + connection + .send_conversation_item_create( + "assistant context".to_string(), + ConversationTextRole::Assistant, + ) + .await + .expect("send assistant item"); connection .send_conversation_function_call_output( "call_1".to_string(), diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs index a7f73d82f3..aa063d07c6 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs @@ -19,12 +19,19 @@ pub(super) fn conversation_item_create_message( text: String, role: ConversationTextRole, ) -> RealtimeOutboundMessage { + let content_type = match role { + ConversationTextRole::Assistant => ConversationContentType::OutputText, + ConversationTextRole::User | ConversationTextRole::Developer => { + ConversationContentType::InputText + } + }; + RealtimeOutboundMessage::ConversationItemCreate { item: ConversationItemPayload::Message(ConversationMessageItem { r#type: ConversationItemType::Message, role, content: vec![ConversationItemContent { - r#type: ConversationContentType::InputText, + r#type: content_type, text, }], }), diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v2.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v2.rs index 702b4be869..ee5d5031af 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v2.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v2.rs @@ -40,12 +40,19 @@ pub(super) fn conversation_item_create_message( text: String, role: ConversationTextRole, ) -> RealtimeOutboundMessage { + let content_type = match role { + ConversationTextRole::Assistant => ConversationContentType::OutputText, + ConversationTextRole::User | ConversationTextRole::Developer => { + ConversationContentType::InputText + } + }; + RealtimeOutboundMessage::ConversationItemCreate { item: ConversationItemPayload::Message(ConversationMessageItem { r#type: ConversationItemType::Message, role, content: vec![ConversationItemContent { - r#type: ConversationContentType::InputText, + r#type: content_type, text, }], }), diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs index 8f61fc1d1d..48f89b0d33 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs @@ -195,6 +195,7 @@ pub(super) struct ConversationItemContent { #[serde(rename_all = "snake_case")] pub(super) enum ConversationContentType { InputText, + OutputText, } #[derive(Debug, Clone, Serialize)] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f87d71b216..e77590c5e4 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -417,6 +417,7 @@ pub enum ConversationTextRole { #[default] User, Developer, + Assistant, } #[derive(Debug, Clone, PartialEq)] From ac3fe641005d002590518e139a61831a3c3b0fff Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Wed, 17 Jun 2026 20:58:48 -0700 Subject: [PATCH 52/70] Refresh signed exec-server URLs on reconnect (#28374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - add a provider API that supplies a fresh signed WebSocket URL for each remote exec-server connection - refresh the signed URL after disconnects and retry once when a handshake returns `401 Unauthorized` - allow `EnvironmentManager` consumers to register remote environments backed by the URL provider ## Tests - `just test -p codex-exec-server -E 'test(remote_websocket_client_refreshes_url_after_unauthorized_handshake) | test(remote_websocket_client_refreshes_url_after_disconnect)'` — 2 passed - `cargo check -p codex-core-api` — passed - `just fix -p codex-exec-server` — passed - `just fix -p codex-core-api` — no test targets; no-op - `just fmt` — passed - `just test -p codex-exec-server` — 187 passed; 32 unrelated macOS sandbox tests could not invoke nested `sandbox-exec` (`Operation not permitted`) --- codex-rs/exec-server/src/client_transport.rs | 49 ++++++-- .../exec-server/src/client_transport_tests.rs | 112 ++++++++++++++++++ 2 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 codex-rs/exec-server/src/client_transport_tests.rs diff --git a/codex-rs/exec-server/src/client_transport.rs b/codex-rs/exec-server/src/client_transport.rs index b079d32725..c6cff4ed30 100644 --- a/codex-rs/exec-server/src/client_transport.rs +++ b/codex-rs/exec-server/src/client_transport.rs @@ -117,17 +117,8 @@ impl ExecServerClient { connect_timeout: DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, initialize_timeout: DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT, }; - let bundle = provider.connect_bundle(identity.public_key()).await?; let (connection, options) = - Self::open_noise_rendezvous_connection(NoiseRendezvousConnectArgs { - bundle, - harness_identity: identity, - client_name: ENVIRONMENT_CLIENT_NAME.to_string(), - connect_timeout: DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, - initialize_timeout: DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT, - resume_session_id: None, - }) - .await?; + Self::open_initial_noise_rendezvous_connection(&provider, &identity).await?; Self::connect_with_recovery(connection, options, Some(reconnect_strategy)).await } crate::client_api::ExecServerTransportParams::StdioCommand { @@ -145,6 +136,40 @@ impl ExecServerClient { } } + async fn open_initial_noise_rendezvous_connection( + provider: &Arc, + identity: &NoiseChannelIdentity, + ) -> Result<(JsonRpcConnection, ExecServerClientConnectOptions), ExecServerError> { + let open_connection = |bundle: NoiseRendezvousConnectBundle| { + Self::open_noise_rendezvous_connection(NoiseRendezvousConnectArgs { + bundle, + harness_identity: identity.clone(), + client_name: ENVIRONMENT_CLIENT_NAME.to_string(), + connect_timeout: DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, + initialize_timeout: DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT, + resume_session_id: None, + }) + }; + let bundle = provider.connect_bundle(identity.public_key()).await?; + match open_connection(bundle).await { + Err(error) + if matches!( + &error, + ExecServerError::WebSocketConnect { source, .. } + if matches!( + source, + tokio_tungstenite::tungstenite::Error::Http(response) + if response.status().as_u16() == 401 + ) + ) => + { + let bundle = provider.connect_bundle(identity.public_key()).await?; + open_connection(bundle).await + } + result => result, + } + } + pub async fn connect_websocket( args: RemoteExecServerConnectArgs, ) -> Result { @@ -326,3 +351,7 @@ fn stdio_command_process(stdio_command: &StdioExecServerCommand) -> Command { command.process_group(0); command } + +#[cfg(test)] +#[path = "client_transport_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/client_transport_tests.rs b/codex-rs/exec-server/src/client_transport_tests.rs new file mode 100644 index 0000000000..7dab5fa218 --- /dev/null +++ b/codex-rs/exec-server/src/client_transport_tests.rs @@ -0,0 +1,112 @@ +use std::collections::VecDeque; +use std::sync::Arc; +use std::sync::Mutex; + +use anyhow::Result; +use futures::future::BoxFuture; +use pretty_assertions::assert_eq; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpListener; +use tokio_tungstenite::accept_async; + +use super::ExecServerClient; +use crate::ExecServerError; +use crate::NoiseChannelIdentity; +use crate::NoiseChannelPublicKey; +use crate::NoiseRendezvousConnectBundle; +use crate::NoiseRendezvousConnectProvider; + +struct SequenceNoiseConnectProvider { + bundles: Mutex>, + returned_urls: Mutex>, +} + +impl SequenceNoiseConnectProvider { + fn new(bundles: Vec) -> Self { + Self { + bundles: Mutex::new(bundles.into()), + returned_urls: Mutex::new(Vec::new()), + } + } + + fn returned_urls(&self) -> Vec { + self.returned_urls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +impl NoiseRendezvousConnectProvider for SequenceNoiseConnectProvider { + fn connect_bundle( + &self, + _: NoiseChannelPublicKey, + ) -> BoxFuture<'_, Result> { + let result = self + .bundles + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pop_front() + .ok_or_else(|| ExecServerError::Protocol("test Noise provider exhausted".to_string())); + if let Ok(bundle) = &result { + self.returned_urls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(bundle.websocket_url.clone()); + } + Box::pin(async move { result }) + } +} + +fn test_bundle(websocket_url: String) -> Result { + Ok(NoiseRendezvousConnectBundle { + websocket_url, + environment_id: "environment".to_string(), + executor_registration_id: "registration".to_string(), + executor_public_key: NoiseChannelIdentity::generate()?.public_key(), + harness_key_authorization: "authorization".to_string(), + }) +} + +#[tokio::test] +async fn initial_noise_connection_refreshes_bundle_after_unauthorized_handshake() -> Result<()> { + let unauthorized_listener = TcpListener::bind("127.0.0.1:0").await?; + let unauthorized_url = format!("ws://{}", unauthorized_listener.local_addr()?); + let unauthorized_server = tokio::spawn(async move { + let (mut socket, _) = unauthorized_listener.accept().await?; + let mut request = [0_u8; 4096]; + let _ = socket.read(&mut request).await?; + socket + .write_all( + b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await?; + socket.shutdown().await?; + anyhow::Ok(()) + }); + let accepted_listener = TcpListener::bind("127.0.0.1:0").await?; + let accepted_url = format!("ws://{}", accepted_listener.local_addr()?); + let accepted_server = tokio::spawn(async move { + let (socket, _) = accepted_listener.accept().await?; + let _websocket = accept_async(socket).await?; + anyhow::Ok(()) + }); + let sequence = Arc::new(SequenceNoiseConnectProvider::new(vec![ + test_bundle(unauthorized_url.clone())?, + test_bundle(accepted_url.clone())?, + ])); + let provider: Arc = sequence.clone(); + let identity = NoiseChannelIdentity::generate()?; + + let _connection = + ExecServerClient::open_initial_noise_rendezvous_connection(&provider, &identity).await?; + + assert_eq!( + sequence.returned_urls(), + vec![unauthorized_url, accepted_url] + ); + unauthorized_server.await??; + accepted_server.await??; + Ok(()) +} From 78a9e169bbdeae5193a25a3f6773cace50d0cf5d Mon Sep 17 00:00:00 2001 From: Won Park Date: Wed, 17 Jun 2026 21:07:54 -0700 Subject: [PATCH 53/70] Expose selecte namespaces as direct model tools (#28825) ## Why Som tools, such as history and notes, must remain top-level when MCP deferral is enabled while staying unavailable through code-mode `exec`. ## What changed - Added `features.code_mode.direct_only_tool_namespaces`. - Classified matching MCP tools as `DirectModelOnly`. - Kept those tools top-level in `code_mode_only`. - Excluded them from `tool_search` deferral and the nested `exec` surface. - Updated the generated config schema. ## Validation - `code_mode_only_exposes_direct_model_only_mcp_namespaces` - `load_config_resolves_code_mode_config` --- codex-rs/core/config.schema.json | 7 ++ codex-rs/core/src/config/config_tests.rs | 5 ++ codex-rs/core/src/config/mod.rs | 5 ++ codex-rs/core/src/tools/handlers/dynamic.rs | 4 +- codex-rs/core/src/tools/spec_plan.rs | 30 +++++++ codex-rs/core/src/tools/spec_plan_tests.rs | 42 +++++++++ .../core/tests/suite/mcp_tool_exposure.rs | 85 +++++++++++++++++++ codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/features/src/feature_configs.rs | 5 ++ 9 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 codex-rs/core/tests/suite/mcp_tool_exposure.rs diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 9944c2994c..148901a704 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -362,6 +362,13 @@ "CodeModeConfigToml": { "additionalProperties": false, "properties": { + "direct_only_tool_namespaces": { + "description": "Exact tool namespaces to expose only as direct model tools. These tools bypass deferral, remain top-level in code-mode-only sessions, and are omitted from the nested code-mode tool surface.", + "items": { + "type": "string" + }, + "type": "array" + }, "enabled": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 47aa8910da..dd6169fbba 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -438,6 +438,7 @@ async fn load_config_resolves_code_mode_config() -> std::io::Result<()> { [features.code_mode] enabled = true excluded_tool_namespaces = ["mcp__codex_apps", "multi_agent_v1"] +direct_only_tool_namespaces = ["mcp__history", "mcp__notes"] "#, ) .expect("TOML deserialization should succeed"); @@ -452,6 +453,10 @@ excluded_tool_namespaces = ["mcp__codex_apps", "multi_agent_v1"] config.code_mode.excluded_tool_namespaces, vec!["mcp__codex_apps".to_string(), "multi_agent_v1".to_string()] ); + assert_eq!( + config.code_mode.direct_only_tool_namespaces, + vec!["mcp__history".to_string(), "mcp__notes".to_string()] + ); assert!(config.features.enabled(Feature::CodeMode)); Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 72d3d45c9d..a54137b58c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1061,6 +1061,7 @@ pub struct Config { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct CodeModeConfig { pub excluded_tool_namespaces: Vec, + pub direct_only_tool_namespaces: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -2404,6 +2405,10 @@ fn resolve_code_mode_config(config_toml: &ConfigToml) -> CodeModeConfig { .and_then(|config| config.excluded_tool_namespaces.as_ref()) .cloned() .unwrap_or_default(), + direct_only_tool_namespaces: base + .and_then(|config| config.direct_only_tool_namespaces.as_ref()) + .cloned() + .unwrap_or_default(), } } diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs index 401278576b..28af3434ff 100644 --- a/codex-rs/core/src/tools/handlers/dynamic.rs +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -56,7 +56,9 @@ impl DynamicToolHandler { namespace.map(|namespace| namespace.name.clone()), tool.name.clone(), ); - let output_tool = dynamic_tool_to_responses_api_tool(tool).ok()?; + let mut output_tool = dynamic_tool_to_responses_api_tool(tool).ok()?; + // Exposure controls deferral; tool search restores this marker for deferred results. + output_tool.defer_loading = None; let spec = match namespace { Some(namespace) => ToolSpec::Namespace(ResponsesApiNamespace { name: namespace.name.clone(), diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index c697f287aa..660f3f4187 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -192,11 +192,41 @@ fn build_tool_specs_and_registry( }; let mut planned_tools = PlannedTools::default(); add_tool_sources(&context, &mut planned_tools); + apply_direct_model_only_namespace_overrides(turn_context, &mut planned_tools); append_tool_search_executor(&context, &mut planned_tools); prepend_code_mode_executors(&context, &mut planned_tools); build_model_visible_specs_and_registry(turn_context, planned_tools) } +fn apply_direct_model_only_namespace_overrides( + turn_context: &TurnContext, + planned_tools: &mut PlannedTools, +) { + for runtime in &mut planned_tools.runtimes { + let configured = runtime + .tool_name() + .namespace + .as_ref() + .is_some_and(|namespace| { + turn_context + .config + .code_mode + .direct_only_tool_namespaces + .contains(namespace) + }); + match runtime.exposure() { + ToolExposure::Direct | ToolExposure::Deferred if configured => { + *runtime = + override_tool_exposure(Arc::clone(runtime), ToolExposure::DirectModelOnly); + } + ToolExposure::Direct + | ToolExposure::Deferred + | ToolExposure::DirectModelOnly + | ToolExposure::Hidden => {} + } + } +} + #[instrument(level = "trace", skip_all)] fn build_model_visible_specs_and_registry( turn_context: &TurnContext, diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 43bcf03a6c..78eba0bc7d 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1011,6 +1011,48 @@ async fn code_mode_only_exposes_code_executor_and_hides_nested_tools() { ); } +#[tokio::test] +async fn code_mode_only_exposes_configured_dynamic_namespace_directly() { + let plan = probe_with( + |turn| { + set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]); + turn.model_info.supports_search_tool = true; + update_config(turn, |config| { + config.code_mode.direct_only_tool_namespaces = vec!["direct_only".to_string()]; + }); + }, + ToolPlanInputs { + dynamic_tools: vec![dynamic_tool( + Some("direct_only"), + "lookup", + /*defer_loading*/ true, + )], + ..ToolPlanInputs::default() + }, + ) + .await; + + plan.assert_visible_contains(&[ + codex_code_mode::PUBLIC_TOOL_NAME, + codex_code_mode::WAIT_TOOL_NAME, + "direct_only", + ]); + plan.assert_visible_lacks(&["tool_search"]); + assert_eq!( + plan.exposure(&ToolName::namespaced("direct_only", "lookup").to_string()), + ToolExposure::DirectModelOnly + ); + let ToolSpec::Namespace(namespace) = plan.visible_spec("direct_only") else { + panic!("expected direct-only namespace spec"); + }; + let ResponsesApiNamespaceTool::Function(tool) = &namespace.tools[0]; + assert_eq!(tool.defer_loading, None); + let ToolSpec::Freeform(exec) = plan.visible_spec(codex_code_mode::PUBLIC_TOOL_NAME) else { + panic!("expected code mode exec tool"); + }; + assert!(!exec.description.contains("direct_only_lookup(args:")); +} + #[tokio::test] async fn excluded_deferred_namespaces_do_not_enable_nested_tool_guidance() { let plan = probe_with( diff --git a/codex-rs/core/tests/suite/mcp_tool_exposure.rs b/codex-rs/core/tests/suite/mcp_tool_exposure.rs new file mode 100644 index 0000000000..44f2f87c3e --- /dev/null +++ b/codex-rs/core/tests/suite/mcp_tool_exposure.rs @@ -0,0 +1,85 @@ +use anyhow::Result; +use codex_features::Feature; +use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL; +use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE; +use core_test_support::apps_test_server::search_capable_apps_builder; +use core_test_support::responses; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::namespace_child_tool; +use core_test_support::responses::sse; +use core_test_support::skip_if_no_network; +use serde_json::Value; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_only_exposes_direct_model_only_mcp_namespaces() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let apps_server = AppsTestServer::mount_searchable(&server).await?; + let response = responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone()) + .with_config(move |config| { + config + .features + .enable(Feature::CodeModeOnly) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::ToolSearchAlwaysDeferMcpTools) + .expect("test config should allow feature update"); + config.code_mode.direct_only_tool_namespaces = + vec![SEARCH_CALENDAR_NAMESPACE.to_string()]; + }); + let test = builder.build(&server).await?; + test.submit_turn("inspect directly exposed MCP tools") + .await?; + let body = response.single_request().body_json(); + let tools = body + .get("tools") + .and_then(Value::as_array) + .expect("request should contain tools"); + + assert!( + namespace_child_tool( + &body, + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL, + ) + .is_some(), + "configured MCP namespace should remain top-level: {body}" + ); + assert!( + !tools.iter().any(|tool| { + tool.get("name") + .or_else(|| tool.get("type")) + .and_then(Value::as_str) + == Some("tool_search") + }), + "configured MCP namespace should not be deferred: {body}" + ); + let exec_description = tools.iter().find_map(|tool| { + (tool.get("name").and_then(Value::as_str) == Some("exec")) + .then(|| tool.get("description").and_then(Value::as_str)) + .flatten() + }); + assert!( + exec_description.is_some_and(|description| { + !description.contains("mcp__codex_apps__calendar_create_event(args:") + }), + "direct-model-only MCP namespace should not be available through exec: {body}" + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 87dc0ffce3..275458ffdc 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -65,6 +65,7 @@ mod image_rollout; mod items; mod json_result; mod live_cli; +mod mcp_tool_exposure; mod mcp_turn_metadata; mod model_overrides; mod model_runtime_selectors; diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index f59b89e9a8..0acb9b7d3c 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -12,6 +12,11 @@ pub struct CodeModeConfigToml { /// Exact tool namespaces to omit from the code-mode nested tool surface. #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tool_namespaces: Option>, + /// Exact tool namespaces to expose only as direct model tools. + /// These tools bypass deferral, remain top-level in code-mode-only sessions, and are omitted + /// from the nested code-mode tool surface. + #[serde(skip_serializing_if = "Option::is_none")] + pub direct_only_tool_namespaces: Option>, } impl FeatureConfig for CodeModeConfigToml { From e12dd73b7d5a2aa2b8d0933a2053e7eb5eba6fbb Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Wed, 17 Jun 2026 21:33:53 -0700 Subject: [PATCH 54/70] [codex] Support plugin manifest path lists (#28790) ## Summary Allow plugin manifests to declare `skills` as either a single path string or an array of path strings in the core plugin loader. ## Why Some plugin packages need to expose skills from more than one directory. Before this change, `plugin.json` only accepted a single string for `skills`, so manifests like this were ignored as an invalid `skills` shape: ```json { "skills": ["./skills/abc", "./skills/edk"] } ``` This keeps the existing single-string form working while adding support for the list form. The final scope is intentionally limited to the core plugin manifest/load path for `skills`; `apps`, file-backed `mcpServers`, and the bundled plugin-creator assets are unchanged in this PR. ## What changed - Parse `skills` as either a string or an array of strings in `plugin.json`. - Store resolved skill paths as a list in `PluginManifestPaths`. - Load manifest-declared skill roots in addition to the default `./skills` root. - Deduplicate exact duplicate skill roots before loading. - Rely on existing skill-loader dedupe by canonical `SKILL.md` path for overlapping roots such as `./skills` plus `./skills/abc`. - Update plugin manifest tests to cover: - single string `skills` - list of string `skills` - duplicate skill roots - `./skills` as a manifest path - explicit child roots like `./skills/abc` and `./skills/edk` - overlapping-root dedupe ## Validation - `just test -p codex-plugin` - `just test -p codex-core-plugins` - `just test -p codex-mcp-extension` - `git diff --check` --- codex-rs/core-plugins/src/loader.rs | 9 +- codex-rs/core-plugins/src/manager_tests.rs | 242 ++++++++++++------ codex-rs/core-plugins/src/manifest.rs | 32 ++- .../mcp/src/executor_plugin/provider_tests.rs | 2 +- codex-rs/plugin/src/manifest.rs | 7 +- codex-rs/plugin/src/provider_tests.rs | 6 +- 6 files changed, 202 insertions(+), 96 deletions(-) diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index fb542f045f..846487aba6 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -819,10 +819,11 @@ fn plugin_skill_roots( plugin_root: &AbsolutePathBuf, manifest_paths: &PluginManifestPaths, ) -> Vec { - let mut paths = default_skill_roots(plugin_root); - if let Some(path) = &manifest_paths.skills { - paths.push(path.clone()); - } + let mut paths = if manifest_paths.skills.is_empty() { + default_skill_roots(plugin_root) + } else { + manifest_paths.skills.clone() + }; paths.sort_unstable(); paths.dedup(); paths diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index c2917211d2..b5d22911f9 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -4,6 +4,7 @@ use crate::OPENAI_API_CURATED_MARKETPLACE_NAME; use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::PluginLoadOutcome; use crate::installed_marketplaces::marketplace_install_root; +use crate::loader::load_plugin_skills; use crate::loader::load_plugins_from_layer_stack; use crate::loader::refresh_non_curated_plugin_cache; use crate::loader::refresh_non_curated_plugin_cache_force_reinstall; @@ -33,10 +34,13 @@ use codex_config::McpServerConfig; use codex_config::McpServerOAuthConfig; use codex_config::McpServerToolConfig; use codex_config::types::McpServerTransportConfig; +use codex_core_skills::config_rules::SkillConfigRules; use codex_login::CodexAuth; use codex_plugin::AppDeclaration; +use codex_plugin::PluginId; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::Product; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::test_support::PathBufExt; use pretty_assertions::assert_eq; use std::fs; @@ -1282,32 +1286,62 @@ async fn capability_summary_truncates_overlong_plugin_descriptions() { #[tokio::test] async fn load_plugins_uses_manifest_configured_component_paths() { - let codex_home = TempDir::new().unwrap(); - let plugin_root = codex_home - .path() - .join("plugins/cache") - .join("test/sample/local"); + for (skills_json, expected_skill_dirs) in [ + (r#""./custom-skills/""#, &["custom-skills"][..]), + ( + r#"["./custom-skills/", "./extra-skills/"]"#, + &["custom-skills", "extra-skills"][..], + ), + ( + r#"["./custom-skills/", "./custom-skills/"]"#, + &["custom-skills"][..], + ), + (r#""./skills/""#, &["skills"][..]), + ( + r#"["./skills/abc/", "./skills/edk/"]"#, + &["skills/abc", "skills/edk"][..], + ), + ] { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); - write_file( - &plugin_root.join(".codex-plugin/plugin.json"), - r#"{ + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + &format!( + r#"{{ "name": "sample", - "skills": "./custom-skills/", + "skills": {skills_json}, "mcpServers": "./config/custom.mcp.json", "apps": "./config/custom.app.json" -}"#, - ); - write_file( - &plugin_root.join("skills/default-skill/SKILL.md"), - "---\nname: default-skill\ndescription: default skill\n---\n", - ); - write_file( - &plugin_root.join("custom-skills/custom-skill/SKILL.md"), - "---\nname: custom-skill\ndescription: custom skill\n---\n", - ); - write_file( - &plugin_root.join(".mcp.json"), - r#"{ +}}"# + ), + ); + write_file( + &plugin_root.join("skills/default-skill/SKILL.md"), + "---\nname: default-skill\ndescription: default skill\n---\n", + ); + write_file( + &plugin_root.join("skills/abc/SKILL.md"), + "---\nname: abc\ndescription: abc skill\n---\n", + ); + write_file( + &plugin_root.join("skills/edk/SKILL.md"), + "---\nname: edk\ndescription: edk skill\n---\n", + ); + write_file( + &plugin_root.join("custom-skills/custom-skill/SKILL.md"), + "---\nname: custom-skill\ndescription: custom skill\n---\n", + ); + write_file( + &plugin_root.join("extra-skills/extra-skill/SKILL.md"), + "---\nname: extra-skill\ndescription: extra skill\n---\n", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ "mcpServers": { "default": { "type": "http", @@ -1315,10 +1349,10 @@ async fn load_plugins_uses_manifest_configured_component_paths() { } } }"#, - ); - write_file( - &plugin_root.join("config/custom.mcp.json"), - r#"{ + ); + write_file( + &plugin_root.join("config/custom.mcp.json"), + r#"{ "mcpServers": { "custom": { "type": "http", @@ -1326,74 +1360,132 @@ async fn load_plugins_uses_manifest_configured_component_paths() { } } }"#, - ); - write_file( - &plugin_root.join(".app.json"), - r#"{ + ); + write_file( + &plugin_root.join(".app.json"), + r#"{ "apps": { "default-app": { "id": "connector_default" } } }"#, - ); - write_file( - &plugin_root.join("config/custom.app.json"), - r#"{ + ); + write_file( + &plugin_root.join("config/custom.app.json"), + r#"{ "apps": { "custom-app": { "id": "connector_custom" } } }"#, - ); + ); + let outcome = load_plugins_from_config( + &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), + codex_home.path(), + Some(AuthMode::Chatgpt), + ) + .await; + let mut expected_skill_roots = expected_skill_dirs + .iter() + .map(|dir| plugin_root.join(dir).abs()) + .collect::>(); + expected_skill_roots.sort_unstable(); + expected_skill_roots.dedup(); - let outcome = load_plugins_from_config( - &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), - codex_home.path(), - Some(AuthMode::Chatgpt), + assert_eq!(outcome.plugins()[0].skill_roots, expected_skill_roots); + assert_eq!( + outcome.plugins()[0].mcp_servers, + HashMap::from([( + "custom".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://custom.example/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + environment_id: "local".to_string(), + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth: None, + oauth_resource: None, + tools: HashMap::new(), + }, + )]) + ); + assert_eq!( + outcome.plugins()[0].apps, + vec![app_declaration("custom-app", "connector_custom")] + ); + } +} + +#[tokio::test] +async fn load_plugin_skills_dedupes_overlapping_manifest_roots() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local") + .abs(); + write_file( + &plugin_root.join("skills/abc/SKILL.md"), + "---\nname: abc\ndescription: abc skill\n---\n", + ); + write_file( + &plugin_root.join("skills/edk/SKILL.md"), + "---\nname: edk\ndescription: edk skill\n---\n", + ); + let manifest_paths = crate::manifest::PluginManifestPaths { + skills: vec![ + plugin_root.join("skills"), + plugin_root.join("skills/abc"), + plugin_root.join("skills/edk"), + plugin_root.join("skills/abc"), + ], + mcp_servers: None, + apps: None, + hooks: None, + }; + let plugin_id = PluginId::parse("sample@test").expect("plugin id should parse"); + + let resolved = load_plugin_skills( + &plugin_root, + &plugin_id, + &manifest_paths, + /*restriction_product*/ None, + &SkillConfigRules::default(), ) .await; + let skill_paths = resolved + .skills + .iter() + .map(|skill| skill.path_to_skills_md.clone()) + .collect::>(); + let canonical_skill_path = |path| { + AbsolutePathBuf::from_absolute_path_checked( + fs::canonicalize(plugin_root.join(path)).expect("canonical skill path"), + ) + .expect("absolute skill path") + }; assert_eq!( - outcome.plugins()[0].skill_roots, + skill_paths, vec![ - plugin_root.join("custom-skills").abs(), - plugin_root.join("skills").abs() + canonical_skill_path("skills/abc/SKILL.md"), + canonical_skill_path("skills/edk/SKILL.md") ] ); - assert_eq!( - outcome.plugins()[0].mcp_servers, - HashMap::from([( - "custom".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::StreamableHttp { - url: "https://custom.example/mcp".to_string(), - bearer_token_env_var: None, - http_headers: None, - env_http_headers: None, - }, - environment_id: "local".to_string(), - enabled: true, - required: false, - supports_parallel_tool_calls: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - default_tools_approval_mode: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth: None, - oauth_resource: None, - tools: HashMap::new(), - }, - )]) - ); - assert_eq!( - outcome.plugins()[0].apps, - vec![app_declaration("custom-app", "connector_custom")] - ); } #[tokio::test] @@ -1521,7 +1613,7 @@ async fn load_plugins_ignores_invalid_manifest_skills_shape() { &plugin_root.join(".codex-plugin/plugin.json"), r#"{ "name": "sample", - "skills": ["./custom-skills/"] + "skills": { "path": "./custom-skills/" } }"#, ); write_file( diff --git a/codex-rs/core-plugins/src/manifest.rs b/codex-rs/core-plugins/src/manifest.rs index fe0bb25644..37630a8436 100644 --- a/codex-rs/core-plugins/src/manifest.rs +++ b/codex-rs/core-plugins/src/manifest.rs @@ -30,7 +30,7 @@ struct RawPluginManifest { // Keep manifest paths as raw strings so we can validate the required `./...` syntax before // resolving them under the plugin root. #[serde(default)] - skills: Option, + skills: Option, #[serde(default)] mcp_servers: Option, #[serde(default)] @@ -94,8 +94,9 @@ enum RawPluginManifestDefaultPromptEntry { #[derive(Debug, Deserialize)] #[serde(untagged)] -enum RawPluginManifestPath { +enum RawPluginManifestPaths { Path(String), + Paths(Vec), Invalid(JsonValue), } @@ -230,7 +231,7 @@ pub(crate) fn parse_plugin_manifest( description, keywords, paths: PluginManifestPaths { - skills: resolve_manifest_path_value(plugin_root, "skills", skills.as_ref()), + skills: resolve_manifest_paths(plugin_root, "skills", skills.as_ref()), mcp_servers: resolve_manifest_mcp_servers(plugin_root, mcp_servers), apps: resolve_manifest_path(plugin_root, "apps", apps.as_deref()), hooks: resolve_manifest_hooks(plugin_root, hooks), @@ -395,20 +396,29 @@ fn json_value_type(value: &JsonValue) -> &'static str { } } -fn resolve_manifest_path_value( +fn resolve_manifest_paths( plugin_root: &Path, field: &'static str, - path: Option<&RawPluginManifestPath>, -) -> Option { - match path? { - RawPluginManifestPath::Path(path) => resolve_manifest_path(plugin_root, field, Some(path)), - RawPluginManifestPath::Invalid(value) => { + paths: Option<&RawPluginManifestPaths>, +) -> Vec { + match paths { + Some(RawPluginManifestPaths::Path(path)) => { + resolve_manifest_path(plugin_root, field, Some(path)) + .map(|path| vec![path]) + .unwrap_or_default() + } + Some(RawPluginManifestPaths::Paths(paths)) => paths + .iter() + .filter_map(|path| resolve_manifest_path(plugin_root, field, Some(path))) + .collect(), + Some(RawPluginManifestPaths::Invalid(value)) => { tracing::warn!( - "ignoring {field}: expected a string; found {}", + "ignoring {field}: expected a string or string array; found {}", json_value_type(value) ); - None + Vec::new() } + None => Vec::new(), } } diff --git a/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs b/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs index dc6a0403d0..b154b060b2 100644 --- a/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs +++ b/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs @@ -316,7 +316,7 @@ fn resolved_plugin( description: None, keywords: Vec::new(), paths: PluginManifestPaths { - skills: None, + skills: Vec::new(), mcp_servers, apps: None, hooks: None, diff --git a/codex-rs/plugin/src/manifest.rs b/codex-rs/plugin/src/manifest.rs index 4aeec49be8..599f4ee407 100644 --- a/codex-rs/plugin/src/manifest.rs +++ b/codex-rs/plugin/src/manifest.rs @@ -17,7 +17,7 @@ pub struct PluginManifest { /// Component resources declared by a plugin manifest. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginManifestPaths { - pub skills: Option, + pub skills: Vec, pub mcp_servers: Option>, pub apps: Option, pub hooks: Option>, @@ -172,7 +172,10 @@ impl PluginManifest { description, keywords, paths: PluginManifestPaths { - skills: skills.map(&mut map).transpose()?, + skills: skills + .into_iter() + .map(&mut map) + .collect::, _>>()?, mcp_servers, apps: apps.map(&mut map).transpose()?, hooks, diff --git a/codex-rs/plugin/src/provider_tests.rs b/codex-rs/plugin/src/provider_tests.rs index 776e63b1c3..99bc3c2089 100644 --- a/codex-rs/plugin/src/provider_tests.rs +++ b/codex-rs/plugin/src/provider_tests.rs @@ -37,7 +37,7 @@ fn environment_descriptor_binds_every_manifest_resource() { description: None, keywords: Vec::new(), paths: PluginManifestPaths { - skills: Some(skills.clone()), + skills: vec![skills.clone()], mcp_servers: Some(PluginManifestMcpServers::Path(mcp_servers.clone())), apps: Some(apps.clone()), hooks: Some(PluginManifestHooks::Paths(vec![hooks.clone()])), @@ -71,7 +71,7 @@ fn environment_descriptor_binds_every_manifest_resource() { description: None, keywords: Vec::new(), paths: PluginManifestPaths { - skills: Some(resource("executor-1", skills)), + skills: vec![resource("executor-1", skills)], mcp_servers: Some(PluginManifestMcpServers::Path(resource( "executor-1", mcp_servers, @@ -103,7 +103,7 @@ fn environment_descriptor_rejects_resources_outside_package_root() { description: None, keywords: Vec::new(), paths: PluginManifestPaths { - skills: None, + skills: Vec::new(), mcp_servers: Some(PluginManifestMcpServers::Path(outside.clone())), apps: None, hooks: None, From 285eff6c3ed4bd08a0235bab294737c65784b973 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Wed, 17 Jun 2026 21:59:42 -0700 Subject: [PATCH 55/70] Record more path migration guidance for codex. (#28851) Some common themes pulled out of both human and automated reviews from the last couple of days' migrations to `PathUri` and `LegacyAppPathString`. --- .codex/skills/path-types/SKILL.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.codex/skills/path-types/SKILL.md b/.codex/skills/path-types/SKILL.md index 96caa98058..0062730013 100644 --- a/.codex/skills/path-types/SKILL.md +++ b/.codex/skills/path-types/SKILL.md @@ -31,7 +31,9 @@ Keep these requirements in mind while migrating code to conform with the above g * path reasoning must work before the related environment has come online * URIs cannot explicitly encode the executor’s path convention or operating system * users must not configure the environment’s OS/path convention explicitly -* URIs should not yet be stored in rollouts +* URIs should not yet be stored in rollouts, databases, or other persistent storage +* path conversion errors: fail-closed for security-relevant paths, fail-open for UI/diagnostics +* prefer small focused methods on `PathUri` or `LegacyAppPathString` over local helpers It is OK if the conversion between paths and URIs is somewhat lossy as long as it will do the right thing for real users. From 3931bc2bde3e89876da5f96335629c71d635bd72 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Wed, 17 Jun 2026 22:00:04 -0700 Subject: [PATCH 56/70] unified-exec: retain PathUri in command events (#28780) ## Why App-server must report command events containing foreign-platform paths without changing existing client or rollout path-string formats. ## What changed - retain `PathUri` through exec command begin/end events - convert cwd values to `LegacyAppPathString` at the app-server compatibility boundary - drop command actions with foreign paths and log them - serialize rollout-trace cwd values using their inferred native path representation - restore Wine coverage for retained Windows cwd values and successful completion --- .../analytics/src/analytics_client_tests.rs | 2 +- ...CommandExecutionRequestApprovalParams.json | 2 +- .../schema/json/ServerNotification.json | 2 +- .../schema/json/ServerRequest.json | 2 +- .../codex_app_server_protocol.schemas.json | 4 +- .../codex_app_server_protocol.v2.schemas.json | 2 +- .../json/v2/ItemCompletedNotification.json | 5 +- .../json/v2/ItemStartedNotification.json | 5 +- .../schema/json/v2/ReviewStartResponse.json | 5 +- .../schema/json/v2/ThreadForkResponse.json | 5 +- .../schema/json/v2/ThreadListResponse.json | 5 +- .../json/v2/ThreadMetadataUpdateResponse.json | 5 +- .../schema/json/v2/ThreadReadResponse.json | 5 +- .../schema/json/v2/ThreadResumeResponse.json | 5 +- .../json/v2/ThreadRollbackResponse.json | 5 +- .../schema/json/v2/ThreadStartResponse.json | 5 +- .../json/v2/ThreadStartedNotification.json | 5 +- .../json/v2/ThreadUnarchiveResponse.json | 5 +- .../json/v2/TurnCompletedNotification.json | 5 +- .../schema/json/v2/TurnStartResponse.json | 5 +- .../json/v2/TurnStartedNotification.json | 5 +- .../CommandExecutionRequestApprovalParams.ts | 4 +- .../schema/typescript/v2/ThreadItem.ts | 3 +- .../src/protocol/item_builders.rs | 75 ++++++++--- .../src/protocol/item_builders_tests.rs | 41 ++++++ .../src/protocol/thread_history.rs | 18 +-- .../src/protocol/v2/item.rs | 5 +- codex-rs/app-server-test-client/src/lib.rs | 2 +- .../app-server/src/bespoke_event_handling.rs | 11 +- codex-rs/app-server/src/request_processors.rs | 3 +- codex-rs/app-server/src/transport_tests.rs | 4 +- .../app-server/tests/suite/v2/turn_start.rs | 2 +- .../tests/suite/v2/turn_start_zsh_fork.rs | 2 +- codex-rs/core/src/tasks/user_shell.rs | 8 +- codex-rs/core/src/tools/events.rs | 16 +-- .../remote_env_windows_test.rs | 13 +- codex-rs/core/tests/suite/unified_exec.rs | 10 +- .../src/event_processor_with_human_output.rs | 3 +- .../tests/event_processor_with_json_output.rs | 8 +- codex-rs/protocol/src/protocol.rs | 4 +- codex-rs/rollout-trace/src/protocol_event.rs | 122 +++++++++++++++++- .../rollout-trace/src/protocol_event_tests.rs | 83 ++++++++++++ .../tui/src/app/agent_status_feed_tests.rs | 4 +- .../tui/src/app/pending_interactive_replay.rs | 2 +- codex-rs/tui/src/app/tests.rs | 2 +- codex-rs/tui/src/app/thread_events.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 8 +- codex-rs/tui/src/chatwidget/interrupts.rs | 2 +- .../tui/src/chatwidget/tests/app_server.rs | 4 +- .../src/chatwidget/tests/approval_requests.rs | 4 +- .../tui/src/chatwidget/tests/exec_flow.rs | 4 +- codex-rs/tui/src/chatwidget/tests/helpers.rs | 4 +- .../src/chatwidget/tests/status_and_layout.rs | 4 +- .../utils/path-uri/src/api_path_string.rs | 57 +++++++- .../path-uri/src/api_path_string_tests.rs | 63 ++++++++- codex-rs/utils/path-uri/src/tests.rs | 5 + 56 files changed, 566 insertions(+), 125 deletions(-) create mode 100644 codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d6792587f2..55467c06b6 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -831,7 +831,7 @@ fn sample_command_execution_item_with_id( ThreadItem::CommandExecution { id: id.to_string(), command: "echo hi".to_string(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: Some("pid-1".to_string()), source: CommandExecutionSource::Agent, status, diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index 4e096cbe04..91f74376fd 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -547,7 +547,7 @@ "cwd": { "anyOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, { "type": "null" diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index a0d49cc548..bfafd5483b 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3972,7 +3972,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 97934dd5bd..31ca31de2c 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -371,7 +371,7 @@ "cwd": { "anyOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, { "type": "null" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 9a13e86523..c75ae416fa 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -2378,7 +2378,7 @@ "cwd": { "anyOf": [ { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "$ref": "#/definitions/v2/LegacyAppPathString" }, { "type": "null" @@ -17392,7 +17392,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "$ref": "#/definitions/v2/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 0f6084a993..a4a0267dc5 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -15192,7 +15192,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 233ff8dc98..027edd0367 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -294,6 +294,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -687,7 +690,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index a55d9e7766..64152e9cf4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -294,6 +294,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -687,7 +690,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index e27c911b36..61f61e74dc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -431,6 +431,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -831,7 +834,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 2b7caeca7e..43a76f145a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -536,6 +536,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1323,7 +1326,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 0caa0daf5d..1e1504b738 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -457,6 +457,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1138,7 +1141,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index a98d3586ec..6e62eae14b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -457,6 +457,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1138,7 +1141,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 51e56f14b8..ff70a017b4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -457,6 +457,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1138,7 +1141,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index e8c309507e..f9a65cdd8b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -536,6 +536,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1323,7 +1326,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 52cba55edb..f455f6dfff 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -457,6 +457,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1138,7 +1141,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 245cc946d4..0c3eac4de5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -536,6 +536,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1323,7 +1326,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index bf497ecfa2..0e44cfb258 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -457,6 +457,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1138,7 +1141,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 14f25cfcdc..f88a1fae59 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -457,6 +457,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1138,7 +1141,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 24d1dccf47..7e6cef2c37 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -431,6 +431,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -831,7 +834,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index da88eacd48..083767a6cf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -431,6 +431,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -831,7 +834,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index edb3fa636a..3a43d7d3fb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -431,6 +431,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -831,7 +834,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts index 230e5b7864..4f02c92cae 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts @@ -1,7 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { CommandAction } from "./CommandAction"; import type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; import type { NetworkApprovalContext } from "./NetworkApprovalContext"; @@ -34,7 +34,7 @@ networkApprovalContext?: NetworkApprovalContext | null, /** command?: string | null, /** * The command's working directory. */ -cwd?: AbsolutePathBuf | null, /** +cwd?: LegacyAppPathString | null, /** * Best-effort parsed command actions for friendly display. */ commandActions?: Array | null, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index 4ccab77b32..1cdac99c64 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { MessagePhase } from "../MessagePhase"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { JsonValue } from "../serde_json/JsonValue"; @@ -32,7 +33,7 @@ command: string, /** * The command's working directory. */ -cwd: AbsolutePathBuf, +cwd: LegacyAppPathString, /** * Identifier for the underlying PTY process (when available). */ diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders.rs b/codex-rs/app-server-protocol/src/protocol/item_builders.rs index 17e0f9aef4..66019980a3 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -23,6 +23,7 @@ use crate::protocol::v2::PatchApplyStatus; use crate::protocol::v2::PatchChangeKind; use crate::protocol::v2::ThreadItem; use codex_protocol::ThreadId; +use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::ExecCommandBeginEvent; @@ -34,8 +35,11 @@ use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::PatchApplyEndEvent; use codex_shell_command::parse_command::parse_command; use codex_shell_command::parse_command::shlex_join; +use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; use std::collections::HashMap; use std::path::PathBuf; +use tracing::warn; pub fn build_file_change_approval_request_item( payload: &ApplyPatchApprovalRequestEvent, @@ -69,7 +73,7 @@ pub fn build_command_execution_approval_request_item( ThreadItem::CommandExecution { id: payload.call_id.clone(), command: shlex_join(&payload.command), - cwd: payload.cwd.clone(), + cwd: payload.cwd.clone().into(), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::InProgress, @@ -86,19 +90,15 @@ pub fn build_command_execution_approval_request_item( } pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> ThreadItem { + let command_actions = command_actions_for_path_uri(&payload.parsed_cmd, &payload.cwd); ThreadItem::CommandExecution { id: payload.call_id.clone(), command: shlex_join(&payload.command), - cwd: payload.cwd.clone(), + cwd: payload.cwd.clone().into(), process_id: payload.process_id.clone(), source: payload.source.into(), status: CommandExecutionStatus::InProgress, - command_actions: payload - .parsed_cmd - .iter() - .cloned() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd)) - .collect(), + command_actions, aggregated_output: None, exit_code: None, duration_ms: None, @@ -112,26 +112,63 @@ pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> Thread Some(payload.aggregated_output.clone()) }; let duration_ms = i64::try_from(payload.duration.as_millis()).unwrap_or(i64::MAX); + let command_actions = command_actions_for_path_uri(&payload.parsed_cmd, &payload.cwd); ThreadItem::CommandExecution { id: payload.call_id.clone(), command: shlex_join(&payload.command), - cwd: payload.cwd.clone(), + cwd: payload.cwd.clone().into(), process_id: payload.process_id.clone(), source: payload.source.into(), status: (&payload.status).into(), - command_actions: payload - .parsed_cmd - .iter() - .cloned() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd)) - .collect(), + command_actions, aggregated_output, exit_code: Some(payload.exit_code), duration_ms: Some(duration_ms), } } +fn command_actions_for_path_uri(parsed_cmd: &[ParsedCommand], cwd: &PathUri) -> Vec { + // TODO(anp): Carry PathUri into CommandAction so foreign Read actions retain resolved paths. + // Until then, omit those actions rather than project a foreign cwd onto the host. + let native_cwd = if cwd.infer_path_convention() == Some(PathConvention::native()) { + cwd.to_abs_path().ok() + } else { + None + }; + + parsed_cmd + .iter() + .cloned() + .filter_map(|parsed| match parsed { + ParsedCommand::Read { cmd, name, path } => match native_cwd.as_ref() { + Some(native_cwd) => Some(CommandAction::Read { + command: cmd, + name, + path: native_cwd.join(path), + }), + None => { + warn!( + command = cmd, + %cwd, + "omitting read command action whose path cannot be resolved against a foreign cwd" + ); + None + } + }, + ParsedCommand::ListFiles { cmd, path } => { + Some(CommandAction::ListFiles { command: cmd, path }) + } + ParsedCommand::Search { cmd, query, path } => Some(CommandAction::Search { + command: cmd, + query, + path, + }), + ParsedCommand::Unknown { cmd } => Some(CommandAction::Unknown { command: cmd }), + }) + .collect() +} + /// Build a guardian-derived [`ThreadItem`]. /// /// Currently this only synthesizes [`ThreadItem::CommandExecution`] for @@ -150,7 +187,7 @@ pub fn build_item_from_guardian_event( Some(ThreadItem::CommandExecution { id: id.clone(), command, - cwd: cwd.clone(), + cwd: cwd.clone().into(), process_id: None, source: CommandExecutionSource::Agent, status, @@ -186,7 +223,7 @@ pub fn build_item_from_guardian_event( Some(ThreadItem::CommandExecution { id: id.clone(), command, - cwd: cwd.clone(), + cwd: cwd.clone().into(), process_id: None, source: CommandExecutionSource::Agent, status, @@ -315,3 +352,7 @@ fn format_file_change_diff(change: &FileChange) -> String { } } } + +#[cfg(test)] +#[path = "item_builders_tests.rs"] +mod tests; diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs b/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs new file mode 100644 index 0000000000..b892fcf505 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs @@ -0,0 +1,41 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn foreign_read_is_omitted_without_dropping_other_command_actions() { + #[cfg(windows)] + let cwd = PathUri::parse("file:///usr/local/src").expect("valid foreign POSIX cwd"); + #[cfg(not(windows))] + let cwd = PathUri::parse("file:///C:/src").expect("valid foreign Windows cwd"); + let parsed_cmd = vec![ + ParsedCommand::Read { + cmd: "cat file.txt".to_string(), + name: "file.txt".to_string(), + path: PathBuf::from("file.txt"), + }, + ParsedCommand::ListFiles { + cmd: "ls".to_string(), + path: Some("subdir".to_string()), + }, + ParsedCommand::Search { + cmd: "rg needle".to_string(), + query: Some("needle".to_string()), + path: Some("src".to_string()), + }, + ]; + + assert_eq!( + command_actions_for_path_uri(&parsed_cmd, &cwd), + vec![ + CommandAction::ListFiles { + command: "ls".to_string(), + path: Some("subdir".to_string()), + }, + CommandAction::Search { + command: "rg needle".to_string(), + query: Some("needle".to_string()), + path: Some("src".to_string()), + }, + ] + ); +} diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 90a6f36ad5..001fa7cf93 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -2376,7 +2376,7 @@ mod tests { turn_id: "turn-1".into(), completed_at_ms: 0, command: vec!["echo".into(), "hello world".into()], - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo hello world".into(), }], @@ -2427,7 +2427,7 @@ mod tests { ThreadItem::CommandExecution { id: "exec-1".into(), command: "echo 'hello world'".into(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: Some("pid-1".into()), source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Completed, @@ -2616,7 +2616,7 @@ mod tests { turn_id: "turn-1".into(), completed_at_ms: 0, command: vec!["ls".into()], - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "ls".into() }], source: ExecCommandSource::Agent, interaction_input: None, @@ -2658,7 +2658,7 @@ mod tests { ThreadItem::CommandExecution { id: "exec-declined".into(), command: "ls".into(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: Some("pid-2".into()), source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Declined, @@ -2756,7 +2756,7 @@ mod tests { ThreadItem::CommandExecution { id: "guardian-exec".into(), command: "rm -rf /tmp/guardian".into(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Declined, @@ -2822,7 +2822,7 @@ mod tests { ThreadItem::CommandExecution { id: "guardian-execve".into(), command: "/bin/rm -f /tmp/file.sqlite".into(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::InProgress, @@ -2882,7 +2882,7 @@ mod tests { turn_id: "turn-a".into(), completed_at_ms: 0, command: vec!["echo".into(), "done".into()], - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo done".into(), }], @@ -2920,7 +2920,7 @@ mod tests { ThreadItem::CommandExecution { id: "exec-late".into(), command: "echo done".into(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: Some("pid-42".into()), source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Completed, @@ -2980,7 +2980,7 @@ mod tests { turn_id: "turn-missing".into(), completed_at_ms: 0, command: vec!["echo".into(), "done".into()], - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo done".into(), }], diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index 5638e3bd41..34cfe8e136 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -31,6 +31,7 @@ use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; use codex_protocol::protocol::ReviewDecision as CoreReviewDecision; use codex_protocol::protocol::SubAgentActivityKind as CoreSubAgentActivityKind; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -256,7 +257,7 @@ pub enum ThreadItem { /// The command to be executed. command: String, /// The command's working directory. - cwd: AbsolutePathBuf, + cwd: LegacyAppPathString, /// Identifier for the underlying PTY process (when available). process_id: Option, #[serde(default)] @@ -1339,7 +1340,7 @@ pub struct CommandExecutionRequestApprovalParams { /// The command's working directory. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] - pub cwd: Option, + pub cwd: Option, /// Best-effort parsed command actions for friendly display. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 3b5e7d8a3f..0be6e68bb2 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -2092,7 +2092,7 @@ impl CodexClient { println!("< command: {command}"); } if let Some(cwd) = cwd.as_ref() { - println!("< cwd: {}", cwd.display()); + println!("< cwd: {cwd}"); } if let Some(command_actions) = command_actions.as_ref() && !command_actions.is_empty() diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ed135271a9..a77b0f4664 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -113,6 +113,7 @@ use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestU use codex_sandboxing::policy_transforms::intersect_permission_profiles; use codex_shell_command::parse_command::shlex_join; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; use std::collections::HashMap; use std::sync::Arc; use std::time::SystemTime; @@ -129,7 +130,7 @@ enum CommandExecutionApprovalPresentation { #[derive(Debug, PartialEq)] struct CommandExecutionCompletionItem { command: String, - cwd: AbsolutePathBuf, + cwd: LegacyAppPathString, command_actions: Vec, } @@ -575,7 +576,7 @@ pub(crate) async fn apply_bespoke_event_handling( let command_string = shlex_join(&command); let completion_item = CommandExecutionCompletionItem { command: command_string, - cwd: cwd.clone(), + cwd: cwd.clone().into(), command_actions: command_actions.clone(), }; CommandExecutionApprovalPresentation::Command(completion_item) @@ -1354,7 +1355,7 @@ async fn start_command_execution_item( turn_id: String, item_id: String, command: String, - cwd: AbsolutePathBuf, + cwd: LegacyAppPathString, command_actions: Vec, source: CommandExecutionSource, outgoing: &ThreadScopedOutgoingMessageSender, @@ -1398,7 +1399,7 @@ async fn complete_command_execution_item( turn_id: String, item_id: String, command: String, - cwd: AbsolutePathBuf, + cwd: LegacyAppPathString, process_id: Option, source: CommandExecutionSource, command_actions: Vec, @@ -2323,7 +2324,7 @@ mod tests { fn command_execution_completion_item(command: &str) -> CommandExecutionCompletionItem { CommandExecutionCompletionItem { command: command.to_string(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), command_actions: vec![V2ParsedCommand::Unknown { command: command.to_string(), }], diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index b5295b9b74..8c202a806d 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -553,8 +553,7 @@ fn resolve_turn_environment_selections( let environment_id = environment.environment_id; let cwd = environment .cwd - .infer_absolute_path_convention() - .and_then(|convention| environment.cwd.to_path_uri(convention).ok()) + .to_inferred_path_uri() .ok_or_else(|| { invalid_request(format!( "invalid cwd for environment `{environment_id}`: path `{}` does not use absolute POSIX or Windows path syntax", diff --git a/codex-rs/app-server/src/transport_tests.rs b/codex-rs/app-server/src/transport_tests.rs index 97372d6ae1..439284a176 100644 --- a/codex-rs/app-server/src/transport_tests.rs +++ b/codex-rs/app-server/src/transport_tests.rs @@ -254,7 +254,7 @@ async fn command_execution_request_approval_strips_additional_permissions_withou reason: Some("Need extra read access".to_string()), network_approval_context: None, command: Some("cat file".to_string()), - cwd: Some(absolute_path("/tmp")), + cwd: Some(absolute_path("/tmp").into()), command_actions: None, additional_permissions: Some( codex_app_server_protocol::AdditionalPermissionProfile { @@ -320,7 +320,7 @@ async fn command_execution_request_approval_keeps_additional_permissions_with_ca reason: Some("Need extra read access".to_string()), network_approval_context: None, command: Some("cat file".to_string()), - cwd: Some(absolute_path("/tmp")), + cwd: Some(absolute_path("/tmp").into()), command_actions: None, additional_permissions: Some( codex_app_server_protocol::AdditionalPermissionProfile { 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 dca58867c8..f577a01fd2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -2440,7 +2440,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { else { unreachable!("loop ensures we break on command execution items"); }; - assert_eq!(cwd.as_path(), second_cwd.as_path()); + assert_eq!(cwd.as_str(), second_cwd.to_string_lossy().as_ref()); let expected_command = format_with_current_shell_display("echo second turn"); assert_eq!(command, expected_command); assert_eq!(status, CommandExecutionStatus::InProgress); diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index 15072c1ccf..c6f8f0e198 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -167,7 +167,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { assert!(command.contains("/bin/sh -c")); assert!(command.contains("sleep 0.01")); assert!(command.contains(&release_marker.display().to_string())); - assert_eq!(cwd.as_path(), workspace.as_path()); + assert_eq!(cwd.as_str(), workspace.to_string_lossy().as_ref()); mcp.interrupt_turn_and_wait_for_aborted(thread.id, turn.id, DEFAULT_READ_TIMEOUT) .await?; diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 057718b9f7..aea7500247 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -188,7 +188,7 @@ pub(crate) async fn execute_user_shell_command( turn_id: turn_context.sub_id.clone(), started_at_ms: now_unix_timestamp_ms(), command: display_command.clone(), - cwd: cwd.clone(), + cwd: cwd.clone().into(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -262,7 +262,7 @@ pub(crate) async fn execute_user_shell_command( turn_id: turn_context.sub_id.clone(), completed_at_ms: now_unix_timestamp_ms(), command: display_command.clone(), - cwd: cwd.clone(), + cwd: cwd.clone().into(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -287,7 +287,7 @@ pub(crate) async fn execute_user_shell_command( turn_id: turn_context.sub_id.clone(), completed_at_ms: now_unix_timestamp_ms(), command: display_command.clone(), - cwd: cwd.clone(), + cwd: cwd.clone().into(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -332,7 +332,7 @@ pub(crate) async fn execute_user_shell_command( turn_id: turn_context.sub_id.clone(), completed_at_ms: now_unix_timestamp_ms(), command: display_command, - cwd, + cwd: cwd.into(), parsed_cmd, source: ExecCommandSource::UserShell, interaction_input: None, diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index bb385f37b5..8034dfd627 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -96,7 +96,7 @@ fn tracker_update_for_known_delta<'a>( pub(crate) async fn emit_exec_command_begin( ctx: ToolEventCtx<'_>, command: &[String], - cwd: &AbsolutePathBuf, + cwd: &PathUri, parsed_cmd: &[ParsedCommand], source: ExecCommandSource, interaction_input: Option, @@ -123,7 +123,7 @@ pub(crate) async fn emit_exec_command_begin( pub(crate) enum ToolEmitter { Shell { command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, source: ExecCommandSource, parsed_cmd: Vec, }, @@ -146,7 +146,7 @@ impl ToolEmitter { let parsed_cmd = parse_command(&command); Self::Shell { command, - cwd, + cwd: PathUri::from_abs_path(&cwd), source, parsed_cmd, } @@ -321,15 +321,11 @@ impl ToolEmitter { }, stage, ) => { - // TODO(anp): Migrate exec command protocol events to PathUri. - let Ok(cwd) = cwd.to_abs_path() else { - return; - }; emit_exec_stage( ctx, ExecCommandInput::new( command, - &cwd, + cwd, parsed_cmd, *source, /*interaction_input*/ None, @@ -437,7 +433,7 @@ impl ToolEmitter { struct ExecCommandInput<'a> { command: &'a [String], - cwd: &'a AbsolutePathBuf, + cwd: &'a PathUri, parsed_cmd: &'a [ParsedCommand], source: ExecCommandSource, interaction_input: Option<&'a str>, @@ -447,7 +443,7 @@ struct ExecCommandInput<'a> { impl<'a> ExecCommandInput<'a> { fn new( command: &'a [String], - cwd: &'a AbsolutePathBuf, + cwd: &'a PathUri, parsed_cmd: &'a [ParsedCommand], source: ExecCommandSource, interaction_input: Option<&'a str>, diff --git a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs index 593dd7ea05..ae3c9406a9 100644 --- a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs +++ b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs @@ -20,6 +20,7 @@ use codex_features::Feature; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecCommandStatus; use codex_protocol::protocol::Op; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::TurnEnvironmentSelections; @@ -127,10 +128,6 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { }) .await?; - // TODO(anp): Re-enable these event assertions once exec command events retain a - // PathUri cwd. Today the host-native event conversion drops begin/end events for a - // foreign cwd. - /* let mut begin = None; let mut end = None; let mut turn_complete = false; @@ -161,13 +158,9 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { assert_eq!(&begin.command[1..], ["-NoProfile", "-Command", COMMAND]); let end = end.context("exec_command should emit an end event")?; + let expected_cwd = PathUri::parse("file:///C:/windows")?; + assert_eq!((&begin.cwd, &end.cwd), (&expected_cwd, &expected_cwd)); assert_eq!((end.exit_code, end.status), (0, ExecCommandStatus::Completed)); - */ - - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; let request = response_mock .last_request() diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 7ac0774316..8187fc0cb1 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -437,7 +437,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { assert_command(&begin_event.command, "-lc", "/bin/echo hello unified exec"); - assert_eq!(begin_event.cwd.as_path(), cwd.as_path()); + assert_eq!(begin_event.cwd, PathUri::from_path(&cwd)?); wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -507,8 +507,8 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { .await; assert_eq!( - begin_event.cwd.as_path(), - workdir.as_path(), + begin_event.cwd, + PathUri::from_path(&workdir)?, "exec_command cwd should resolve relative workdir against turn cwd", ); @@ -569,8 +569,8 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { .await; assert_eq!( - begin_event.cwd.as_path(), - workdir.as_path(), + begin_event.cwd, + PathUri::from_path(&workdir)?, "exec_command cwd should reflect the requested workdir override" ); diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 60667bb5df..a667cdd53d 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -68,10 +68,9 @@ impl EventProcessorWithHumanOutput { match item { ThreadItem::CommandExecution { command, cwd, .. } => { eprintln!( - "{}\n{} in {}", + "{}\n{} in {cwd}", "exec".style(self.italic).style(self.magenta), command.style(self.bold), - cwd.display() ); } ThreadItem::McpToolCall { server, tool, .. } => { 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 2cee9b0e83..0f88670940 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -170,7 +170,7 @@ fn command_execution_started_and_completed_translate_to_thread_events() { let command_item = ThreadItem::CommandExecution { id: "cmd-1".to_string(), command: "ls".to_string(), - cwd: test_path_buf("/tmp/project").abs(), + cwd: test_path_buf("/tmp/project").abs().into(), process_id: Some("123".to_string()), source: CommandExecutionSource::UserShell, status: ApiCommandExecutionStatus::InProgress, @@ -210,7 +210,7 @@ fn command_execution_started_and_completed_translate_to_thread_events() { item: ThreadItem::CommandExecution { id: "cmd-1".to_string(), command: "ls".to_string(), - cwd: test_path_buf("/tmp/project").abs(), + cwd: test_path_buf("/tmp/project").abs().into(), process_id: Some("123".to_string()), source: CommandExecutionSource::UserShell, status: ApiCommandExecutionStatus::Completed, @@ -1321,7 +1321,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() { item: ThreadItem::CommandExecution { id: "cmd-1".to_string(), command: "ls".to_string(), - cwd: test_path_buf("/tmp/project").abs(), + cwd: test_path_buf("/tmp/project").abs().into(), process_id: Some("123".to_string()), source: CommandExecutionSource::UserShell, status: ApiCommandExecutionStatus::InProgress, @@ -1361,7 +1361,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() { items: vec![ThreadItem::CommandExecution { id: "cmd-1".to_string(), command: "ls".to_string(), - cwd: test_path_buf("/tmp/project").abs(), + cwd: test_path_buf("/tmp/project").abs().into(), process_id: Some("123".to_string()), source: CommandExecutionSource::UserShell, status: ApiCommandExecutionStatus::Completed, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e77590c5e4..d51a30addf 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3246,7 +3246,7 @@ pub struct ExecCommandBeginEvent { /// The command to be executed. pub command: Vec, /// The command's working directory if not the default cwd for the agent. - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, pub parsed_cmd: Vec, /// Where the command originated. Defaults to Agent for backward compatibility. #[serde(default)] @@ -3272,7 +3272,7 @@ pub struct ExecCommandEndEvent { /// The command that was executed. pub command: Vec, /// The command's working directory if not the default cwd for the agent. - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, pub parsed_cmd: Vec, /// Where the command originated. Defaults to Agent for backward compatibility. #[serde(default)] diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index bbba245307..a998c98fc0 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -24,6 +24,7 @@ use codex_protocol::protocol::PatchApplyStatus; use codex_protocol::protocol::SubAgentActivityEvent; use codex_protocol::protocol::TurnAbortReason; use serde::Serialize; +use std::time::Duration; use crate::AgentThreadId; use crate::CodexTurnId; @@ -120,8 +121,12 @@ impl Serialize for ToolRuntimePayload<'_> { S: serde::Serializer, { match self { - ToolRuntimePayload::ExecCommandBegin(event) => event.serialize(serializer), - ToolRuntimePayload::ExecCommandEnd(event) => event.serialize(serializer), + ToolRuntimePayload::ExecCommandBegin(event) => { + ExecCommandBeginTracePayload::from(*event).serialize(serializer) + } + ToolRuntimePayload::ExecCommandEnd(event) => { + ExecCommandEndTracePayload::from(*event).serialize(serializer) + } ToolRuntimePayload::PatchApplyBegin(event) => event.serialize(serializer), ToolRuntimePayload::PatchApplyEnd(event) => event.serialize(serializer), ToolRuntimePayload::McpToolCallBegin(event) => event.serialize(serializer), @@ -139,6 +144,119 @@ impl Serialize for ToolRuntimePayload<'_> { } } +/// Rollout-trace representation of an exec begin event. +/// +/// Rollout traces share the rollout compatibility requirement that paths remain path-flavored +/// strings on disk, even though live events carry `PathUri` internally. +#[derive(Serialize)] +struct ExecCommandBeginTracePayload<'a> { + call_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + process_id: Option<&'a str>, + turn_id: &'a str, + started_at_ms: i64, + command: &'a [String], + cwd: String, + parsed_cmd: &'a [codex_protocol::parse_command::ParsedCommand], + source: ExecCommandSource, + #[serde(skip_serializing_if = "Option::is_none")] + interaction_input: Option<&'a str>, +} + +impl<'a> From<&'a ExecCommandBeginEvent> for ExecCommandBeginTracePayload<'a> { + fn from(event: &'a ExecCommandBeginEvent) -> Self { + let ExecCommandBeginEvent { + call_id, + process_id, + turn_id, + started_at_ms, + command, + cwd, + parsed_cmd, + source, + interaction_input, + } = event; + Self { + call_id, + process_id: process_id.as_deref(), + turn_id, + started_at_ms: *started_at_ms, + command, + cwd: cwd.inferred_native_path_string(), + parsed_cmd, + source: *source, + interaction_input: interaction_input.as_deref(), + } + } +} + +/// Rollout-trace representation of an exec end event. +/// +/// Like [`ExecCommandBeginTracePayload`], this renders `cwd` as an inferred native path to preserve +/// the on-disk format rather than serializing the internal `PathUri`. +#[derive(Serialize)] +struct ExecCommandEndTracePayload<'a> { + call_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + process_id: Option<&'a str>, + turn_id: &'a str, + completed_at_ms: i64, + command: &'a [String], + cwd: String, + parsed_cmd: &'a [codex_protocol::parse_command::ParsedCommand], + source: ExecCommandSource, + #[serde(skip_serializing_if = "Option::is_none")] + interaction_input: Option<&'a str>, + stdout: &'a str, + stderr: &'a str, + aggregated_output: &'a str, + exit_code: i32, + duration: Duration, + formatted_output: &'a str, + status: &'a ExecCommandStatus, +} + +impl<'a> From<&'a ExecCommandEndEvent> for ExecCommandEndTracePayload<'a> { + fn from(event: &'a ExecCommandEndEvent) -> Self { + let ExecCommandEndEvent { + call_id, + process_id, + turn_id, + completed_at_ms, + command, + cwd, + parsed_cmd, + source, + interaction_input, + stdout, + stderr, + aggregated_output, + exit_code, + duration, + formatted_output, + status, + } = event; + Self { + call_id, + process_id: process_id.as_deref(), + turn_id, + completed_at_ms: *completed_at_ms, + command, + cwd: cwd.inferred_native_path_string(), + parsed_cmd, + source: *source, + interaction_input: interaction_input.as_deref(), + stdout, + stderr, + aggregated_output, + exit_code: *exit_code, + duration: *duration, + formatted_output, + status, + } + } +} + pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option> { match event { EventMsg::ExecCommandBegin(event) if event.source != ExecCommandSource::UserShell => { diff --git a/codex-rs/rollout-trace/src/protocol_event_tests.rs b/codex-rs/rollout-trace/src/protocol_event_tests.rs index b18c7200ac..200c35d94d 100644 --- a/codex-rs/rollout-trace/src/protocol_event_tests.rs +++ b/codex-rs/rollout-trace/src/protocol_event_tests.rs @@ -1,10 +1,15 @@ use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecCommandBeginEvent; +use codex_protocol::protocol::ExecCommandEndEvent; +use codex_protocol::protocol::ExecCommandSource; +use codex_protocol::protocol::ExecCommandStatus; use codex_protocol::protocol::SubAgentActivityEvent; use codex_protocol::protocol::SubAgentActivityKind; use pretty_assertions::assert_eq; use serde_json::json; +use std::time::Duration; use super::ToolRuntimeTraceEvent; use super::tool_runtime_trace_event; @@ -44,3 +49,81 @@ fn sub_agent_activity_is_a_terminal_tool_runtime_event() -> anyhow::Result<()> { ); Ok(()) } + +#[test] +fn exec_command_trace_payloads_use_inferred_native_cwd() -> anyhow::Result<()> { + // Convention inference depends on the URI spelling, not the test host, so exercise both + // Windows and POSIX paths on every platform. + let begin = EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "call-begin".to_string(), + process_id: Some("process-1".to_string()), + turn_id: "turn-1".to_string(), + started_at_ms: 1234, + command: vec!["pwd".to_string()], + cwd: "file:///C:/windows".parse()?, + parsed_cmd: Vec::new(), + source: ExecCommandSource::Agent, + interaction_input: None, + }); + let end = EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "call-end".to_string(), + process_id: None, + turn_id: "turn-1".to_string(), + completed_at_ms: 2345, + command: vec!["pwd".to_string()], + cwd: "file:///workspace/project".parse()?, + parsed_cmd: Vec::new(), + source: ExecCommandSource::UnifiedExecInteraction, + interaction_input: Some("input".to_string()), + stdout: "output".to_string(), + stderr: String::new(), + aggregated_output: "output".to_string(), + exit_code: 0, + duration: Duration::from_millis(250), + formatted_output: "output".to_string(), + status: ExecCommandStatus::Completed, + }); + + let Some(ToolRuntimeTraceEvent::Started { payload, .. }) = tool_runtime_trace_event(&begin) + else { + panic!("expected started tool runtime event"); + }; + assert_eq!( + serde_json::to_value(payload)?, + json!({ + "call_id": "call-begin", + "process_id": "process-1", + "turn_id": "turn-1", + "started_at_ms": 1234, + "command": ["pwd"], + "cwd": r"C:\windows", + "parsed_cmd": [], + "source": "agent" + }) + ); + + let Some(ToolRuntimeTraceEvent::Ended { payload, .. }) = tool_runtime_trace_event(&end) else { + panic!("expected ended tool runtime event"); + }; + assert_eq!( + serde_json::to_value(payload)?, + json!({ + "call_id": "call-end", + "turn_id": "turn-1", + "completed_at_ms": 2345, + "command": ["pwd"], + "cwd": "/workspace/project", + "parsed_cmd": [], + "source": "unified_exec_interaction", + "interaction_input": "input", + "stdout": "output", + "stderr": "", + "aggregated_output": "output", + "exit_code": 0, + "duration": {"secs": 0, "nanos": 250000000}, + "formatted_output": "output", + "status": "completed" + }) + ); + Ok(()) +} diff --git a/codex-rs/tui/src/app/agent_status_feed_tests.rs b/codex-rs/tui/src/app/agent_status_feed_tests.rs index 5b8bc4b59b..d7583d2b44 100644 --- a/codex-rs/tui/src/app/agent_status_feed_tests.rs +++ b/codex-rs/tui/src/app/agent_status_feed_tests.rs @@ -12,7 +12,9 @@ fn agent_status_uses_bounded_buffered_activity() { item: ThreadItem::CommandExecution { id: "command-1".to_string(), command: "cargo test -p codex-tui".to_string(), - cwd: AbsolutePathBuf::try_from("/workspace").expect("absolute path"), + cwd: AbsolutePathBuf::try_from("/workspace") + .expect("absolute path") + .into(), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Completed, diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 406cd687ff..d204105356 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -619,7 +619,7 @@ mod tests { reason: None, network_approval_context: None, command: Some("echo hi".to_string()), - cwd: Some(test_path_buf("/tmp").abs()), + cwd: Some(test_path_buf("/tmp").abs().into()), command_actions: None, additional_permissions: None, proposed_execpolicy_amendment: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 0d4ef9ee3d..51151206fa 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4697,7 +4697,7 @@ fn exec_approval_request( reason: Some("needs approval".to_string()), network_approval_context: None, command: Some("echo hello".to_string()), - cwd: Some(test_path_buf("/tmp/project").abs()), + cwd: Some(test_path_buf("/tmp/project").abs().into()), command_actions: None, additional_permissions: None, proposed_execpolicy_amendment: None, diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 6b76a65186..00b7fcdedb 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -492,7 +492,7 @@ mod tests { reason: Some("needs approval".to_string()), network_approval_context: None, command: Some("echo hello".to_string()), - cwd: Some(test_path_buf("/tmp/project").abs()), + cwd: Some(test_path_buf("/tmp/project").abs().into()), command_actions: None, additional_permissions: None, proposed_execpolicy_amendment: None, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ab6d493d49..94cdd9e03d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -840,6 +840,12 @@ fn exec_approval_request_from_params( params: CommandExecutionRequestApprovalParams, fallback_cwd: &AbsolutePathBuf, ) -> ExecApprovalRequestEvent { + // TODO(anp): Keep this as PathUri once `tui::approval_events::ExecApprovalRequestEvent` and + // approval rendering support foreign paths. + let cwd = params + .cwd + .and_then(|cwd| cwd.to_inferred_abs_path()) + .unwrap_or_else(|| fallback_cwd.clone()); ExecApprovalRequestEvent { call_id: params.item_id, command: params @@ -847,7 +853,7 @@ fn exec_approval_request_from_params( .as_deref() .map(split_command_string) .unwrap_or_default(), - cwd: params.cwd.unwrap_or_else(|| fallback_cwd.clone()), + cwd, reason: params.reason, network_approval_context: params.network_approval_context, additional_permissions: params.additional_permissions, diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index f8ecb3d0aa..7b4c23e273 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -177,7 +177,7 @@ mod tests { ThreadItem::CommandExecution { id: call_id.to_string(), command: "true".to_string(), - cwd: AbsolutePathBuf::current_dir().expect("current dir"), + cwd: AbsolutePathBuf::current_dir().expect("current dir").into(), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::InProgress, diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index f4fcc85e43..383ab43053 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -521,7 +521,7 @@ async fn live_app_server_command_execution_strips_shell_wrapper() { item: AppServerThreadItem::CommandExecution { id: "cmd-1".to_string(), command: command.clone(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: None, source: AppServerCommandExecutionSource::UserShell, status: AppServerCommandExecutionStatus::InProgress, @@ -543,7 +543,7 @@ async fn live_app_server_command_execution_strips_shell_wrapper() { item: AppServerThreadItem::CommandExecution { id: "cmd-1".to_string(), command, - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: None, source: AppServerCommandExecutionSource::UserShell, status: AppServerCommandExecutionStatus::Completed, diff --git a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs index 5c9b92b517..1ec9a9c381 100644 --- a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs +++ b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs @@ -65,7 +65,7 @@ fn app_server_exec_approval_request_splits_shell_wrapped_command() { shlex::try_join(["/bin/zsh", "-lc", script]) .expect("round-trippable shell wrapper"), ), - cwd: Some(test_path_buf("/tmp").abs()), + cwd: Some(test_path_buf("/tmp").abs().into()), command_actions: None, additional_permissions: None, proposed_execpolicy_amendment: None, @@ -107,7 +107,7 @@ fn app_server_exec_approval_request_preserves_permissions_context() { protocol: codex_app_server_protocol::NetworkApprovalProtocol::Socks5Tcp, }), command: Some("ls".to_string()), - cwd: Some(test_path_buf("/tmp").abs()), + cwd: Some(test_path_buf("/tmp").abs().into()), command_actions: None, additional_permissions: Some(AppServerAdditionalPermissionProfile { network: Some(AppServerAdditionalNetworkPermissions { diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 60eb2d891d..1b8a7beddb 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -64,7 +64,7 @@ fn app_server_exec_approval_request_splits_shell_wrapped_command() { shlex::try_join(["/bin/zsh", "-lc", script]) .expect("round-trippable shell wrapper"), ), - cwd: Some(test_path_buf("/tmp").abs()), + cwd: Some(test_path_buf("/tmp").abs().into()), command_actions: None, additional_permissions: None, proposed_execpolicy_amendment: None, @@ -363,7 +363,7 @@ async fn exec_end_without_begin_uses_event_command() { AppServerThreadItem::CommandExecution { id: "call-orphan".to_string(), command: codex_shell_command::parse_command::shlex_join(&command), - cwd, + cwd: cwd.into(), process_id: None, source: ExecCommandSource::Agent, status: AppServerCommandExecutionStatus::Completed, diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index dc32e8c7ba..9a50b37832 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -821,7 +821,7 @@ pub(super) fn begin_exec_with_source( let item = AppServerThreadItem::CommandExecution { id: call_id.to_string(), command: codex_shell_command::parse_command::shlex_join(&command), - cwd: chat.config.cwd.clone(), + cwd: chat.config.cwd.clone().into(), process_id: None, source, status: AppServerCommandExecutionStatus::InProgress, @@ -844,7 +844,7 @@ pub(super) fn begin_unified_exec_startup( let item = AppServerThreadItem::CommandExecution { id: call_id.to_string(), command: codex_shell_command::parse_command::shlex_join(&command), - cwd: chat.config.cwd.clone(), + cwd: chat.config.cwd.clone().into(), process_id: Some(process_id.to_string()), source: ExecCommandSource::UnifiedExecStartup, status: AppServerCommandExecutionStatus::InProgress, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 1667501335..d8c79f56b1 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -3614,7 +3614,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { AppServerThreadItem::CommandExecution { id: "c1".into(), command: codex_shell_command::parse_command::shlex_join(&command), - cwd: cwd.clone(), + cwd: cwd.clone().into(), process_id: None, source: ExecCommandSource::Agent, status: AppServerCommandExecutionStatus::InProgress, @@ -3629,7 +3629,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { AppServerThreadItem::CommandExecution { id: "c1".into(), command: codex_shell_command::parse_command::shlex_join(&command), - cwd, + cwd: cwd.into(), process_id: None, source: ExecCommandSource::Agent, status: AppServerCommandExecutionStatus::Completed, diff --git a/codex-rs/utils/path-uri/src/api_path_string.rs b/codex-rs/utils/path-uri/src/api_path_string.rs index 5fc204b178..3e33f316ca 100644 --- a/codex-rs/utils/path-uri/src/api_path_string.rs +++ b/codex-rs/utils/path-uri/src/api_path_string.rs @@ -70,11 +70,21 @@ impl LegacyAppPathString { PathUri::from_absolute_native_path(&self.0, convention).ok_or_else(|| { LegacyAppPathStringError::InvalidNativePath { path: self.0.clone(), - convention, + convention: Some(convention), } }) } + /// Parses this API string as an absolute path using the convention inferred from its spelling. + pub fn to_inferred_path_uri(&self) -> Option { + PathUri::try_from(self.clone()).ok() + } + + /// Parses this API string as a host-native absolute path. + pub fn to_inferred_abs_path(&self) -> Option { + AbsolutePathBuf::try_from(self.clone()).ok() + } + /// Infers the path convention of an absolute API path from its spelling. /// /// Relative paths and ambiguous spellings return `None`. In particular, @@ -111,6 +121,44 @@ impl From for LegacyAppPathString { } } +impl From for LegacyAppPathString { + fn from(path: PathUri) -> Self { + Self(path.inferred_native_path_string()) + } +} + +impl TryFrom for PathUri { + type Error = LegacyAppPathStringError; + + fn try_from(path: LegacyAppPathString) -> Result { + let Some(convention) = path.infer_absolute_path_convention() else { + return Err(LegacyAppPathStringError::InvalidNativePath { + path: path.0, + convention: None, + }); + }; + PathUri::from_absolute_native_path(path.as_str(), convention).ok_or({ + LegacyAppPathStringError::InvalidNativePath { + path: path.0, + convention: Some(convention), + } + }) + } +} + +impl TryFrom for AbsolutePathBuf { + type Error = LegacyAppPathStringError; + + fn try_from(path: LegacyAppPathString) -> Result { + AbsolutePathBuf::from_absolute_path_checked(path.as_str()).map_err(|_| { + LegacyAppPathStringError::InvalidNativePath { + path: path.0, + convention: None, + } + }) + } +} + fn render_opaque_fallback( path: &PathUri, path_bytes: &[u8], @@ -279,10 +327,13 @@ pub enum LegacyAppPathStringError { path: String, convention: PathConvention, }, - #[error("path `{path}` is not absolute using {convention} path syntax")] + #[error( + "path `{path}` is not absolute{convention}", + convention = .convention.map(|convention| format!(" using {convention} path syntax")).unwrap_or_default() + )] InvalidNativePath { path: String, - convention: PathConvention, + convention: Option, }, } diff --git a/codex-rs/utils/path-uri/src/api_path_string_tests.rs b/codex-rs/utils/path-uri/src/api_path_string_tests.rs index 42829a783e..3439ca11ea 100644 --- a/codex-rs/utils/path-uri/src/api_path_string_tests.rs +++ b/codex-rs/utils/path-uri/src/api_path_string_tests.rs @@ -369,7 +369,21 @@ fn relative_api_path_is_invalid_when_converted_to_a_path_uri() { path.to_path_uri(PathConvention::Posix), Err(LegacyAppPathStringError::InvalidNativePath { path: raw_path.to_string(), - convention: PathConvention::Posix, + convention: Some(PathConvention::Posix), + }) + ); + assert_eq!( + PathUri::try_from(path.clone()), + Err(LegacyAppPathStringError::InvalidNativePath { + path: raw_path.to_string(), + convention: None, + }) + ); + assert_eq!( + AbsolutePathBuf::try_from(path), + Err(LegacyAppPathStringError::InvalidNativePath { + path: raw_path.to_string(), + convention: None, }) ); } @@ -388,7 +402,7 @@ fn other_non_absolute_api_paths_cannot_be_converted_to_path_uris() { path.to_path_uri(convention), Err(LegacyAppPathStringError::InvalidNativePath { path: raw_path.to_string(), - convention, + convention: Some(convention), }) ); } @@ -423,6 +437,51 @@ fn infers_absolute_path_conventions_from_api_text() { } } +#[test] +fn converts_absolute_api_paths_using_the_inferred_convention() { + for (raw_path, convention, expected_uri) in [ + ( + r"C:\workspace\file.rs", + PathConvention::Windows, + "file:///C:/workspace/file.rs", + ), + ( + "/workspace/file.rs", + PathConvention::Posix, + "file:///workspace/file.rs", + ), + ] { + let path = serde_json::from_value::(serde_json::json!(raw_path)) + .expect("absolute API path should deserialize"); + + assert_eq!( + path.to_inferred_path_uri(), + Some(PathUri::parse(expected_uri).expect("expected URI should parse")), + ); + assert_eq!( + PathUri::try_from(path.clone()), + path.to_path_uri(convention) + ); + } +} + +#[test] +fn converts_native_api_path_to_inferred_absolute_path() { + #[cfg(windows)] + let raw_path = r"C:\workspace\file.rs"; + #[cfg(not(windows))] + let raw_path = "/workspace/file.rs"; + let path = serde_json::from_value::(serde_json::json!(raw_path)) + .expect("absolute API path should deserialize"); + let expected = AbsolutePathBuf::try_from(raw_path).expect("native absolute path should parse"); + + assert_eq!( + AbsolutePathBuf::try_from(path.clone()), + Ok(expected.clone()) + ); + assert_eq!(path.to_inferred_abs_path(), Some(expected)); +} + #[test] fn foreign_absolute_syntax_deserializes_without_host_interpretation() { for (raw_path, convention) in [ diff --git a/codex-rs/utils/path-uri/src/tests.rs b/codex-rs/utils/path-uri/src/tests.rs index 6e7dc7294d..4ccb9e9c81 100644 --- a/codex-rs/utils/path-uri/src/tests.rs +++ b/codex-rs/utils/path-uri/src/tests.rs @@ -119,6 +119,11 @@ fn inferred_native_path_string_uses_the_inferred_convention() { expected, "rendering {uri}" ); + assert_eq!( + LegacyAppPathString::from(path).as_str(), + expected, + "rendering typed API path {uri}" + ); } } From 2c7802e7cf3ad53733ca9fb603f270debcca280f Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Wed, 17 Jun 2026 22:45:10 -0700 Subject: [PATCH 57/70] [codex] Split plugin and skill warmup tracing (#28605) ## What changed - promote plugin config loading to an info-level `plugins_for_config` span - promote skill config loading to an info-level `skills_for_config` span - attach stable OpenTelemetry names to both spans ## Why `session_init.plugin_skill_warmup` currently combines plugin loading and skill loading, which makes cold-start traces unable to identify which phase dominates. These child spans preserve the existing aggregate while making the two costs independently visible. Context: https://openai.slack.com/archives/C0ARA9GF5D4/p1781639496496439?thread_ts=1781202444.891669&cid=C0ARA9GF5D4 ## Impact This is observability-only. It does not change plugin or skill loading behavior. ## Validation - `just test -p codex-core-skills -p codex-core-plugins` (347 passed) - `just fmt` --- codex-rs/core-plugins/src/manager.rs | 9 +++++++-- codex-rs/core-skills/src/service.rs | 7 ++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index fb004f4943..04f2ff6132 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -466,9 +466,14 @@ impl PluginsManager { } #[instrument( - level = "trace", + name = "plugins_for_config", + level = "info", skip_all, - fields(force_reload, plugins_enabled = config.plugins_enabled) + fields( + otel.name = "plugins_for_config", + force_reload, + plugins_enabled = config.plugins_enabled + ) )] pub(crate) async fn plugins_for_config_with_force_reload( &self, diff --git a/codex-rs/core-skills/src/service.rs b/codex-rs/core-skills/src/service.rs index 29d34c3809..d20f5e39e4 100644 --- a/codex-rs/core-skills/src/service.rs +++ b/codex-rs/core-skills/src/service.rs @@ -105,7 +105,12 @@ impl SkillsService { /// This path uses a cache keyed by the effective skill-relevant config state rather than just /// cwd so role-local and session-local skill overrides cannot bleed across sessions that happen /// to share a directory. - #[instrument(level = "trace", skip_all)] + #[instrument( + name = "skills_for_config", + level = "info", + skip_all, + fields(otel.name = "skills_for_config") + )] pub async fn snapshot_for_config( &self, input: &SkillsLoadInput, From c73296a0f095e72dbb646909c613ae09c9459c3a Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Thu, 18 Jun 2026 00:16:46 -0700 Subject: [PATCH 58/70] [codex] Pass plugin namespace into skill loading (#28608) ## What changed - retain the parsed plugin manifest namespace on loaded plugins - carry that namespace through `PluginSkillRoot` and `SkillRoot` - use the provided namespace when qualifying plugin skill names - include the namespace in the skills cache key ## Why Plugin loading has already parsed `plugin.json`, but skill parsing currently walks every `SKILL.md` ancestor and probes/reads the manifest again to reconstruct the same namespace. Passing the parsed namespace removes those repeated filesystem calls, which are particularly costly on remote filesystems. Context: https://openai.slack.com/archives/C0ARA9GF5D4/p1781639496496439?thread_ts=1781202444.891669&cid=C0ARA9GF5D4 ## Impact Plugin skill names remain unchanged. A regression test uses a deliberately different on-disk manifest name to verify that plugin roots use the provided parsed namespace. ## Validation - `just test -p codex-core-skills -p codex-core-plugins -p codex-plugin -p codex-utils-plugins` (352 passed) - `just fix -p codex-core-skills -p codex-core-plugins -p codex-plugin -p codex-utils-plugins` - `just fmt` --- codex-rs/core-plugins/src/loader.rs | 10 +++-- codex-rs/core-plugins/src/manager.rs | 2 +- codex-rs/core-plugins/src/manager_tests.rs | 37 +++++++++++++------ codex-rs/core-skills/src/loader.rs | 29 ++++++++++++++- codex-rs/core-skills/src/loader_tests.rs | 14 ++++++- codex-rs/core-skills/src/service.rs | 9 ++++- codex-rs/core-skills/src/service_tests.rs | 10 ++++- codex-rs/ext/skills/src/provider/executor.rs | 1 + .../tests/executor_file_system_authority.rs | 1 + codex-rs/plugin/src/load_outcome.rs | 12 ++++++ codex-rs/utils/plugins/src/lib.rs | 1 + 11 files changed, 103 insertions(+), 23 deletions(-) diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 846487aba6..029be960b6 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -1,6 +1,7 @@ use crate::app_mcp_routing::apply_app_mcp_routing_policy; use crate::app_mcp_routing::apps_route_available; use crate::is_openai_curated_marketplace_name; +use crate::manifest::PluginManifest; use crate::manifest::PluginManifestHooks; use crate::manifest::PluginManifestMcpServers; use crate::manifest::PluginManifestPaths; @@ -664,6 +665,7 @@ async fn load_plugin( let mut loaded_plugin = LoadedPlugin { config_name, manifest_name: None, + plugin_namespace: None, manifest_description: None, root, enabled: plugin.enabled, @@ -706,6 +708,7 @@ async fn load_plugin( }; let manifest_paths = &manifest.paths; + loaded_plugin.plugin_namespace = Some(manifest.name.clone()); match scope { PluginLoadScope::AllCapabilities { restriction_product, @@ -717,7 +720,7 @@ async fn load_plugin( let resolved_skills = load_plugin_skills( &plugin_root, &loaded_plugin_id, - manifest_paths, + &manifest, *restriction_product, skill_config_rules, ) @@ -785,17 +788,18 @@ impl ResolvedPluginSkills { pub async fn load_plugin_skills( plugin_root: &AbsolutePathBuf, plugin_id: &PluginId, - manifest_paths: &PluginManifestPaths, + manifest: &PluginManifest, restriction_product: Option, skill_config_rules: &SkillConfigRules, ) -> ResolvedPluginSkills { - let roots = plugin_skill_roots(plugin_root, manifest_paths) + let roots = plugin_skill_roots(plugin_root, &manifest.paths) .into_iter() .map(|path| SkillRoot { path, scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some(plugin_id.as_key()), + plugin_namespace: Some(manifest.name.clone()), plugin_root: Some(plugin_root.clone()), }) .collect::>(); diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 04f2ff6132..b1bb508eeb 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -1591,7 +1591,7 @@ impl PluginsManager { let resolved_skills = load_plugin_skills( &source_path, &plugin_id, - &manifest.paths, + &manifest, self.restriction_product, &codex_core_skills::config_rules::skill_config_rules_from_stack( &config.config_layer_stack, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index b5d22911f9..f5073f68b0 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -623,6 +623,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() { vec![LoadedPlugin { config_name: "sample@test".to_string(), manifest_name: Some("sample".to_string()), + plugin_namespace: Some("sample".to_string()), manifest_description: Some( "Plugin that includes the sample MCP server and Skills".to_string(), ), @@ -1446,23 +1447,30 @@ async fn load_plugin_skills_dedupes_overlapping_manifest_roots() { &plugin_root.join("skills/edk/SKILL.md"), "---\nname: edk\ndescription: edk skill\n---\n", ); - let manifest_paths = crate::manifest::PluginManifestPaths { - skills: vec![ - plugin_root.join("skills"), - plugin_root.join("skills/abc"), - plugin_root.join("skills/edk"), - plugin_root.join("skills/abc"), - ], - mcp_servers: None, - apps: None, - hooks: None, + let manifest = crate::manifest::PluginManifest { + name: "sample".to_string(), + version: None, + description: None, + keywords: Vec::new(), + paths: crate::manifest::PluginManifestPaths { + skills: vec![ + plugin_root.join("skills"), + plugin_root.join("skills/abc"), + plugin_root.join("skills/edk"), + plugin_root.join("skills/abc"), + ], + mcp_servers: None, + apps: None, + hooks: None, + }, + interface: None, }; let plugin_id = PluginId::parse("sample@test").expect("plugin id should parse"); let resolved = load_plugin_skills( &plugin_root, &plugin_id, - &manifest_paths, + &manifest, /*restriction_product*/ None, &SkillConfigRules::default(), ) @@ -1677,6 +1685,7 @@ async fn load_plugins_preserves_disabled_plugins_without_effective_contributions vec![LoadedPlugin { config_name: "sample@test".to_string(), manifest_name: None, + plugin_namespace: None, manifest_description: None, root: AbsolutePathBuf::try_from(plugin_root).unwrap(), enabled: false, @@ -1845,6 +1854,12 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { let plugin = |config_name: &str, dir_name: &str, manifest_name: &str| LoadedPlugin { config_name: config_name.to_string(), manifest_name: Some(manifest_name.to_string()), + plugin_namespace: Some( + config_name + .split_once('@') + .map_or(config_name, |(name, _)| name) + .to_string(), + ), manifest_description: None, root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(), enabled: true, diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index e2877349db..64861c45eb 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -157,6 +157,7 @@ pub struct SkillRoot { pub scope: SkillScope, pub file_system: Arc, pub plugin_id: Option, + pub plugin_namespace: Option, pub plugin_root: Option, } @@ -178,6 +179,7 @@ where &root_path, root.scope, root.plugin_id.as_deref(), + root.plugin_namespace.as_deref(), root.plugin_root.as_ref(), &mut outcome, ) @@ -266,6 +268,7 @@ async fn skill_roots_with_home_dir( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some(root.plugin_id), + plugin_namespace: Some(root.plugin_namespace), plugin_root: Some(root.plugin_root), })); roots.extend(extra_skill_roots.into_iter().map(|path| SkillRoot { @@ -273,6 +276,7 @@ async fn skill_roots_with_home_dir( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, })); roots.extend(repo_agents_skill_roots(fs, config_layer_stack, cwd).await); @@ -303,6 +307,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::Repo, file_system: Arc::clone(repo_fs), plugin_id: None, + plugin_namespace: None, plugin_root: None, }); } @@ -315,6 +320,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }); @@ -325,6 +331,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }); } @@ -336,6 +343,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::System, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }); } @@ -347,6 +355,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::Admin, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }); } @@ -382,6 +391,7 @@ async fn repo_agents_skill_roots( scope: SkillScope::Repo, file_system: Arc::clone(&fs), plugin_id: None, + plugin_namespace: None, plugin_root: None, }), Ok(_) => {} @@ -490,6 +500,7 @@ async fn discover_skills_under_root( root: &AbsolutePathBuf, scope: SkillScope, plugin_id: Option<&str>, + plugin_namespace: Option<&str>, plugin_root: Option<&AbsolutePathBuf>, outcome: &mut SkillLoadOutcome, ) { @@ -610,7 +621,16 @@ async fn discover_skills_under_root( } if metadata.is_file && file_name == SKILLS_FILENAME { - match parse_skill_file(fs, &path, scope, plugin_id, plugin_root.as_ref()).await { + match parse_skill_file( + fs, + &path, + scope, + plugin_id, + plugin_namespace, + plugin_root.as_ref(), + ) + .await + { Ok(skill) => { outcome.skills.push(skill); } @@ -641,6 +661,7 @@ async fn parse_skill_file( path: &AbsolutePathBuf, scope: SkillScope, plugin_id: Option<&str>, + plugin_namespace: Option<&str>, plugin_root: Option<&AbsolutePathBuf>, ) -> Result { let path_uri = PathUri::from_abs_path(path); @@ -671,7 +692,7 @@ async fn parse_skill_file( .map(sanitize_single_line) .filter(|value| !value.is_empty()) .unwrap_or_else(|| default_skill_name(path)); - let name = namespaced_skill_name(fs, path, &base_name).await; + let name = namespaced_skill_name(fs, path, &base_name, plugin_namespace).await; let description = parsed .description .as_deref() @@ -731,7 +752,11 @@ async fn namespaced_skill_name( fs: &dyn ExecutorFileSystem, path: &AbsolutePathBuf, base_name: &str, + plugin_namespace: Option<&str>, ) -> String { + if let Some(plugin_namespace) = plugin_namespace { + return format!("{plugin_namespace}:{base_name}"); + } plugin_namespace_for_skill_path(fs, path) .await .map(|namespace| format!("{namespace}:{base_name}")) diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index cc1d8c8e04..3a6ae9b1c4 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -850,6 +850,7 @@ interface: scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some("twilio-developer-kit@test".to_string()), + plugin_namespace: None, plugin_root: Some(plugin_root_abs.clone()), }]) .await; @@ -907,6 +908,7 @@ interface: scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some("twilio-developer-kit@test".to_string()), + plugin_namespace: None, plugin_root: Some(plugin_root.abs()), }]) .await; @@ -1053,6 +1055,7 @@ async fn loads_skills_via_symlinked_subdir_for_admin_scope() { scope: SkillScope::Admin, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }]) .await; @@ -1135,6 +1138,7 @@ async fn system_scope_ignores_symlinked_subdir() { scope: SkillScope::System, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }]) .await; @@ -1169,6 +1173,7 @@ async fn respects_max_scan_depth_for_user_scope() { scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }]) .await; @@ -1256,7 +1261,7 @@ async fn falls_back_to_directory_name_when_skill_name_is_missing() { } #[tokio::test] -async fn namespaces_plugin_skills_using_plugin_name() { +async fn namespaces_plugin_skills_using_provided_namespace() { let root = tempfile::tempdir().expect("tempdir"); let plugin_root = root.path().join("plugins/sample"); let skill_path = write_raw_skill_at( @@ -1267,7 +1272,7 @@ async fn namespaces_plugin_skills_using_plugin_name() { fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); fs::write( plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, + r#"{"name":"should-not-be-read"}"#, ) .unwrap(); @@ -1276,6 +1281,7 @@ async fn namespaces_plugin_skills_using_plugin_name() { scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some("sample@test".to_string()), + plugin_namespace: Some("sample".to_string()), plugin_root: Some(plugin_root.abs()), }]) .await; @@ -1321,6 +1327,7 @@ async fn plugin_skill_name_length_limit_allows_max_qualified_name() { scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some("sample@test".to_string()), + plugin_namespace: Some(plugin_name.clone()), plugin_root: Some(plugin_root.abs()), }]) .await; @@ -1366,6 +1373,7 @@ async fn plugin_skill_name_length_limit_rejects_overlong_qualified_name() { scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some("sample@test".to_string()), + plugin_namespace: Some(plugin_name.clone()), plugin_root: Some(plugin_root.abs()), }]) .await; @@ -1805,6 +1813,7 @@ async fn deduplicates_by_path_preferring_first_root() { scope: SkillScope::Repo, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }, SkillRoot { @@ -1812,6 +1821,7 @@ async fn deduplicates_by_path_preferring_first_root() { scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }, ]) diff --git a/codex-rs/core-skills/src/service.rs b/codex-rs/core-skills/src/service.rs index d20f5e39e4..a6a1e30f57 100644 --- a/codex-rs/core-skills/src/service.rs +++ b/codex-rs/core-skills/src/service.rs @@ -256,7 +256,7 @@ impl SkillsService { #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct ConfigSkillsCacheKey { - roots: Vec<(AbsolutePathBuf, u8, Option)>, + roots: Vec<(AbsolutePathBuf, u8, Option, Option)>, skill_config_rules: SkillConfigRules, } @@ -296,7 +296,12 @@ fn config_skills_cache_key( SkillScope::System => 2, SkillScope::Admin => 3, }; - (root.path.clone(), scope_rank, root.plugin_id.clone()) + ( + root.path.clone(), + scope_rank, + root.plugin_id.clone(), + root.plugin_namespace.clone(), + ) }) .collect(), skill_config_rules: skill_config_rules.clone(), diff --git a/codex-rs/core-skills/src/service_tests.rs b/codex-rs/core-skills/src/service_tests.rs index bc05ebf350..23a15f7955 100644 --- a/codex-rs/core-skills/src/service_tests.rs +++ b/codex-rs/core-skills/src/service_tests.rs @@ -55,7 +55,11 @@ fn write_plugin_skill( skill_path } -fn plugin_skill_root_for_skill_path(skill_path: &Path, plugin_id: &str) -> PluginSkillRoot { +fn plugin_skill_root_for_skill_path( + skill_path: &Path, + plugin_id: &str, + plugin_namespace: &str, +) -> PluginSkillRoot { let skills_root = skill_path .parent() .and_then(Path::parent) @@ -66,6 +70,7 @@ fn plugin_skill_root_for_skill_path(skill_path: &Path, plugin_id: &str) -> Plugi PluginSkillRoot { path: skills_root.abs(), plugin_id: plugin_id.to_string(), + plugin_namespace: plugin_namespace.to_string(), plugin_root: plugin_root.abs(), } } @@ -364,7 +369,8 @@ async fn skills_for_config_disables_plugin_skills_by_name() { &codex_home, &name_toggle_config("sample:sample-search", /*enabled*/ false), ); - let plugin_skill_root = plugin_skill_root_for_skill_path(&skill_path, "test-plugin@test"); + let plugin_skill_root = + plugin_skill_root_for_skill_path(&skill_path, "test-plugin@test", "sample"); let skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, diff --git a/codex-rs/ext/skills/src/provider/executor.rs b/codex-rs/ext/skills/src/provider/executor.rs index 2d26ae8bed..8da0265d09 100644 --- a/codex-rs/ext/skills/src/provider/executor.rs +++ b/codex-rs/ext/skills/src/provider/executor.rs @@ -81,6 +81,7 @@ impl SkillProvider for ExecutorSkillProvider { scope: SkillScope::User, file_system: Arc::clone(&file_system), plugin_id: None, + plugin_namespace: None, plugin_root: None, }]) .await, diff --git a/codex-rs/ext/skills/tests/executor_file_system_authority.rs b/codex-rs/ext/skills/tests/executor_file_system_authority.rs index 1eff53c3b9..bc2a87a740 100644 --- a/codex-rs/ext/skills/tests/executor_file_system_authority.rs +++ b/codex-rs/ext/skills/tests/executor_file_system_authority.rs @@ -196,6 +196,7 @@ async fn skill_loading_and_reads_use_the_supplied_executor_file_system() { canonical_root: canonical_root.clone(), }), plugin_id: None, + plugin_namespace: None, plugin_root: None, }]) .await; diff --git a/codex-rs/plugin/src/load_outcome.rs b/codex-rs/plugin/src/load_outcome.rs index 95b98d863a..ad83655463 100644 --- a/codex-rs/plugin/src/load_outcome.rs +++ b/codex-rs/plugin/src/load_outcome.rs @@ -17,6 +17,7 @@ const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024; pub struct LoadedPlugin { pub config_name: String, pub manifest_name: Option, + pub plugin_namespace: Option, pub manifest_description: Option, pub root: AbsolutePathBuf, pub enabled: bool, @@ -126,11 +127,15 @@ impl PluginLoadOutcome { let mut skill_roots = Vec::new(); let mut seen_paths = HashSet::new(); for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) { + let Some(plugin_namespace) = &plugin.plugin_namespace else { + continue; + }; for path in &plugin.skill_roots { if seen_paths.insert(path.clone()) { skill_roots.push(PluginSkillRoot { path: path.clone(), plugin_id: plugin.config_name.clone(), + plugin_namespace: plugin_namespace.clone(), plugin_root: plugin.root.clone(), }); } @@ -218,6 +223,12 @@ mod tests { LoadedPlugin { config_name: config_name.to_string(), manifest_name: None, + plugin_namespace: Some( + config_name + .split_once('@') + .map_or(config_name, |(name, _)| name) + .to_string(), + ), manifest_description: None, root: test_path(config_name), enabled: true, @@ -245,6 +256,7 @@ mod tests { vec![PluginSkillRoot { path: shared_root, plugin_id: "zeta@test".to_string(), + plugin_namespace: "zeta".to_string(), plugin_root: test_path("zeta@test"), }] ); diff --git a/codex-rs/utils/plugins/src/lib.rs b/codex-rs/utils/plugins/src/lib.rs index fe178e679c..203b1f22e4 100644 --- a/codex-rs/utils/plugins/src/lib.rs +++ b/codex-rs/utils/plugins/src/lib.rs @@ -15,5 +15,6 @@ pub use plugin_namespace::plugin_namespace_for_skill_path; pub struct PluginSkillRoot { pub path: AbsolutePathBuf, pub plugin_id: String, + pub plugin_namespace: String, pub plugin_root: AbsolutePathBuf, } From ecc4c30e281a9dff77ab45e0365c73a2a526a520 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Thu, 18 Jun 2026 04:29:47 -0700 Subject: [PATCH 59/70] [codex] add rollout token budget configuration (varlength 1/N) (#28746) ## What This PR defines the structured configuration contract for shared rollout token budgets (across ALL agent threads under 1 rollout). ```toml [features.rollout_budget] enabled = true limit_tokens = 100000 reminder_interval_tokens = 10000 sampling_token_weight = 1.0 prefill_token_weight = 0.1 ``` The reminder interval defaults to 10% of the rollout limit. Sampling and prefill weights default to `1.0`. ## Scope This PR only defines and validates configuration. It does not track usage, inject reminders, or stop a rollout. Accounting and reminders are implemented in the stacked follow-up #28494. The existing `token_budget` feature remains unchanged. `rollout_budget` has its own feature key and configuration type. ## Tests The config test verifies that the structured fields resolve into `RolloutBudgetConfig` and do not enable the existing `token_budget` feature. Local checks: - `just write-config-schema` - `just test -p codex-core load_config_resolves_rollout_budget` - `cargo check -p codex-thread-manager-sample` - `git diff --check` The full workspace test suite was not run locally. --- codex-rs/config/src/schema.rs | 9 +++ codex-rs/core/config.schema.json | 45 ++++++++++++++ codex-rs/core/src/config/config_tests.rs | 61 ++++++++++++++++++ codex-rs/core/src/config/mod.rs | 72 ++++++++++++++++++++++ codex-rs/core/src/session/config_lock.rs | 30 +++++++++ codex-rs/features/src/feature_configs.rs | 29 +++++++++ codex-rs/features/src/lib.rs | 17 +++++ codex-rs/thread-manager-sample/src/main.rs | 1 + 8 files changed, 264 insertions(+) diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs index c641f1703d..6d3f901bf4 100644 --- a/codex-rs/config/src/schema.rs +++ b/codex-rs/config/src/schema.rs @@ -44,6 +44,15 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { ); continue; } + if feature.id == codex_features::Feature::RolloutBudget { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } if feature.id == codex_features::Feature::AppsMcpPathOverride { validation.properties.insert( feature.key.to_string(), diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 148901a704..852f6de39a 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -611,6 +611,9 @@ "responses_websockets_v2": { "type": "boolean" }, + "rollout_budget": { + "$ref": "#/definitions/FeatureToml_for_RolloutBudgetConfigToml" + }, "runtime_metrics": { "type": "boolean" }, @@ -908,6 +911,16 @@ } ] }, + "FeatureToml_for_RolloutBudgetConfigToml": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/RolloutBudgetConfigToml" + } + ] + }, "FeedbackConfigToml": { "additionalProperties": false, "properties": { @@ -2572,6 +2585,35 @@ } ] }, + "RolloutBudgetConfigToml": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "limit_tokens": { + "format": "int64", + "minimum": 1.0, + "type": "integer" + }, + "prefill_token_weight": { + "format": "double", + "minimum": 0.0, + "type": "number" + }, + "reminder_interval_tokens": { + "format": "int64", + "minimum": 1.0, + "type": "integer" + }, + "sampling_token_weight": { + "format": "double", + "minimum": 0.0, + "type": "number" + } + }, + "type": "object" + }, "SandboxMode": { "enum": [ "read-only", @@ -4772,6 +4814,9 @@ "responses_websockets_v2": { "type": "boolean" }, + "rollout_budget": { + "$ref": "#/definitions/FeatureToml_for_RolloutBudgetConfigToml" + }, "runtime_metrics": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index dd6169fbba..2244e3e1b7 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -461,6 +461,67 @@ direct_only_tool_namespaces = ["mcp__history", "mcp__notes"] Ok(()) } +#[tokio::test] +async fn load_config_resolves_rollout_budget() -> std::io::Result<()> { + let codex_home = tempdir()?; + let config_toml: ConfigToml = toml::from_str( + r#" +[features.rollout_budget] +enabled = true +limit_tokens = 100000 +reminder_interval_tokens = 10000 +sampling_token_weight = 1.0 +prefill_token_weight = 0.1 +"#, + ) + .expect("TOML deserialization should succeed"); + let config = Config::load_from_base_config_with_overrides( + config_toml, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert!(config.features.enabled(Feature::RolloutBudget)); + assert!(!config.features.enabled(Feature::TokenBudget)); + assert_eq!( + config.rollout_budget, + Some(RolloutBudgetConfig { + limit_tokens: 100_000, + reminder_interval_tokens: 10_000, + sampling_token_weight: 1.0, + prefill_token_weight: 0.1, + }) + ); + Ok(()) +} + +#[tokio::test] +async fn load_config_rejects_enabled_rollout_budget_without_limit() -> std::io::Result<()> { + for config_toml in [ + "[features]\nrollout_budget = true\n", + "[features.rollout_budget]\nenabled = true\n", + ] { + let codex_home = tempdir()?; + let config_toml: ConfigToml = + toml::from_str(config_toml).expect("TOML deserialization should succeed"); + let err = Config::load_from_base_config_with_overrides( + config_toml, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await + .expect_err("enabled rollout budget without limit_tokens should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "features.rollout_budget.limit_tokens is required when rollout_budget is enabled" + ); + } + Ok(()) +} + #[test] fn rejects_provider_auth_with_env_key() { let err = toml::from_str::( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a54137b58c..0427613361 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1020,6 +1020,9 @@ pub struct Config { /// Settings specific to the task-path-based multi-agent tool surface. pub multi_agent_v2: MultiAgentV2Config, + /// Shared token budget for the root thread and its sub-agents. + pub rollout_budget: Option, + /// Centralized feature flags; source of truth for feature gating. pub features: ManagedFeatures, @@ -1064,6 +1067,14 @@ pub struct CodeModeConfig { pub direct_only_tool_namespaces: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +pub struct RolloutBudgetConfig { + pub limit_tokens: i64, + pub reminder_interval_tokens: i64, + pub sampling_token_weight: f64, + pub prefill_token_weight: f64, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MultiAgentV2Config { pub max_concurrent_threads_per_session: usize, @@ -2469,6 +2480,65 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config } } +fn resolve_rollout_budget_config( + config_toml: &ConfigToml, + features: &ManagedFeatures, +) -> std::io::Result> { + if !features.enabled(Feature::RolloutBudget) { + return Ok(None); + } + let missing_limit_error = || { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.rollout_budget.limit_tokens is required when rollout_budget is enabled", + ) + }; + let Some(FeatureToml::Config(config)) = config_toml + .features + .as_ref() + .and_then(|features| features.rollout_budget.as_ref()) + else { + return Err(missing_limit_error()); + }; + let Some(limit_tokens) = config.limit_tokens else { + return Err(missing_limit_error()); + }; + if limit_tokens <= 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.rollout_budget.limit_tokens must be positive", + )); + } + let reminder_interval_tokens = config + .reminder_interval_tokens + .unwrap_or_else(|| (limit_tokens / 10).max(1)); + if reminder_interval_tokens <= 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.rollout_budget.reminder_interval_tokens must be positive", + )); + } + let sampling_token_weight = config.sampling_token_weight.unwrap_or(1.0); + let prefill_token_weight = config.prefill_token_weight.unwrap_or(1.0); + for (field, weight) in [ + ("sampling_token_weight", sampling_token_weight), + ("prefill_token_weight", prefill_token_weight), + ] { + if !weight.is_finite() || weight < 0.0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("features.rollout_budget.{field} must be finite and non-negative"), + )); + } + } + Ok(Some(RolloutBudgetConfig { + limit_tokens, + reminder_interval_tokens, + sampling_token_weight, + prefill_token_weight, + })) +} + fn resolve_terminal_resize_reflow_config(config_toml: &ConfigToml) -> TerminalResizeReflowConfig { let Some(tui) = config_toml.tui.as_ref() else { return TerminalResizeReflowConfig::default(); @@ -3146,6 +3216,7 @@ impl Config { resolve_experimental_request_user_input_enabled(&cfg); let code_mode = resolve_code_mode_config(&cfg); let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg); + let rollout_budget = resolve_rollout_budget_config(&cfg, &features)?; let terminal_resize_reflow = resolve_terminal_resize_reflow_config(&cfg); let agent_roles = @@ -3686,6 +3757,7 @@ impl Config { background_terminal_max_timeout, ghost_snapshot, multi_agent_v2, + rollout_budget, features, suppress_unstable_features_warning: cfg .suppress_unstable_features_warning diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index ec2056581c..b5f1689ad2 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -6,6 +6,7 @@ use codex_features::Feature; use codex_features::FeatureToml; use codex_features::FeaturesToml; use codex_features::MultiAgentV2ConfigToml; +use codex_features::RolloutBudgetConfigToml; use codex_protocol::ThreadId; use crate::config::Config; @@ -148,6 +149,12 @@ fn save_config_resolved_fields( resolved_config_to_toml(&config.multi_agent_v2, "features.multi_agent_v2")?; multi_agent_v2.enabled = Some(config.features.enabled(Feature::MultiAgentV2)); features.multi_agent_v2 = Some(FeatureToml::Config(multi_agent_v2)); + if let Some(rollout_budget) = config.rollout_budget.as_ref() { + let mut rollout_budget: RolloutBudgetConfigToml = + resolved_config_to_toml(rollout_budget, "features.rollout_budget")?; + rollout_budget.enabled = Some(config.features.enabled(Feature::RolloutBudget)); + features.rollout_budget = Some(FeatureToml::Config(rollout_budget)); + } lock_config.memories = Some(resolved_config_to_toml::( &config.memories, "memories", @@ -209,6 +216,18 @@ mod tests { #[tokio::test] async fn lock_contains_prompts_and_materializes_features() { let mut sc = crate::session::tests::make_session_configuration_for_tests().await; + let mut config = (*sc.original_config_do_not_use).clone(); + config.rollout_budget = Some(crate::config::RolloutBudgetConfig { + limit_tokens: 100_000, + reminder_interval_tokens: 10_000, + sampling_token_weight: 1.0, + prefill_token_weight: 0.25, + }); + config + .features + .enable(Feature::RolloutBudget) + .expect("rollout_budget should be enableable in tests"); + sc.original_config_do_not_use = Arc::new(config); sc.base_instructions = "resolved instructions".to_string(); sc.developer_instructions = Some("resolved developer instructions".to_string()); sc.compact_prompt = Some("resolved compact prompt".to_string()); @@ -273,6 +292,17 @@ mod tests { }) )); + assert_eq!( + features.rollout_budget, + Some(FeatureToml::Config(RolloutBudgetConfigToml { + enabled: Some(true), + limit_tokens: Some(100_000), + reminder_interval_tokens: Some(10_000), + sampling_token_weight: Some(1.0), + prefill_token_weight: Some(0.25), + })) + ); + assert_eq!(lockfile.version, crate::config_lock::CONFIG_LOCK_VERSION); } diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 0acb9b7d3c..ad4b6da63c 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -73,6 +73,35 @@ impl FeatureConfig for MultiAgentV2ConfigToml { } } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RolloutBudgetConfigToml { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + pub limit_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + pub reminder_interval_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0.0))] + pub sampling_token_weight: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0.0))] + pub prefill_token_weight: Option, +} + +impl FeatureConfig for RolloutBudgetConfigToml { + fn enabled(&self) -> Option { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = Some(enabled); + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct RemovedAppsMcpPathOverrideConfigToml { diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index b887237a18..731ea20a10 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -23,6 +23,7 @@ pub use feature_configs::NetworkProxyDomainPermissionToml; pub use feature_configs::NetworkProxyModeToml; pub use feature_configs::NetworkProxyUnixSocketPermissionToml; use feature_configs::RemovedAppsMcpPathOverrideConfigToml; +pub use feature_configs::RolloutBudgetConfigToml; use legacy::LegacyFeatureToggles; pub use legacy::legacy_feature_keys; @@ -203,6 +204,8 @@ pub enum Feature { Goals, /// Add current context-window metadata to model-visible context. TokenBudget, + /// Track and report a shared token budget across a session's agent threads. + RolloutBudget, /// Expose an input-interruptible sleep tool. SleepTool, /// Route MCP tool approval prompts through the MCP elicitation request path. @@ -618,6 +621,8 @@ pub struct FeaturesToml { pub code_mode: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub multi_agent_v2: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rollout_budget: Option>, #[serde(default, rename = "apps_mcp_path_override", skip_serializing)] #[schemars(skip)] removed_apps_mcp_path_override: Option>, @@ -650,6 +655,9 @@ impl FeaturesToml { if let Some(enabled) = self.multi_agent_v2.as_ref().and_then(FeatureToml::enabled) { entries.insert(Feature::MultiAgentV2.key().to_string(), enabled); } + if let Some(enabled) = self.rollout_budget.as_ref().and_then(FeatureToml::enabled) { + entries.insert(Feature::RolloutBudget.key().to_string(), enabled); + } if let Some(enabled) = self.network_proxy.as_ref().and_then(FeatureToml::enabled) { entries.insert(Feature::NetworkProxy.key().to_string(), enabled); } @@ -661,6 +669,7 @@ impl FeaturesToml { let Self { code_mode, multi_agent_v2, + rollout_budget, removed_apps_mcp_path_override: _, network_proxy, entries, @@ -674,6 +683,8 @@ impl FeaturesToml { materialize_resolved_feature_enabled(code_mode, enabled); } else if spec.id == Feature::MultiAgentV2 { materialize_resolved_feature_enabled(multi_agent_v2, enabled); + } else if spec.id == Feature::RolloutBudget { + materialize_resolved_feature_enabled(rollout_budget, enabled); } else if spec.id == Feature::NetworkProxy { materialize_resolved_feature_enabled(network_proxy, enabled); } else { @@ -1167,6 +1178,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::RolloutBudget, + key: "rollout_budget", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::SleepTool, key: "sleep_tool", diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index e2d803ce76..ca11ff9072 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -277,6 +277,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R background_terminal_max_timeout: 300_000, ghost_snapshot: GhostSnapshotConfig::default(), multi_agent_v2: MultiAgentV2Config::default(), + rollout_budget: None, features: Default::default(), suppress_unstable_features_warning: false, active_project: ProjectConfig { trust_level: None }, From 0369b24d54fe43d9dbe5b8544c4c44f26277d5c0 Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 18 Jun 2026 13:09:38 +0100 Subject: [PATCH 60/70] Add network environment ID plumbing (#28766) ## Why Prepare network approval scoping to distinguish execution environments without changing behavior yet. ## What changed - Add optional environment IDs to network policy requests. - Add optional network environment IDs to exec and sandbox request structs. - Thread default None values through existing construction points. - Fix stale constructor call sites that caused the CI compile failures. ## Not included - Per-environment proxy listeners. - Network approval cache or prompt behavior changes. - Ambiguous request attribution handling. Those behavior changes moved to stacked follow-up #28899. ## Validation - just fmt - CI will run tests and clippy --- codex-rs/app-server/src/command_exec.rs | 3 +++ .../src/request_processors/command_exec_processor.rs | 1 + codex-rs/core/src/exec.rs | 7 +++++++ codex-rs/core/src/exec_tests.rs | 7 +++++++ codex-rs/core/src/sandboxing/mod.rs | 5 +++++ codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/tools/handlers/shell/shell_command.rs | 1 + .../core/src/tools/runtimes/shell/unix_escalation.rs | 3 +++ codex-rs/core/src/tools/sandboxing.rs | 1 + codex-rs/core/src/unified_exec/mod_tests.rs | 1 + codex-rs/core/src/unified_exec/process_manager_tests.rs | 1 + codex-rs/core/tests/suite/exec.rs | 1 + codex-rs/core/tests/suite/windows_sandbox.rs | 2 ++ codex-rs/exec-server/src/fs_sandbox.rs | 1 + codex-rs/exec/tests/suite/sandbox.rs | 1 + codex-rs/linux-sandbox/tests/suite/landlock.rs | 2 ++ codex-rs/network-proxy/src/http_proxy.rs | 2 ++ codex-rs/network-proxy/src/network_policy.rs | 9 +++++++++ codex-rs/network-proxy/src/socks5.rs | 2 ++ codex-rs/sandboxing/src/manager.rs | 4 ++++ codex-rs/sandboxing/src/manager_tests.rs | 5 +++++ 21 files changed, 60 insertions(+) diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index f66bd95794..d7f0b90d5c 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -704,6 +704,7 @@ mod tests { cwd.clone(), HashMap::new(), /*network*/ None, + /*network_environment_id*/ None, ExecExpiration::DefaultTimeout, codex_core::exec::ExecCapturePolicy::ShellTool, SandboxType::WindowsRestrictedToken, @@ -821,6 +822,7 @@ mod tests { cwd.clone(), HashMap::new(), /*network*/ None, + /*network_environment_id*/ None, ExecExpiration::Cancellation(CancellationToken::new()), codex_core::exec::ExecCapturePolicy::ShellTool, SandboxType::None, @@ -908,6 +910,7 @@ mod tests { cwd.clone(), HashMap::new(), /*network*/ None, + /*network_environment_id*/ None, ExecExpiration::TimeoutOrCancellation { timeout: Duration::from_secs(30), cancellation, diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor.rs b/codex-rs/app-server/src/request_processors/command_exec_processor.rs index 5b14f0b0c0..dfe7555d58 100644 --- a/codex-rs/app-server/src/request_processors/command_exec_processor.rs +++ b/codex-rs/app-server/src/request_processors/command_exec_processor.rs @@ -297,6 +297,7 @@ impl CommandExecRequestProcessor { network: started_network_proxy .as_ref() .map(codex_core::config::StartedNetworkProxy::proxy), + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level, windows_sandbox_private_desktop: self diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 4983e0a806..0142f995f5 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -95,6 +95,7 @@ pub struct ExecParams { pub capture_policy: ExecCapturePolicy, pub env: HashMap, pub network: Option, + pub network_environment_id: Option, pub sandbox_permissions: SandboxPermissions, pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, pub windows_sandbox_private_desktop: bool, @@ -321,6 +322,7 @@ pub fn build_exec_request( expiration, capture_policy, network, + network_environment_id, windows_sandbox_level, windows_sandbox_private_desktop, @@ -372,6 +374,7 @@ pub fn build_exec_request( permissions: permission_profile, sandbox: sandbox_type, enforce_managed_network, + environment_id: network_environment_id.as_deref(), network: network.as_ref(), sandbox_policy_cwd: &sandbox_policy_cwd_uri, codex_linux_sandbox_exe: codex_linux_sandbox_exe.as_deref(), @@ -437,6 +440,7 @@ pub(crate) async fn execute_exec_request( file_system_sandbox_policy: _, network_sandbox_policy, windows_sandbox_filesystem_overrides, + network_environment_id, arg0, } = exec_request; @@ -456,6 +460,7 @@ pub(crate) async fn execute_exec_request( capture_policy, env, network: network.clone(), + network_environment_id, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level, windows_sandbox_private_desktop, @@ -593,6 +598,7 @@ async fn exec_windows_sandbox( cwd, mut env, network, + network_environment_id: _, expiration, capture_policy, windows_sandbox_level, @@ -941,6 +947,7 @@ async fn exec( cwd, mut env, network, + network_environment_id: _, arg0, expiration, capture_policy, diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index 84dfaba5d7..1a4477599d 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -273,6 +273,7 @@ async fn exec_full_buffer_capture_ignores_expiration() -> Result<()> { capture_policy: ExecCapturePolicy::FullBuffer, env, network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -309,6 +310,7 @@ async fn exec_full_buffer_capture_keeps_io_drain_timeout_when_descendant_holds_p capture_policy: ExecCapturePolicy::FullBuffer, env: std::env::vars().collect(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -356,6 +358,7 @@ async fn process_exec_tool_call_preserves_full_buffer_capture_policy() -> Result capture_policy: ExecCapturePolicy::FullBuffer, env: std::env::vars().collect(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -998,6 +1001,7 @@ fn build_exec_request_preserves_windows_workspace_roots() -> Result<()> { capture_policy: ExecCapturePolicy::ShellTool, env: HashMap::new(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -1052,6 +1056,7 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> capture_policy: ExecCapturePolicy::ShellTool, env, network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -1107,6 +1112,7 @@ async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> { capture_policy: ExecCapturePolicy::ShellTool, env, network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -1190,6 +1196,7 @@ while :; do sleep 1; done"# capture_policy: ExecCapturePolicy::ShellTool, env, network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index fa069afd4a..c156b0d70e 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -47,6 +47,7 @@ pub struct ExecRequest { pub env: HashMap, pub(crate) exec_server_env_config: Option, pub network: Option, + pub network_environment_id: Option, pub expiration: ExecExpiration, pub capture_policy: ExecCapturePolicy, pub sandbox: SandboxType, @@ -68,6 +69,7 @@ impl ExecRequest { cwd: AbsolutePathBuf, env: HashMap, network: Option, + network_environment_id: Option, expiration: ExecExpiration, capture_policy: ExecCapturePolicy, sandbox: SandboxType, @@ -87,6 +89,7 @@ impl ExecRequest { env, exec_server_env_config: None, network, + network_environment_id, expiration, capture_policy, sandbox, @@ -113,6 +116,7 @@ impl ExecRequest { sandbox_policy_cwd: windows_sandbox_policy_cwd, mut env, network, + network_environment_id, sandbox, windows_sandbox_level, windows_sandbox_private_desktop, @@ -141,6 +145,7 @@ impl ExecRequest { env, exec_server_env_config: None, network, + network_environment_id, expiration, capture_policy, sandbox, diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index aea7500247..7cb9841db7 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -205,6 +205,7 @@ pub(crate) async fn execute_user_shell_command( // `/shell` is the explicit full-access escape hatch, so it must not // inherit a managed proxy from the surrounding session or turn. network: None, + network_environment_id: None, // TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we // should use that instead of an "arbitrarily large" timeout here. expiration: USER_SHELL_TIMEOUT_MS.into(), diff --git a/codex-rs/core/src/tools/handlers/shell/shell_command.rs b/codex-rs/core/src/tools/handlers/shell/shell_command.rs index f1c44f9d05..6176a07d62 100644 --- a/codex-rs/core/src/tools/handlers/shell/shell_command.rs +++ b/codex-rs/core/src/tools/handlers/shell/shell_command.rs @@ -105,6 +105,7 @@ impl ShellCommandHandler { Some(thread_id), ), network: turn_context.network.clone(), + network_environment_id: None, sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), windows_sandbox_level: turn_context.windows_sandbox_level, windows_sandbox_private_desktop: turn_context diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index b8e417f856..2acbf37781 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -151,6 +151,7 @@ pub(super) async fn try_run_zsh_fork( env: sandbox_env, exec_server_env_config: _, network: sandbox_network, + network_environment_id: _, expiration: _sandbox_expiration, capture_policy: _capture_policy, sandbox, @@ -879,6 +880,7 @@ impl CoreShellCommandExecutor { env: exec_env, exec_server_env_config: None, network: self.network.clone(), + network_environment_id: None, expiration: ExecExpiration::Cancellation(cancel_rx), capture_policy: ExecCapturePolicy::ShellTool, sandbox: self.sandbox, @@ -1010,6 +1012,7 @@ impl CoreShellCommandExecutor { permissions: permission_profile, sandbox, enforce_managed_network: self.network.is_some(), + environment_id: None, network: self.network.as_ref(), sandbox_policy_cwd: &sandbox_policy_cwd, codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_deref(), diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index cc7a7913dd..e80cbbba1a 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -434,6 +434,7 @@ impl<'a> SandboxAttempt<'a> { permissions: self.permissions, sandbox: self.sandbox, enforce_managed_network: self.enforce_managed_network, + environment_id: None, network, sandbox_policy_cwd: self.sandbox_cwd, codex_linux_sandbox_exe: self diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 0d4e21d93c..8e1b03d118 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -77,6 +77,7 @@ fn test_exec_request( cwd, env, network, + /*network_environment_id*/ None, ExecExpiration::DefaultTimeout, ExecCapturePolicy::ShellTool, SandboxType::None, diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index c399b9c988..5abd926c62 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -98,6 +98,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { ]), }), network: None, + network_environment_id: None, expiration: crate::exec::ExecExpiration::DefaultTimeout, capture_policy: crate::exec::ExecCapturePolicy::ShellTool, sandbox: codex_sandboxing::SandboxType::None, diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index 84d7408b81..355711e345 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -41,6 +41,7 @@ where capture_policy: ExecCapturePolicy::ShellTool, env: HashMap::new(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, diff --git a/codex-rs/core/tests/suite/windows_sandbox.rs b/codex-rs/core/tests/suite/windows_sandbox.rs index 74d7f6a23b..b8895f49e4 100644 --- a/codex-rs/core/tests/suite/windows_sandbox.rs +++ b/codex-rs/core/tests/suite/windows_sandbox.rs @@ -173,6 +173,7 @@ async fn windows_restricted_token_rejects_exact_and_glob_deny_read_policy() -> a capture_policy: ExecCapturePolicy::ShellTool, env: HashMap::new(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, windows_sandbox_private_desktop: false, @@ -262,6 +263,7 @@ async fn windows_elevated_enforces_deny_read_and_protects_setup_marker() -> anyh capture_policy: ExecCapturePolicy::ShellTool, env: HashMap::new(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Elevated, windows_sandbox_private_desktop: false, diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index ea420c9ea2..3dbcf73c14 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -135,6 +135,7 @@ impl FileSystemSandboxRunner { permissions: permission_profile, sandbox, enforce_managed_network: false, + environment_id: None, network: None, sandbox_policy_cwd: &cwd.uri, codex_linux_sandbox_exe: self.runtime_paths.codex_linux_sandbox_exe.as_deref(), diff --git a/codex-rs/exec/tests/suite/sandbox.rs b/codex-rs/exec/tests/suite/sandbox.rs index 3171a445a1..03976a8546 100644 --- a/codex-rs/exec/tests/suite/sandbox.rs +++ b/codex-rs/exec/tests/suite/sandbox.rs @@ -36,6 +36,7 @@ async fn spawn_command_under_sandbox( capture_policy: ExecCapturePolicy::ShellTool, env, network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index 3e6d821110..5843e03115 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -175,6 +175,7 @@ async fn run_cmd_result_with_permission_profile_for_cwd( capture_policy: ExecCapturePolicy::ShellTool, env: create_env_from_core_vars(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -432,6 +433,7 @@ async fn assert_network_blocked(cmd: &[&str]) { capture_policy: ExecCapturePolicy::ShellTool, env: create_env_from_core_vars(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index f20c01b906..c07ff17606 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -200,6 +200,7 @@ async fn http_connect_accept( protocol: NetworkProtocol::HttpsConnect, host: host.clone(), port: authority.port, + environment_id: None, client_addr: client.clone(), method: Some("CONNECT".to_string()), command: None, @@ -683,6 +684,7 @@ async fn http_plain_proxy( protocol: NetworkProtocol::Http, host: host.clone(), port, + environment_id: None, client_addr: client.clone(), method: Some(req.method().as_str().to_string()), command: None, diff --git a/codex-rs/network-proxy/src/network_policy.rs b/codex-rs/network-proxy/src/network_policy.rs index 18bca2c916..e5792a07c0 100644 --- a/codex-rs/network-proxy/src/network_policy.rs +++ b/codex-rs/network-proxy/src/network_policy.rs @@ -79,6 +79,7 @@ pub struct NetworkPolicyRequest { pub protocol: NetworkProtocol, pub host: String, pub port: u16, + pub environment_id: Option, pub client_addr: Option, pub method: Option, pub command: Option, @@ -89,6 +90,7 @@ pub struct NetworkPolicyRequestArgs { pub protocol: NetworkProtocol, pub host: String, pub port: u16, + pub environment_id: Option, pub client_addr: Option, pub method: Option, pub command: Option, @@ -101,6 +103,7 @@ impl NetworkPolicyRequest { protocol, host, port, + environment_id, client_addr, method, command, @@ -110,6 +113,7 @@ impl NetworkPolicyRequest { protocol, host, port, + environment_id, client_addr, method, command, @@ -625,6 +629,7 @@ mod tests { protocol: NetworkProtocol::Http, host: "example.com".to_string(), port: 80, + environment_id: None, client_addr: None, method: None, command: None, @@ -686,6 +691,7 @@ mod tests { protocol: NetworkProtocol::Http, host: "blocked.com".to_string(), port: 80, + environment_id: None, client_addr: Some("127.0.0.1:1234".to_string()), method: Some("GET".to_string()), command: None, @@ -729,6 +735,7 @@ mod tests { protocol: NetworkProtocol::Http, host: "example.com".to_string(), port: 80, + environment_id: None, client_addr: None, method: Some("GET".to_string()), command: None, @@ -779,6 +786,7 @@ mod tests { protocol: NetworkProtocol::Http, host: "example.com".to_string(), port: 80, + environment_id: None, client_addr: None, method: Some("GET".to_string()), command: None, @@ -865,6 +873,7 @@ mod tests { protocol: NetworkProtocol::Http, host: "127.0.0.1".to_string(), port: 80, + environment_id: None, client_addr: None, method: Some("GET".to_string()), command: None, diff --git a/codex-rs/network-proxy/src/socks5.rs b/codex-rs/network-proxy/src/socks5.rs index 6e600d2025..a4f36db562 100644 --- a/codex-rs/network-proxy/src/socks5.rs +++ b/codex-rs/network-proxy/src/socks5.rs @@ -268,6 +268,7 @@ async fn handle_socks5_tcp( protocol: NetworkProtocol::Socks5Tcp, host: host.clone(), port, + environment_id: None, client_addr: client.clone(), method: None, command: None, @@ -624,6 +625,7 @@ async fn inspect_socks5_udp( protocol: NetworkProtocol::Socks5Udp, host: host.clone(), port, + environment_id: None, client_addr: client.clone(), method: None, command: None, diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 25032a8050..f00e056907 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -113,6 +113,7 @@ pub struct SandboxExecRequest { pub sandbox_policy_cwd: PathUri, pub env: HashMap, pub network: Option, + pub network_environment_id: Option, pub sandbox: SandboxType, pub windows_sandbox_level: WindowsSandboxLevel, pub windows_sandbox_private_desktop: bool, @@ -130,6 +131,7 @@ pub struct SandboxTransformRequest<'a> { pub permissions: &'a PermissionProfile, pub sandbox: SandboxType, pub enforce_managed_network: bool, + pub environment_id: Option<&'a str>, // TODO(viyatb): Evaluate switching this to Option> // to make shared ownership explicit across runtime/sandbox plumbing. pub network: Option<&'a NetworkProxy>, @@ -305,6 +307,7 @@ impl SandboxManager { permissions, sandbox, enforce_managed_network, + environment_id, network, sandbox_policy_cwd, codex_linux_sandbox_exe, @@ -422,6 +425,7 @@ impl SandboxManager { sandbox_policy_cwd: sandbox_policy_cwd.clone(), env: command.env, network: network.cloned(), + network_environment_id: environment_id.map(str::to_string), sandbox, windows_sandbox_level, windows_sandbox_private_desktop, diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index 344787f141..64fc87b6e4 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -97,6 +97,7 @@ fn unsandboxed_transform_preserves_foreign_cwd_and_unrestricted_file_system_poli permissions: &permissions, sandbox: SandboxType::None, enforce_managed_network: false, + environment_id: None, network: None, sandbox_policy_cwd: &cwd_uri, codex_linux_sandbox_exe: None, @@ -151,6 +152,7 @@ fn transform_additional_permissions_enable_network_for_external_sandbox() { permissions: &permissions, sandbox: SandboxType::None, enforce_managed_network: false, + environment_id: None, network: None, sandbox_policy_cwd: &cwd_uri, codex_linux_sandbox_exe: None, @@ -220,6 +222,7 @@ fn transform_additional_permissions_preserves_denied_entries() { permissions: &permissions, sandbox: SandboxType::None, enforce_managed_network: false, + environment_id: None, network: None, sandbox_policy_cwd: &cwd_uri, codex_linux_sandbox_exe: None, @@ -316,6 +319,7 @@ fn transform_linux_seccomp_request( permissions: &permissions, sandbox: SandboxType::LinuxSeccomp, enforce_managed_network: false, + environment_id: None, network: None, sandbox_policy_cwd: &cwd_uri, codex_linux_sandbox_exe: Some(codex_linux_sandbox_exe), @@ -505,6 +509,7 @@ fn transform_for_direct_spawn_windows_materializes_inner_helper() { permissions: &permissions, sandbox: SandboxType::WindowsRestrictedToken, enforce_managed_network: false, + environment_id: None, network: None, sandbox_policy_cwd: &cwd_uri, codex_linux_sandbox_exe: None, From 56703600091d25542b60597b85d0e027799ad063 Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 18 Jun 2026 14:13:55 +0100 Subject: [PATCH 61/70] Avoid sandbox helper in apply_patch approval tests (#28915) ## Summary This keeps the apply_patch approval tests focused on approval behavior instead of macOS sandboxed filesystem helper startup. The changed cases still force patch approval with `UnlessTrusted`, but use `DangerFullAccess` after approval so the patch write is direct and cheap. Workspace-write and sandbox-helper behavior remain covered by the filesystem and apply_patch sandbox tests. --- codex-rs/core/tests/suite/approvals.rs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 422fd28260..f26b8e4b85 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -1380,7 +1380,7 @@ fn scenarios() -> Vec { ScenarioSpec { name: "apply_patch_shell_command_requires_patch_approval", approval_policy: UnlessTrusted, - sandbox_policy: workspace_write(false), + sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::ApplyPatchShell { target: TargetPath::Workspace("apply_patch_shell.txt"), content: "shell-apply-patch", @@ -1494,7 +1494,7 @@ fn scenarios() -> Vec { ScenarioSpec { name: "apply_patch_freeform_unless_trusted_requires_patch_approval", approval_policy: UnlessTrusted, - sandbox_policy: workspace_write(false), + sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::ApplyPatchFreeform { target: TargetPath::Workspace("apply_patch_freeform_unless_trusted.txt"), content: "freeform-patch-unless-trusted", @@ -2060,13 +2060,8 @@ async fn approving_apply_patch_for_session_skips_future_prompts_for_same_file() skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let approval_policy = AskForApproval::UnlessTrusted; + let sandbox_policy = SandboxPolicy::DangerFullAccess; let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex() From 07298a948cbac94c7b0b505e91279fc69786f78c Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 18 Jun 2026 08:48:03 -0700 Subject: [PATCH 62/70] Pause active goals before TUI interrupts (#28813) Fixes #28104. ## Summary Active `/goal` turns should leave the persisted goal paused whenever the TUI interrupts the running turn. The bug in #28104 showed this most visibly through `Esc`: some interrupt paths aborted the turn without updating the goal status, so the goal could remain active and continue automatically. This change makes `ChatWidget` pause an active goal before the TUI sends an interrupt from the status-row path, the pending-steer path, `Ctrl+C`, or a request-user-input overlay. The modal overlay now reports whether a key will interrupt the turn, which keeps modal `Esc` and `Ctrl+C` behavior aligned with the normal interrupt paths. ## Manual Testing Built the local CLI with `just codex --help`, then launched the local TUI with goals enabled. Started an active `/goal` turn and interrupted it with `Esc`, then resumed and repeated with `Ctrl+C`; both paths showed `Goal paused`, the interrupted-conversation message, and the `Goal paused (/goal resume)` footer. I also stopped the background terminal and exited the TUI cleanly after the run. I did not find a reliable standalone manual path to force the request-user-input overlay case, so that path is covered by the focused automated test. --- .../tui/src/bottom_pane/bottom_pane_view.rs | 5 ++ codex-rs/tui/src/bottom_pane/mod.rs | 36 +++++--- .../src/bottom_pane/request_user_input/mod.rs | 15 ++++ codex-rs/tui/src/chatwidget/interaction.rs | 34 ++++++-- ...sts__esc_interrupt_goal_paused_footer.snap | 11 +++ ..._interrupt_goal_paused_footer@windows.snap | 11 +++ .../src/chatwidget/tests/status_and_layout.rs | 84 ++++++++++++++++--- 7 files changed, 169 insertions(+), 27 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer@windows.snap diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index a246c05e57..551bacd25a 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -67,6 +67,11 @@ pub(crate) trait BottomPaneView: Renderable { false } + /// Return true when this key event will interrupt the active agent turn. + fn will_interrupt_turn_on_key_event(&self, _key_event: KeyEvent) -> bool { + false + } + /// Optional paste handler. Return true if the view modified its state and /// needs a redraw. fn handle_paste(&mut self, _pasted: String) -> bool { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 951c310d1f..7fffdeb98e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -616,21 +616,10 @@ impl BottomPane { self.request_redraw(); InputResult::None } else { - let is_agent_command = self - .composer_text() - .lines() - .next() - .and_then(parse_slash_name) - .is_some_and(|(name, _, _)| name == "agent"); - // If a task is running and a status line is visible, allow the // configured action to interrupt even while the composer has focus. // When a popup is active, prefer dismissing it over interrupting the task. - if self.keymap.chat.interrupt_turn.is_pressed(key_event) - && self.is_task_running - && !(is_agent_command && key_event.code == KeyCode::Esc) - && !self.composer.popup_active() - && !self.composer_should_handle_vim_insert_escape(key_event) + if self.should_interrupt_running_task(key_event) && let Some(status) = &self.status { // Send Op::Interrupt @@ -1306,6 +1295,22 @@ impl BottomPane { self.is_task_running } + pub(crate) fn should_interrupt_running_task(&self, key_event: KeyEvent) -> bool { + let is_agent_command = self + .composer_text() + .lines() + .next() + .and_then(parse_slash_name) + .is_some_and(|(name, _, _)| name == "agent"); + + self.keymap.chat.interrupt_turn.is_pressed(key_event) + && self.is_task_running + && !(is_agent_command && key_event.code == KeyCode::Esc) + && self.no_modal_or_popup_active() + && !self.composer_should_handle_vim_insert_escape(key_event) + && self.status.is_some() + } + pub(crate) fn terminal_title_requires_action(&self) -> bool { self.active_view() .is_some_and(bottom_pane_view::BottomPaneView::terminal_title_requires_action) @@ -1315,6 +1320,13 @@ impl BottomPane { !self.view_stack.is_empty() } + pub(crate) fn active_view_will_interrupt_turn_on_key_event(&self, key_event: KeyEvent) -> bool { + self.is_task_running + && self + .active_view() + .is_some_and(|view| view.will_interrupt_turn_on_key_event(key_event)) + } + #[cfg(test)] pub(crate) fn active_view_id(&self) -> Option<&'static str> { self.view_stack.last().and_then(|view| view.view_id()) diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index acbf615684..bf1fa5fa8a 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -1183,6 +1183,21 @@ impl BottomPaneView for RequestUserInputOverlay { true } + fn will_interrupt_turn_on_key_event(&self, key_event: KeyEvent) -> bool { + if KeyBinding::new(KeyCode::Char('c'), KeyModifiers::CONTROL).is_press(key_event) { + return self.confirm_unanswered_active() + || !self.focus_is_notes() + || self.composer.current_text_with_pending().is_empty(); + } + + key_event.kind != KeyEventKind::Release + && !self.confirm_unanswered_active() + && !(matches!(key_event.code, KeyCode::Esc) + && self.has_options() + && self.notes_ui_visible()) + && self.interrupt_turn_keys.is_pressed(key_event) + } + fn handle_key_event(&mut self, key_event: KeyEvent) { if key_event.kind == KeyEventKind::Release { return; diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 25e93088da..74fc3d5e52 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -17,7 +17,13 @@ impl ChatWidget { && !key_hint::ctrl(KeyCode::Char('r')).is_press(key_event) && !key_hint::ctrl(KeyCode::Char('u')).is_press(key_event) { + let should_pause_active_goal = self + .bottom_pane + .active_view_will_interrupt_turn_on_key_event(key_event); self.bottom_pane.handle_key_event(key_event); + if should_pause_active_goal { + self.pause_active_goal_for_interrupt(); + } if self.bottom_pane.no_modal_or_popup_active() { self.maybe_send_next_queued_input(); } @@ -133,7 +139,9 @@ impl ChatWidget { && !self.should_handle_vim_insert_escape(key_event) { self.input_queue.submit_pending_steers_after_interrupt = true; - if !self.submit_op(AppCommand::interrupt()) { + if self.submit_op(AppCommand::interrupt()) { + self.pause_active_goal_for_interrupt(); + } else { self.input_queue.submit_pending_steers_after_interrupt = false; } return; @@ -165,7 +173,12 @@ impl ChatWidget { } _ => { let had_modal_or_popup = !self.bottom_pane.no_modal_or_popup_active(); + let should_pause_active_goal = + self.bottom_pane.should_interrupt_running_task(key_event); let input_result = self.bottom_pane.handle_key_event(key_event); + if should_pause_active_goal { + self.pause_active_goal_for_interrupt(); + } self.handle_composer_input_result(input_result, had_modal_or_popup); } } @@ -360,6 +373,12 @@ impl ChatWidget { fn on_ctrl_c(&mut self) { let key = key_hint::ctrl(KeyCode::Char('c')); let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active(); + let should_pause_active_goal = self + .bottom_pane + .active_view_will_interrupt_turn_on_key_event(KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + )); if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled { if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { if modal_or_popup_active { @@ -370,6 +389,9 @@ impl ChatWidget { self.arm_quit_shortcut(key); } } + if should_pause_active_goal { + self.pause_active_goal_for_interrupt(); + } return; } @@ -378,8 +400,9 @@ impl ChatWidget { self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; self.bottom_pane.clear_quit_shortcut_hint(); - self.pause_active_goal_for_interrupt(); - self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()); + if self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()) { + self.pause_active_goal_for_interrupt(); + } } else { self.request_quit_without_confirmation(); } @@ -395,9 +418,10 @@ impl ChatWidget { self.arm_quit_shortcut(key); - if self.is_cancellable_work_active() { + if self.is_cancellable_work_active() + && self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()) + { self.pause_active_goal_for_interrupt(); - self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()); } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap new file mode 100644 index 0000000000..bebbb3ffd5 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: snapshot +--- +" " +"• Working (0s • esc to interrupt) " +" " +" " +"› Ask Codex to do anything " +" " +" gpt-5.5 default · /tmp/project Goal paused (/goal resume) " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer@windows.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer@windows.snap new file mode 100644 index 0000000000..1025ae3862 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer@windows.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: snapshot +--- +" " +"• Working (0s • esc to interrupt) " +" " +" " +"› Ask Codex to do anything " +" " +" gpt-5.5 default · /tmp/project Goal paused (/goal resume) " diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d8c79f56b1..583377bace 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1410,33 +1410,97 @@ async fn streaming_final_answer_keeps_task_running_state() { #[tokio::test] async fn ctrl_c_interrupt_pauses_active_goal_turn() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = start_active_goal_turn(&mut chat); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + + next_interrupt_op(&mut op_rx); + assert_goal_paused_event(&mut rx, thread_id); +} + +#[tokio::test] +async fn esc_interrupt_pauses_active_goal_turn() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; + let thread_id = start_active_goal_turn(&mut chat); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))); + assert_goal_paused_event(&mut rx, thread_id); + + update_thread_goal(&mut chat, thread_id, AppThreadGoalStatus::Paused); + let width = 80; + let height = chat.desired_height(width); + let mut terminal = ratatui::Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw goal paused footer"); + let snapshot = normalized_backend_snapshot(terminal.backend()); + #[cfg(target_os = "windows")] + insta::with_settings!({ snapshot_suffix => "windows" }, { + assert_chatwidget_snapshot!("esc_interrupt_goal_paused_footer", snapshot); + }); + #[cfg(not(target_os = "windows"))] + assert_chatwidget_snapshot!("esc_interrupt_goal_paused_footer", snapshot); +} + +#[tokio::test] +async fn request_user_input_interrupt_pauses_active_goal_turn() { + for key_event in [ + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ] { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = start_active_goal_turn(&mut chat); + chat.handle_request_user_input_now(ToolRequestUserInputParams { + thread_id: thread_id.to_string(), + item_id: "call-1".to_string(), + turn_id: "turn-1".to_string(), + questions: Vec::new(), + auto_resolution_ms: None, + }); + + chat.handle_key_event(key_event); + + assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))); + assert_goal_paused_event(&mut rx, thread_id); + } +} + +fn start_active_goal_turn(chat: &mut ChatWidget) -> ThreadId { let thread_id = ThreadId::new(); chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); chat.thread_id = Some(thread_id); + update_thread_goal(chat, thread_id, AppThreadGoalStatus::Active); + chat.on_task_started(); + thread_id +} + +fn update_thread_goal(chat: &mut ChatWidget, thread_id: ThreadId, status: AppThreadGoalStatus) { let mut goal = test_thread_goal( - codex_app_server_protocol::ThreadGoalStatus::Active, + status, /*token_budget*/ Some(50_000), /*tokens_used*/ 40_000, ); - goal.thread_id = thread_id.to_string(); + let thread_id = thread_id.to_string(); + goal.thread_id = thread_id.clone(); chat.handle_server_notification( ServerNotification::ThreadGoalUpdated( codex_app_server_protocol::ThreadGoalUpdatedNotification { - thread_id: thread_id.to_string(), + thread_id, turn_id: None, goal, }, ), /*replay_kind*/ None, ); - chat.on_task_started(); +} - chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); - - match op_rx.try_recv() { - Ok(Op::Interrupt { .. }) => {} - other => panic!("expected Op::Interrupt, got {other:?}"), - } +fn assert_goal_paused_event( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + thread_id: ThreadId, +) { assert_matches!( rx.try_recv(), Ok(AppEvent::SetThreadGoalStatus { From 83e6a786a20403fd35991c8979523013595e71da Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 18 Jun 2026 18:04:26 +0100 Subject: [PATCH 63/70] Recover exec process stdin writes (#28895) ## Summary Remote stdio MCP servers send tool calls by writing JSON-RPC bytes through `process/write`. When the exec-server websocket drops at the wrong time, the remote process can survive session recovery, but the stdin write can still fail back to RMCP as a transport send error. RMCP then closes the stdio MCP transport, so tools like `node_repl` are lost even though the process/session recovery path is working. This changes `process/write` to be safe to retry across exec-server recovery: - adds a required `writeId` to `process/write` - retries remote `Session::write` with the same `writeId` after reconnect - remembers accepted write ids per process so duplicate retries return `Accepted` without writing the same bytes to child stdin again - covers both the client retry path and server-side write id dedupe with tests In simple terms: ```text before: write to MCP stdin -> websocket closes -> write errors -> RMCP closes node_repl after: write to MCP stdin -> websocket closes -> reconnect -> retry same writeId server either writes once or recognizes it already did ``` --- codex-rs/exec-server/src/client.rs | 148 +++++++++++++++++++++- codex-rs/exec-server/src/local_process.rs | 81 +++++++++++- codex-rs/exec-server/src/protocol.rs | 1 + codex-rs/exec-server/tests/process.rs | 137 +++++++++++++++++++- 4 files changed, 361 insertions(+), 6 deletions(-) diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index ae77dc7c4f..325fb74420 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -155,6 +155,7 @@ pub(crate) struct SessionState { events: ExecProcessEventLog, ordered_events: StdMutex, recoverable: AtomicBool, + next_write_id: AtomicU64, } #[derive(Default)] @@ -421,12 +422,14 @@ impl ExecServerClient { &self, process_id: &ProcessId, chunk: Vec, + write_id: String, ) -> Result { self.call( EXEC_WRITE_METHOD, &WriteParams { process_id: process_id.clone(), chunk: chunk.into(), + write_id, }, ) .await @@ -730,6 +733,7 @@ impl SessionState { ), ordered_events: StdMutex::new(OrderedSessionEvents::default()), recoverable: AtomicBool::new(recoverable), + next_write_id: AtomicU64::new(1), } } @@ -829,6 +833,12 @@ impl SessionState { failure: Some(message), } } + + fn next_write_id(&self) -> String { + self.next_write_id + .fetch_add(1, Ordering::Relaxed) + .to_string() + } } impl Session { @@ -885,7 +895,22 @@ impl Session { } pub(crate) async fn write(&self, chunk: Vec) -> Result { - self.client.write(&self.process_id, chunk).await + let write_id = self.state.next_write_id(); + loop { + match self + .client + .write(&self.process_id, chunk.clone(), write_id.clone()) + .await + { + Ok(response) => return Ok(response), + Err(error) + if is_transport_closed_error(&error) && !self.client.inner.is_failed() => + { + continue; + } + Err(error) => return Err(error), + } + } } pub(crate) async fn signal(&self, signal: ProcessSignal) -> Result<(), ExecServerError> { @@ -1110,6 +1135,8 @@ mod tests { use crate::protocol::EXEC_CLOSED_METHOD; use crate::protocol::EXEC_EXITED_METHOD; use crate::protocol::EXEC_OUTPUT_DELTA_METHOD; + use crate::protocol::EXEC_READ_METHOD; + use crate::protocol::EXEC_WRITE_METHOD; use crate::protocol::ExecClosedNotification; use crate::protocol::ExecExitedNotification; use crate::protocol::ExecOutputDeltaNotification; @@ -1118,6 +1145,10 @@ mod tests { use crate::protocol::INITIALIZED_METHOD; use crate::protocol::InitializeResponse; use crate::protocol::ProcessOutputChunk; + use crate::protocol::ReadResponse; + use crate::protocol::WriteParams; + use crate::protocol::WriteResponse; + use crate::protocol::WriteStatus; async fn read_jsonrpc_line(lines: &mut tokio::io::Lines>) -> JSONRPCMessage where @@ -1685,6 +1716,121 @@ mod tests { server.await.expect("server task should finish"); } + #[tokio::test] + async fn session_write_retries_same_write_id_after_recovery() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let websocket_url = format!( + "ws://{}", + listener.local_addr().expect("listener should have address") + ); + let (finish_tx, finish_rx) = oneshot::channel(); + let server = tokio::spawn(async move { + let mut first = accept_websocket(&listener).await; + complete_websocket_initialize( + &mut first, + "session-1", + /*expected_resume_session_id*/ None, + ) + .await; + + let first_write = read_jsonrpc_websocket(&mut first).await; + let first_write = match first_write { + JSONRPCMessage::Request(request) if request.method == EXEC_WRITE_METHOD => request, + other => panic!("expected first process/write request, got {other:?}"), + }; + let first_write_params: WriteParams = + serde_json::from_value(first_write.params.expect("write params should exist")) + .expect("write params should deserialize"); + assert_eq!(first_write_params.process_id.as_str(), "proc-write"); + assert_eq!(first_write_params.chunk.into_inner(), b"hello\n".to_vec()); + let write_id = first_write_params.write_id; + assert!(!write_id.is_empty()); + drop(first); + + let mut resumed = accept_websocket(&listener).await; + complete_websocket_initialize( + &mut resumed, + "session-1", + /*expected_resume_session_id*/ Some("session-1"), + ) + .await; + + let recovery_read = read_jsonrpc_websocket(&mut resumed).await; + let recovery_read = match recovery_read { + JSONRPCMessage::Request(request) if request.method == EXEC_READ_METHOD => request, + other => panic!("expected recovery process/read request, got {other:?}"), + }; + write_jsonrpc_websocket( + &mut resumed, + JSONRPCMessage::Response(JSONRPCResponse { + id: recovery_read.id, + result: serde_json::to_value(ReadResponse { + chunks: Vec::new(), + next_seq: 1, + exited: false, + exit_code: None, + closed: false, + failure: None, + }) + .expect("read response should serialize"), + }), + ) + .await; + + let retried_write = read_jsonrpc_websocket(&mut resumed).await; + let retried_write = match retried_write { + JSONRPCMessage::Request(request) if request.method == EXEC_WRITE_METHOD => request, + other => panic!("expected retried process/write request, got {other:?}"), + }; + let retried_write_params: WriteParams = + serde_json::from_value(retried_write.params.expect("write params should exist")) + .expect("write params should deserialize"); + assert_eq!(retried_write_params.process_id.as_str(), "proc-write"); + assert_eq!(retried_write_params.chunk.into_inner(), b"hello\n".to_vec()); + assert_eq!(retried_write_params.write_id, write_id); + write_jsonrpc_websocket( + &mut resumed, + JSONRPCMessage::Response(JSONRPCResponse { + id: retried_write.id, + result: serde_json::to_value(WriteResponse { + status: WriteStatus::Accepted, + }) + .expect("write response should serialize"), + }), + ) + .await; + + finish_rx.await.expect("test should finish"); + }); + + let client = LazyRemoteExecServerClient::new(ExecServerTransportParams::WebSocketUrl { + websocket_url, + connect_timeout: Duration::from_secs(1), + initialize_timeout: Duration::from_secs(1), + }); + let stable_client = client.get().await.expect("client should connect"); + let session = stable_client + .register_session(&ProcessId::from("proc-write")) + .await + .expect("session should register"); + + let response = timeout(Duration::from_secs(2), session.write(b"hello\n".to_vec())) + .await + .expect("write should not time out") + .expect("write should recover"); + assert_eq!( + response, + WriteResponse { + status: WriteStatus::Accepted + } + ); + + finish_tx.send(()).expect("test should finish"); + server.await.expect("server task should finish"); + } + #[tokio::test] async fn explicit_resume_drains_notifications_before_initialize_response() { let listener = TcpListener::bind("127.0.0.1:0") diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index d8ee65160a..5e1b52b11c 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -1,7 +1,10 @@ use std::collections::HashMap; +use std::collections::HashSet; use std::collections::VecDeque; use std::collections::hash_map::Entry; use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; use std::time::Duration; use codex_app_server_protocol::JSONRPCErrorError; @@ -54,6 +57,8 @@ use crate::rpc::invalid_request; const RETAINED_OUTPUT_BYTES_PER_PROCESS: usize = 1024 * 1024; const NOTIFICATION_CHANNEL_CAPACITY: usize = 256; const PROCESS_EVENT_CHANNEL_CAPACITY: usize = 256; +const RETAINED_STDIN_WRITE_IDS_PER_PROCESS: usize = 4096; +static NEXT_LOCAL_STDIN_WRITE_ID: AtomicU64 = AtomicU64::new(1); #[cfg(test)] const EXITED_PROCESS_RETENTION: Duration = Duration::from_millis(25); #[cfg(not(test))] @@ -70,6 +75,7 @@ struct RunningProcess { session: ExecCommandSession, tty: bool, pipe_stdin: bool, + accepted_stdin_write_ids: Arc>, output: VecDeque, retained_bytes: usize, next_seq: u64, @@ -81,6 +87,37 @@ struct RunningProcess { closed: bool, } +/// Bounded cache of stdin write ids that have already been accepted for one process. +/// +/// A remote client can retry `process/write` after reconnecting. Remembering accepted +/// ids lets the server acknowledge the retried request without writing the same bytes +/// to child stdin twice. +#[derive(Default)] +struct AcceptedStdinWriteIds { + ids: HashSet, + order: VecDeque, +} + +impl AcceptedStdinWriteIds { + fn contains(&self, write_id: &str) -> bool { + self.ids.contains(write_id) + } + + fn remember(&mut self, write_id: String) { + if !self.ids.insert(write_id.clone()) { + return; + } + + self.order.push_back(write_id); + while self.order.len() > RETAINED_STDIN_WRITE_IDS_PER_PROCESS { + let Some(evicted) = self.order.pop_front() else { + break; + }; + self.ids.remove(&evicted); + } + } +} + struct ProcessStart; enum ProcessEntry { @@ -247,6 +284,9 @@ impl LocalProcess { session: spawned.session, tty: params.tty, pipe_stdin: params.pipe_stdin, + accepted_stdin_write_ids: Arc::new( + Mutex::new(AcceptedStdinWriteIds::default()), + ), output: VecDeque::new(), retained_bytes: 0, next_seq: 1, @@ -383,7 +423,11 @@ impl LocalProcess { params: WriteParams, ) -> Result { let _input_bytes = params.chunk.0.len(); - let writer_tx = { + if params.write_id.is_empty() { + return Err(invalid_params("writeId must not be empty".to_string())); + } + + let (writer_tx, accepted_stdin_write_ids) = { let process_map = self.inner.processes.lock().await; let Some(process) = process_map.get(¶ms.process_id) else { return Ok(WriteResponse { @@ -400,13 +444,37 @@ impl LocalProcess { status: WriteStatus::StdinClosed, }); } - process.session.writer_sender() + ( + process.session.writer_sender(), + Arc::clone(&process.accepted_stdin_write_ids), + ) }; - writer_tx - .send(params.chunk.into_inner()) + if accepted_stdin_write_ids + .lock() + .await + .contains(¶ms.write_id) + { + return Ok(WriteResponse { + status: WriteStatus::Accepted, + }); + } + + let permit = writer_tx + .reserve() .await .map_err(|_| internal_error("failed to write to process stdin".to_string()))?; + let mut accepted_stdin_write_ids = accepted_stdin_write_ids.lock().await; + if accepted_stdin_write_ids.contains(¶ms.write_id) { + return Ok(WriteResponse { + status: WriteStatus::Accepted, + }); + } + + // After this synchronous send, record the write id before any further await. + // Otherwise a cancelled RPC handler could retry and write the same bytes again. + permit.send(params.chunk.into_inner()); + accepted_stdin_write_ids.remember(params.write_id); Ok(WriteResponse { status: WriteStatus::Accepted, @@ -601,6 +669,10 @@ impl LocalProcess { self.exec_write(WriteParams { process_id: process_id.clone(), chunk: chunk.into(), + write_id: format!( + "local-{}", + NEXT_LOCAL_STDIN_WRITE_ID.fetch_add(1, Ordering::Relaxed) + ), }) .await .map_err(map_handler_error) @@ -1023,6 +1095,7 @@ mod tests { session: dummy_session(), tty: false, pipe_stdin: false, + accepted_stdin_write_ids: Arc::new(Mutex::new(AcceptedStdinWriteIds::default())), output: VecDeque::new(), retained_bytes: 0, next_seq: 1, diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index 32271ed207..97ef86aad2 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -154,6 +154,7 @@ pub struct ReadResponse { pub struct WriteParams { pub process_id: ProcessId, pub chunk: ByteChunk, + pub write_id: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/codex-rs/exec-server/tests/process.rs b/codex-rs/exec-server/tests/process.rs index 334e449224..571fd427fa 100644 --- a/codex-rs/exec-server/tests/process.rs +++ b/codex-rs/exec-server/tests/process.rs @@ -144,7 +144,8 @@ async fn exec_server_defaults_omitted_pipe_stdin_to_closed_stdin() -> anyhow::Re "process/write", serde_json::json!({ "processId": "proc-default-stdin", - "chunk": "aWdub3JlZAo=" + "chunk": "aWdub3JlZAo=", + "writeId": "write-default-stdin" }), ) .await?; @@ -171,6 +172,140 @@ async fn exec_server_defaults_omitted_pipe_stdin_to_closed_stdin() -> anyhow::Re Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_server_dedupes_retried_process_write_ids() -> anyhow::Result<()> { + let mut server = exec_server().await?; + let initialize_id = server + .send_request( + "initialize", + serde_json::to_value(InitializeParams { + client_name: "exec-server-test".to_string(), + resume_session_id: None, + })?, + ) + .await?; + let _ = server + .wait_for_event(|event| { + matches!( + event, + JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &initialize_id + ) + }) + .await?; + + server + .send_notification("initialized", serde_json::json!({})) + .await?; + + let process_start_id = server + .send_request( + "process/start", + serde_json::json!({ + "processId": "proc-write-id", + "argv": [ + "/bin/sh", + "-c", + "IFS= read -r first; printf 'line:%s\\n' \"$first\"; IFS= read -r second; printf 'line:%s\\n' \"$second\"" + ], + "cwd": std::env::current_dir()?, + "env": {}, + "tty": false, + "pipeStdin": true, + "arg0": null + }), + ) + .await?; + let _ = server + .wait_for_event(|event| { + matches!( + event, + JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &process_start_id + ) + }) + .await?; + + for (write_id, chunk) in [ + ("write-1", "Zmlyc3QK"), + ("write-1", "Zmlyc3QK"), + ("write-2", "c2Vjb25kCg=="), + ] { + let request_id = server + .send_request( + "process/write", + serde_json::json!({ + "processId": "proc-write-id", + "chunk": chunk, + "writeId": write_id + }), + ) + .await?; + let response = server + .wait_for_event(|event| { + matches!( + event, + JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &request_id + ) + }) + .await?; + let JSONRPCMessage::Response(JSONRPCResponse { result, .. }) = response else { + panic!("expected process/write response"); + }; + let write_response: WriteResponse = serde_json::from_value(result)?; + assert_eq!( + write_response, + WriteResponse { + status: WriteStatus::Accepted + } + ); + } + + let mut after_seq = None; + let mut output = Vec::new(); + for _ in 0..5 { + let read_id = server + .send_request( + "process/read", + serde_json::json!({ + "processId": "proc-write-id", + "afterSeq": after_seq, + "maxBytes": null, + "waitMs": 1000 + }), + ) + .await?; + let response = server + .wait_for_event(|event| { + matches!( + event, + JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &read_id + ) + }) + .await?; + let JSONRPCMessage::Response(JSONRPCResponse { result, .. }) = response else { + panic!("expected process/read response"); + }; + let read_response: ReadResponse = serde_json::from_value(result)?; + output.extend( + read_response + .chunks + .into_iter() + .flat_map(|chunk| chunk.chunk.into_inner()), + ); + after_seq = Some(read_response.next_seq.saturating_sub(1)); + if read_response.closed || output.ends_with(b"line:second\n") { + break; + } + } + + assert_eq!( + String::from_utf8(output)?, + "line:first\nline:second\n".to_string() + ); + + server.shutdown().await?; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn exec_server_resumes_detached_session_without_killing_processes() -> anyhow::Result<()> { let mut server = exec_server().await?; From 47ab51470b5051f4eba1694000416be43e880cda Mon Sep 17 00:00:00 2001 From: rka-oai Date: Thu, 18 Jun 2026 10:16:51 -0700 Subject: [PATCH 64/70] Pin Windows argument lint to Windows 2022 (#28940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Run the Windows argument-comment-lint job on the `windows-2022` hosted runner instead of the custom Windows runner pool. ## Why The custom pool recently moved from the Visual Studio 2022 Windows image to `windows-2025-vs2026`. Since that migration, the job fails while Bazel materializes LLVM external repository sources, before the argument lint itself runs. The same failure appears across unrelated PRs. This narrow change tests GitHub’s recommended mitigation for workloads that still require the Visual Studio 2022 image: https://github.com/actions/runner-images/issues/14017 ## How Use the standard `windows-2022` runner for only the Windows argument-comment-lint matrix entry. No product code or lint behavior changes. --- .github/workflows/rust-ci.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 4cf6d6e37a..59a7820151 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -173,11 +173,8 @@ jobs: runner: macos-15-xlarge timeout_minutes: 30 - name: Windows - runner: windows-x64 + runner: windows-2022 timeout_minutes: 30 - runs_on: - group: ${{ github.event.repository.name }}-runners - labels: ${{ github.event.repository.name }}-windows-x64 steps: - name: Check whether argument comment lint should run id: argument_comment_lint_gate From 790213ded0588d824e99f830f41f04f3d98196df Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 18 Jun 2026 18:31:07 +0100 Subject: [PATCH 65/70] Scope MCP sandbox metadata to server environment (#28914) Scope MCP sandbox metadata to the MCP server's owning environment. Previously, `codex/sandbox-state-meta` always used the turn's primary cwd and rebuilt a legacy sandbox policy from that cwd. That can be wrong for MCP servers owned by a different execution environment. This now sends the owning environment cwd as a `file:` URI in `sandboxCwd`, keeps `permissionProfile` as the permission source of truth, and omits sandbox-state metadata when a non-default server environment is not selected for the turn. Local/default MCP servers keep the existing fallback cwd behavior. Tests: - `just fmt` - `just bazel-lock-update` - `just bazel-lock-check` - `just test -p codex-mcp` - `just test -p codex-core mcp_sandbox_cwd` - `cargo build -p codex-rmcp-client --bin test_stdio_server` - `just test -p codex-core stdio_mcp_tool_call_includes_sandbox_state_meta` --- codex-rs/Cargo.lock | 1 + codex-rs/codex-mcp/Cargo.toml | 1 + codex-rs/codex-mcp/src/connection_manager.rs | 6 +++ .../codex-mcp/src/connection_manager_tests.rs | 3 ++ codex-rs/codex-mcp/src/runtime.rs | 5 +-- codex-rs/codex-mcp/src/server.rs | 2 + codex-rs/core/src/mcp_tool_call.rs | 38 +++++++++++++++---- codex-rs/core/src/mcp_tool_call_tests.rs | 35 +++++++++++++++++ codex-rs/core/tests/suite/rmcp_client.rs | 11 ++---- 9 files changed, 83 insertions(+), 19 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 844a84ef73..1cc32ea681 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3289,6 +3289,7 @@ dependencies = [ "codex-plugin", "codex-protocol", "codex-rmcp-client", + "codex-utils-path-uri", "codex-utils-plugins", "futures", "pretty_assertions", diff --git a/codex-rs/codex-mcp/Cargo.toml b/codex-rs/codex-mcp/Cargo.toml index 433cbde8b8..15866e49e4 100644 --- a/codex-rs/codex-mcp/Cargo.toml +++ b/codex-rs/codex-mcp/Cargo.toml @@ -26,6 +26,7 @@ codex-otel = { workspace = true } codex-plugin = { workspace = true } codex-protocol = { workspace = true } codex-rmcp-client = { workspace = true } +codex-utils-path-uri = { workspace = true } codex-utils-plugins = { workspace = true } futures = { workspace = true } regex-lite = { workspace = true } diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 3596486568..e50532714a 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -370,6 +370,12 @@ impl McpConnectionManager { .map(super::server::McpServerOrigin::as_str) } + pub fn server_environment_id(&self, server_name: &str) -> Option<&str> { + self.server_metadata + .get(server_name) + .map(|metadata| metadata.environment_id.as_str()) + } + pub fn server_pollutes_memory(&self, server_name: &str) -> bool { self.server_metadata .get(server_name) diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index bfbc09c473..20bb8d43cc 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -1127,6 +1127,7 @@ async fn list_all_tools_adds_server_metadata_to_cached_tools() { manager.server_metadata.insert( server_name.to_string(), McpServerMetadata { + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), pollutes_memory: true, origin: Some(McpServerOrigin::StreamableHttp( "https://docs.example".to_string(), @@ -1162,6 +1163,7 @@ fn server_metadata_preserves_tool_approval_policy() { "https://docs.example", /*apps_mcp_product_sku*/ None, ); + config.environment_id = "remote".to_string(); config.default_tools_approval_mode = Some(AppToolApproval::Prompt); config.tools.insert( "search".to_string(), @@ -1171,6 +1173,7 @@ fn server_metadata_preserves_tool_approval_policy() { ); let metadata = McpServerMetadata::from(&EffectiveMcpServer::configured(config)); + assert_eq!(metadata.environment_id, "remote"); assert_eq!(metadata.tool_approval_mode("read"), AppToolApproval::Prompt); assert_eq!( metadata.tool_approval_mode("search"), diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs index 5404bf1eb5..ff5c65db00 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -12,7 +12,7 @@ use std::time::Duration; use codex_exec_server::Environment; use codex_exec_server::EnvironmentManager; use codex_protocol::models::PermissionProfile; -use codex_protocol::protocol::SandboxPolicy; +use codex_utils_path_uri::PathUri; use serde::Deserialize; use serde::Serialize; @@ -22,9 +22,8 @@ use serde::Serialize; pub struct SandboxState { #[serde(default, skip_serializing_if = "Option::is_none")] pub permission_profile: Option, - pub sandbox_policy: SandboxPolicy, pub codex_linux_sandbox_exe: Option, - pub sandbox_cwd: PathBuf, + pub sandbox_cwd: PathUri, #[serde(default)] pub use_legacy_landlock: bool, } diff --git a/codex-rs/codex-mcp/src/server.rs b/codex-rs/codex-mcp/src/server.rs index 90952cbfaf..39287b2136 100644 --- a/codex-rs/codex-mcp/src/server.rs +++ b/codex-rs/codex-mcp/src/server.rs @@ -75,6 +75,7 @@ impl McpServerOrigin { /// Semantic metadata that must survive after the server is launched. #[derive(Debug, Clone)] pub(crate) struct McpServerMetadata { + pub environment_id: String, pub pollutes_memory: bool, pub origin: Option, pub supports_parallel_tool_calls: bool, @@ -96,6 +97,7 @@ impl From<&EffectiveMcpServer> for McpServerMetadata { fn from(server: &EffectiveMcpServer) -> Self { match server.launch() { McpServerLaunch::Configured(config) => Self { + environment_id: config.environment_id.clone(), pollutes_memory: true, origin: McpServerOrigin::from_transport(&config.transport), supports_parallel_tool_calls: config.supports_parallel_tool_calls, diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index e8cbb56c55..a89951542a 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -81,6 +81,7 @@ use codex_rollout::state_db; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::truncate_text; +use codex_utils_path_uri::PathUri; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use rmcp::model::ToolAnnotations; use serde::Deserialize; @@ -713,10 +714,8 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state( server: &str, mut meta: Option, ) -> anyhow::Result> { - let supports_sandbox_state_meta = sess - .services - .mcp_connection_manager - .load_full() + let mcp_connection_manager = sess.services.mcp_connection_manager.load_full(); + let supports_sandbox_state_meta = mcp_connection_manager .server_supports_sandbox_state_meta_capability(server) .await .unwrap_or(false); @@ -724,12 +723,17 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state( return Ok(meta); } + let server_environment_id = mcp_connection_manager + .server_environment_id(server) + .unwrap_or(codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID); + let Some(sandbox_cwd) = sandbox_cwd_for_mcp_server(turn_context, server_environment_id) else { + return Ok(meta); + }; + let permission_profile = turn_context.permission_profile(); let sandbox_state = serde_json::to_value(SandboxState { - permission_profile: Some(turn_context.permission_profile()), - sandbox_policy: turn_context.sandbox_policy(), + permission_profile: Some(permission_profile), codex_linux_sandbox_exe: turn_context.config.codex_linux_sandbox_exe.clone(), - #[allow(deprecated)] - sandbox_cwd: turn_context.cwd.to_path_buf(), + sandbox_cwd, use_legacy_landlock: turn_context.config.features.use_legacy_landlock(), })?; @@ -754,6 +758,24 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state( Ok(meta) } +fn sandbox_cwd_for_mcp_server(turn_context: &TurnContext, environment_id: &str) -> Option { + if let Some(environment) = turn_context + .environments + .turn_environments + .iter() + .find(|environment| environment.environment_id == environment_id) + { + return Some(environment.cwd().clone()); + } + + if environment_id == codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID { + #[allow(deprecated)] + return Some(PathUri::from_abs_path(&turn_context.cwd)); + } + + None +} + async fn maybe_mark_thread_memory_mode_polluted( sess: &Session, turn_context: &TurnContext, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 56d1ff9b6c..4249c0d4b0 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -3,6 +3,7 @@ use crate::config::ConfigBuilder; use crate::config::ManagedFeatures; use crate::session::tests::make_session_and_context; use crate::session::tests::make_session_and_context_with_rx; +use crate::session::turn_context::TurnEnvironment; use crate::state::ActiveTurn; use crate::test_support::models_manager_with_provider; use crate::tools::hook_names::HookToolName; @@ -31,6 +32,7 @@ use codex_rollout_trace::ToolDispatchInvocation; use codex_rollout_trace::ToolDispatchPayload; use codex_rollout_trace::ToolDispatchRequester; use codex_rollout_trace::replay_bundle; +use codex_utils_path_uri::PathUri; use core_test_support::hooks::trusted_config_layer_stack; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -1091,6 +1093,39 @@ async fn mcp_tool_call_request_meta_includes_turn_started_at_unix_ms() { ); } +#[tokio::test] +async fn mcp_sandbox_cwd_uses_matching_server_environment_uri() -> anyhow::Result<()> { + let (_, mut turn_context) = make_session_and_context().await; + let secondary_cwd = PathUri::parse("file:///C:/remote/project")?; + let environment = turn_context.environments.turn_environments[0] + .environment + .clone(); + turn_context + .environments + .turn_environments + .push(TurnEnvironment::new( + "remote".to_string(), + environment, + secondary_cwd.clone(), + /*shell*/ None, + )); + + let sandbox_cwd = sandbox_cwd_for_mcp_server(&turn_context, "remote"); + + assert_eq!(sandbox_cwd, Some(secondary_cwd)); + Ok(()) +} + +#[tokio::test] +async fn mcp_sandbox_cwd_is_none_for_unselected_server_environment() -> anyhow::Result<()> { + let (_, turn_context) = make_session_and_context().await; + + let sandbox_cwd = sandbox_cwd_for_mcp_server(&turn_context, "remote"); + + assert_eq!(sandbox_cwd, None); + Ok(()) +} + #[tokio::test] async fn plugin_mcp_tool_call_request_meta_includes_plugin_id() { let (_, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 2be014b3f5..656ca69317 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -815,16 +815,11 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> let sandbox_meta = meta .get(MCP_SANDBOX_STATE_META_CAPABILITY) .expect("sandbox state metadata should be present"); - let (sandbox_policy, _) = - turn_permission_fields(PermissionProfile::read_only(), fixture.config.cwd.as_path()); - let expected_sandbox_policy = serde_json::to_value(&sandbox_policy)?; - assert_eq!( - sandbox_meta.get("sandboxPolicy"), - Some(&expected_sandbox_policy) - ); + assert_eq!(sandbox_meta.get("sandboxPolicy"), None); + let expected_sandbox_cwd = PathUri::from_abs_path(&fixture.config.cwd).to_string(); assert_eq!( sandbox_meta.get("sandboxCwd").and_then(Value::as_str), - fixture.config.cwd.as_path().to_str() + Some(expected_sandbox_cwd.as_str()) ); assert_eq!(sandbox_meta.get("useLegacyLandlock"), Some(&json!(false))); From 9684ec25be97707ea5a0cc4dc0a2d23d466c4b41 Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 18 Jun 2026 18:40:28 +0100 Subject: [PATCH 66/70] Add turn-scoped context contributions (#28911) ## Summary - keep context injection on a single ContextContributor trait - split context injection into thread-scoped and turn-scoped contribution methods - wire turn-scoped fragments into initial context assembly so extensions can contribute context from turn-local state --- codex-rs/core/src/guardian/tests.rs | 2 +- codex-rs/core/src/session/mod.rs | 129 +++++++++++++++--- codex-rs/core/src/session/tests.rs | 92 ++++++++++++- .../examples/enabled_extensions.rs | 6 +- .../shared_state_extension.rs | 4 +- .../ext/extension-api/src/contributors.rs | 28 +++- .../extension-api/src/contributors/context.rs | 20 +++ codex-rs/ext/extension-api/src/lib.rs | 1 + codex-rs/ext/extension-api/tests/registry.rs | 44 +++++- codex-rs/ext/memories/src/extension.rs | 2 +- codex-rs/ext/memories/src/tests.rs | 2 +- codex-rs/ext/skills/src/extension.rs | 2 +- codex-rs/ext/skills/tests/skills_extension.rs | 6 +- codex-rs/protocol/src/protocol.rs | 2 +- 14 files changed, 307 insertions(+), 33 deletions(-) create mode 100644 codex-rs/ext/extension-api/src/contributors/context.rs diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index d6eb93dd14..15024990a2 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -107,7 +107,7 @@ impl codex_extension_api::ThreadLifecycleContributor for GuardianMemoryC } impl codex_extension_api::ContextContributor for GuardianMemoryContextProbe { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, _session_store: &'a codex_extension_api::ExtensionData, thread_store: &'a codex_extension_api::ExtensionData, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 0ecc706818..84e60a3d2d 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -58,7 +58,9 @@ use codex_exec_server::Environment; use codex_exec_server::EnvironmentManager; use codex_extension_api::ExtensionDataInit; use codex_extension_api::LoadedUserInstructions; +use codex_extension_api::PromptFragment; use codex_extension_api::PromptSlot; +use codex_extension_api::TurnContextContributionInput; use codex_features::FEATURES; use codex_features::Feature; use codex_features::unstable_features_warning_event; @@ -911,6 +913,25 @@ async fn thread_title_from_thread_store( (!title.is_empty() && thread.preview.trim() != title).then(|| title.to_string()) } +fn push_prompt_fragment( + fragment: PromptFragment, + developer_sections: &mut Vec, + contextual_user_sections: &mut Vec, + separate_developer_sections: &mut Vec, +) { + match fragment.slot() { + PromptSlot::DeveloperPolicy | PromptSlot::DeveloperCapabilities => { + developer_sections.push(fragment.text().to_string()); + } + PromptSlot::ContextualUser => { + contextual_user_sections.push(fragment.text().to_string()); + } + PromptSlot::SeparateDeveloper => { + separate_developer_sections.push(fragment.text().to_string()); + } + } +} + impl Session { pub(crate) async fn app_server_client_metadata(&self) -> AppServerClientMetadata { let state = self.state.lock().await; @@ -2860,6 +2881,57 @@ impl Session { } } + async fn build_turn_context_contribution_items( + &self, + turn_context: &TurnContext, + ) -> Vec { + let mut developer_sections = Vec::new(); + let mut contextual_user_sections = Vec::new(); + let mut separate_developer_sections = Vec::new(); + let context_contributors = self.services.extensions.context_contributors().to_vec(); + + for contributor in &context_contributors { + for fragment in contributor + .contribute_turn_context(TurnContextContributionInput { + thread_id: self.thread_id(), + turn_id: turn_context.sub_id.as_str(), + session_store: &self.services.session_extension_data, + thread_store: &self.services.thread_extension_data, + turn_store: turn_context.extension_data.as_ref(), + model_context_window: turn_context.model_context_window(), + }) + .await + { + push_prompt_fragment( + fragment, + &mut developer_sections, + &mut contextual_user_sections, + &mut separate_developer_sections, + ); + } + } + + let mut items = Vec::with_capacity(3); + if let Some(developer_message) = + crate::context_manager::updates::build_developer_update_item(developer_sections) + { + items.push(developer_message); + } + for section in separate_developer_sections { + if let Some(developer_message) = + crate::context_manager::updates::build_developer_update_item(vec![section]) + { + items.push(developer_message); + } + } + if let Some(contextual_user_message) = + crate::context_manager::updates::build_contextual_user_message(contextual_user_sections) + { + items.push(contextual_user_message); + } + items + } + pub(crate) async fn build_initial_context( &self, turn_context: &TurnContext, @@ -3026,25 +3098,40 @@ impl Session { developer_sections.push(plugin_instructions.render()); } let context_contributors = self.services.extensions.context_contributors().to_vec(); - for contributor in context_contributors { + for contributor in &context_contributors { for fragment in contributor - .contribute( + .contribute_thread_context( &self.services.session_extension_data, &self.services.thread_extension_data, ) .await { - match fragment.slot() { - PromptSlot::DeveloperPolicy | PromptSlot::DeveloperCapabilities => { - developer_sections.push(fragment.text().to_string()); - } - PromptSlot::ContextualUser => { - contextual_user_sections.push(fragment.text().to_string()); - } - PromptSlot::SeparateDeveloper => { - separate_developer_sections.push(fragment.text().to_string()); - } - } + push_prompt_fragment( + fragment, + &mut developer_sections, + &mut contextual_user_sections, + &mut separate_developer_sections, + ); + } + } + for contributor in &context_contributors { + for fragment in contributor + .contribute_turn_context(TurnContextContributionInput { + thread_id: self.thread_id(), + turn_id: turn_context.sub_id.as_str(), + session_store: &self.services.session_extension_data, + thread_store: &self.services.thread_extension_data, + turn_store: turn_context.extension_data.as_ref(), + model_context_window: turn_context.model_context_window(), + }) + .await + { + push_prompt_fragment( + fragment, + &mut developer_sections, + &mut contextual_user_sections, + &mut separate_developer_sections, + ); } } if let Some(user_instructions) = turn_context.user_instructions.as_deref() { @@ -3211,15 +3298,25 @@ impl Session { let state = self.state.lock().await; state.reference_context_item() }; + let turn_context_item = turn_context.to_turn_context_item(); + if reference_context_item.as_ref() == Some(&turn_context_item) { + return; + } let should_inject_full_context = reference_context_item.is_none(); - let context_items = if should_inject_full_context { + let mut context_items = if should_inject_full_context { self.build_initial_context(turn_context).await } else { - // Steady-state path: append only context diffs to minimize token overhead. + // Steady-state path: append only built-in context diffs here; turn-scoped extension + // context is added below. self.build_settings_update_items(reference_context_item.as_ref(), turn_context) .await }; - let turn_context_item = turn_context.to_turn_context_item(); + if !should_inject_full_context { + context_items.extend( + self.build_turn_context_contribution_items(turn_context) + .await, + ); + } if !context_items.is_empty() { self.record_conversation_items(turn_context, &context_items) .await; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 724c35f2e8..63e8783cc6 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -7540,9 +7540,13 @@ async fn make_multi_agent_v2_usage_hint_test_session( struct PromptExtensionTestContributor; struct PromptExtensionTestState; +struct TurnContextExtensionTestContributor; +struct TurnContextExtensionTestState { + expected_model_context_window: Option, +} impl codex_extension_api::ContextContributor for PromptExtensionTestContributor { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, _session_store: &'a codex_extension_api::ExtensionData, thread_store: &'a codex_extension_api::ExtensionData, @@ -7571,6 +7575,31 @@ fn prompt_extension_test_registry() Arc::new(builder.build()) } +impl codex_extension_api::ContextContributor for TurnContextExtensionTestContributor { + fn contribute_turn_context<'a>( + &'a self, + input: codex_extension_api::TurnContextContributionInput<'a>, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + Box::pin(async move { + let Some(state) = input.turn_store.get::() else { + return Vec::new(); + }; + (input.model_context_window == state.expected_model_context_window + && input.model_context_window.is_some() + && !input.turn_id.is_empty()) + .then(|| { + codex_extension_api::PromptFragment::developer_policy( + "turn context extension enabled", + ) + }) + .into_iter() + .collect() + }) + } +} + #[tokio::test] async fn build_initial_context_includes_prompt_fragments_from_extensions() { let (mut session, turn_context) = make_session_and_context().await; @@ -7592,6 +7621,67 @@ async fn build_initial_context_includes_prompt_fragments_from_extensions() { ); } +#[tokio::test] +async fn build_initial_context_includes_turn_context_fragments_from_extensions() { + let (mut session, mut turn_context) = make_session_and_context().await; + let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); + builder.prompt_contributor(Arc::new(TurnContextExtensionTestContributor)); + session.services.extensions = Arc::new(builder.build()); + turn_context.model_info.context_window = Some(100); + turn_context.model_info.effective_context_window_percent = 50; + turn_context + .extension_data + .insert(TurnContextExtensionTestState { + expected_model_context_window: Some(50), + }); + + let initial_context = session.build_initial_context(&turn_context).await; + let developer_messages = developer_message_texts(&initial_context); + + assert!( + developer_messages + .iter() + .flatten() + .any(|text| *text == "turn context extension enabled"), + "expected turn context extension developer text, got {developer_messages:?}" + ); +} + +#[tokio::test] +async fn record_context_updates_includes_turn_context_fragments_on_steady_state_turns() { + let (mut session, mut turn_context) = make_session_and_context().await; + let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); + builder.prompt_contributor(Arc::new(TurnContextExtensionTestContributor)); + session.services.extensions = Arc::new(builder.build()); + turn_context.model_info.context_window = Some(200); + turn_context.model_info.effective_context_window_percent = 25; + turn_context + .extension_data + .insert(TurnContextExtensionTestState { + expected_model_context_window: Some(50), + }); + let mut previous_context_item = turn_context.to_turn_context_item(); + previous_context_item.turn_id = Some("previous-turn-id".to_string()); + { + let mut state = session.state.lock().await; + state.set_reference_context_item(Some(previous_context_item)); + } + + session + .record_context_updates_and_set_reference_context_item(&turn_context) + .await; + + let history = session.clone_history().await; + let developer_messages = developer_message_texts(history.raw_items()); + assert!( + developer_messages + .iter() + .flatten() + .any(|text| *text == "turn context extension enabled"), + "expected steady-state turn context extension developer text, got {developer_messages:?}" + ); +} + #[tokio::test] async fn build_initial_context_omits_prompt_fragments_without_extension_state() { let (mut session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/ext/extension-api/examples/enabled_extensions.rs b/codex-rs/ext/extension-api/examples/enabled_extensions.rs index 9027c28241..45b178bc81 100644 --- a/codex-rs/ext/extension-api/examples/enabled_extensions.rs +++ b/codex-rs/ext/extension-api/examples/enabled_extensions.rs @@ -74,7 +74,11 @@ async fn contribute_prompt( ) -> Vec { let mut fragments = Vec::new(); for contributor in registry.context_contributors() { - fragments.extend(contributor.contribute(session_store, thread_store).await); + fragments.extend( + contributor + .contribute_thread_context(session_store, thread_store) + .await, + ); } fragments } diff --git a/codex-rs/ext/extension-api/examples/enabled_extensions/shared_state_extension.rs b/codex-rs/ext/extension-api/examples/enabled_extensions/shared_state_extension.rs index 531f65b99e..414a67215b 100644 --- a/codex-rs/ext/extension-api/examples/enabled_extensions/shared_state_extension.rs +++ b/codex-rs/ext/extension-api/examples/enabled_extensions/shared_state_extension.rs @@ -17,7 +17,7 @@ pub fn install(registry: &mut ExtensionRegistryBuilder<()>) { struct StyleContributor; impl ContextContributor for StyleContributor { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, session_store: &'a ExtensionData, thread_store: &'a ExtensionData, @@ -37,7 +37,7 @@ impl ContextContributor for StyleContributor { struct UsageContributor; impl ContextContributor for UsageContributor { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, session_store: &'a ExtensionData, thread_store: &'a ExtensionData, diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index 3a0fee49a9..7d16efd3d4 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -11,6 +11,7 @@ use codex_tools::ToolExecutor; use crate::ExtensionData; +mod context; mod mcp; mod prompt; mod thread_lifecycle; @@ -18,6 +19,7 @@ mod tool_lifecycle; mod turn_input; mod turn_lifecycle; +pub use context::TurnContextContributionInput; pub use mcp::McpServerContribution; pub use mcp::McpServerContributionContext; pub use prompt::PromptFragment; @@ -62,12 +64,34 @@ pub trait McpServerContributor: Send + Sync { } /// Extension contribution that adds prompt fragments during prompt assembly. +/// +/// Implementations should use the method matching the scope needed by the +/// fragment: thread/session context for stable inputs, and turn context for +/// fragments that depend on turn-local host state. pub trait ContextContributor: Send + Sync { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, session_store: &'a ExtensionData, thread_store: &'a ExtensionData, - ) -> ExtensionFuture<'a, Vec>; + ) -> ExtensionFuture<'a, Vec> { + Box::pin(async move { + let _self = self; + let _session_store = session_store; + let _thread_store = thread_store; + Vec::new() + }) + } + + fn contribute_turn_context<'a>( + &'a self, + input: TurnContextContributionInput<'a>, + ) -> ExtensionFuture<'a, Vec> { + Box::pin(async move { + let _self = self; + let _input = input; + Vec::new() + }) + } } /// Contributor for host-owned thread lifecycle gates. diff --git a/codex-rs/ext/extension-api/src/contributors/context.rs b/codex-rs/ext/extension-api/src/contributors/context.rs new file mode 100644 index 0000000000..fb6239ec4b --- /dev/null +++ b/codex-rs/ext/extension-api/src/contributors/context.rs @@ -0,0 +1,20 @@ +use codex_protocol::ThreadId; + +use crate::ExtensionData; + +/// Host context available while extensions contribute turn-scoped context fragments. +#[derive(Clone, Copy)] +pub struct TurnContextContributionInput<'a> { + /// Stable host-owned thread identifier. + pub thread_id: ThreadId, + /// Stable host-owned turn identifier. + pub turn_id: &'a str, + /// Store scoped to the host session runtime. + pub session_store: &'a ExtensionData, + /// Store scoped to this thread runtime. + pub thread_store: &'a ExtensionData, + /// Store scoped to this turn. + pub turn_store: &'a ExtensionData, + /// Effective model context window for this turn, when known. + pub model_context_window: Option, +} diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index e646a53a52..de0e0c99f8 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -54,6 +54,7 @@ pub use contributors::ToolLifecycleContributor; pub use contributors::ToolLifecycleFuture; pub use contributors::ToolStartInput; pub use contributors::TurnAbortInput; +pub use contributors::TurnContextContributionInput; pub use contributors::TurnErrorInput; pub use contributors::TurnInputContext; pub use contributors::TurnInputContributor; diff --git a/codex-rs/ext/extension-api/tests/registry.rs b/codex-rs/ext/extension-api/tests/registry.rs index 79773a98cf..59b4f8c5e4 100644 --- a/codex-rs/ext/extension-api/tests/registry.rs +++ b/codex-rs/ext/extension-api/tests/registry.rs @@ -12,12 +12,14 @@ use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionFuture; use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::PromptFragment; +use codex_extension_api::PromptSlot; use codex_extension_api::ThreadLifecycleContributor; use codex_extension_api::TokenUsageContributor; use codex_extension_api::ToolCall; use codex_extension_api::ToolContributor; use codex_extension_api::ToolExecutor; use codex_extension_api::ToolLifecycleContributor; +use codex_extension_api::TurnContextContributionInput; use codex_extension_api::TurnInputContext; use codex_extension_api::TurnInputContributor; use codex_extension_api::TurnItemContributor; @@ -34,7 +36,7 @@ use pretty_assertions::assert_eq; struct AllContributors; impl ContextContributor for AllContributors { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, _session_store: &'a ExtensionData, _thread_store: &'a ExtensionData, @@ -147,7 +149,7 @@ async fn build_round_trips_every_contributor_category() { struct NamedContextContributor(&'static str); impl ContextContributor for NamedContextContributor { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, _session_store: &'a ExtensionData, _thread_store: &'a ExtensionData, @@ -158,6 +160,20 @@ impl ContextContributor for NamedContextContributor { } } +struct NamedTurnContextContributor(&'static str); + +impl ContextContributor for NamedTurnContextContributor { + fn contribute_turn_context<'a>( + &'a self, + _input: TurnContextContributionInput<'a>, + ) -> ExtensionFuture<'a, Vec> { + Box::pin(std::future::ready(vec![PromptFragment::new( + PromptSlot::ContextualUser, + self.0, + )])) + } +} + struct RecordingTurnItemContributor { name: &'static str, calls: Arc>>, @@ -186,6 +202,8 @@ async fn contributors_preserve_registration_order() { let mut builder = ExtensionRegistryBuilder::<()>::new(); builder.prompt_contributor(Arc::new(NamedContextContributor("first"))); builder.prompt_contributor(Arc::new(NamedContextContributor("second"))); + builder.prompt_contributor(Arc::new(NamedTurnContextContributor("turn-first"))); + builder.prompt_contributor(Arc::new(NamedTurnContextContributor("turn-second"))); for name in ["first", "second"] { builder.turn_item_contributor(Arc::new(RecordingTurnItemContributor { name, @@ -199,7 +217,25 @@ async fn contributors_preserve_registration_order() { let mut fragments = Vec::new(); for contributor in registry.context_contributors() { - fragments.extend(contributor.contribute(&session_store, &thread_store).await); + fragments.extend( + contributor + .contribute_thread_context(&session_store, &thread_store) + .await, + ); + } + for contributor in registry.context_contributors() { + fragments.extend( + contributor + .contribute_turn_context(TurnContextContributionInput { + thread_id: codex_protocol::ThreadId::default(), + turn_id: turn_store.level_id(), + session_store: &session_store, + thread_store: &thread_store, + turn_store: &turn_store, + model_context_window: Some(123), + }) + .await, + ); } let mut item = TurnItem::HookPrompt(HookPromptItem { id: "item".to_string(), @@ -217,6 +253,8 @@ async fn contributors_preserve_registration_order() { vec![ PromptFragment::developer_policy("first"), PromptFragment::developer_policy("second"), + PromptFragment::new(PromptSlot::ContextualUser, "turn-first"), + PromptFragment::new(PromptSlot::ContextualUser, "turn-second"), ] ); assert_eq!( diff --git a/codex-rs/ext/memories/src/extension.rs b/codex-rs/ext/memories/src/extension.rs index 23c80a8779..464ce24576 100644 --- a/codex-rs/ext/memories/src/extension.rs +++ b/codex-rs/ext/memories/src/extension.rs @@ -48,7 +48,7 @@ impl MemoriesExtensionConfig { } impl ContextContributor for MemoriesExtension { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, _session_store: &'a ExtensionData, thread_store: &'a ExtensionData, diff --git a/codex-rs/ext/memories/src/tests.rs b/codex-rs/ext/memories/src/tests.rs index b78c20c9f1..9cc038b800 100644 --- a/codex-rs/ext/memories/src/tests.rs +++ b/codex-rs/ext/memories/src/tests.rs @@ -181,7 +181,7 @@ async fn prompt_contribution_uses_memory_summary_when_enabled() { }); let fragments = extension - .contribute(&ExtensionData::new("session"), &thread_store) + .contribute_thread_context(&ExtensionData::new("session"), &thread_store) .await; assert_eq!(fragments.len(), 1); diff --git a/codex-rs/ext/skills/src/extension.rs b/codex-rs/ext/skills/src/extension.rs index c82ce66423..0a3da0972a 100644 --- a/codex-rs/ext/skills/src/extension.rs +++ b/codex-rs/ext/skills/src/extension.rs @@ -103,7 +103,7 @@ impl ContextContributor for SkillsExtension where C: Send + Sync + 'static, { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, session_store: &'a ExtensionData, thread_store: &'a ExtensionData, diff --git a/codex-rs/ext/skills/tests/skills_extension.rs b/codex-rs/ext/skills/tests/skills_extension.rs index cd6f46d9c9..16d9bdd1a3 100644 --- a/codex-rs/ext/skills/tests/skills_extension.rs +++ b/codex-rs/ext/skills/tests/skills_extension.rs @@ -183,7 +183,7 @@ async fn selected_executor_catalog_is_context_and_selected_entrypoint_is_turn_in .await; let prompt_fragments = registry.context_contributors()[0] - .contribute(&session_store, &thread_store) + .contribute_thread_context(&session_store, &thread_store) .await; assert_eq!(1, prompt_fragments.len()); assert!( @@ -228,7 +228,7 @@ async fn selected_executor_catalog_is_context_and_selected_entrypoint_is_turn_in read_request_keys(&read_requests) ); let rebuilt_prompt_fragments = registry.context_contributors()[0] - .contribute(&session_store, &thread_store) + .contribute_thread_context(&session_store, &thread_store) .await; assert_eq!(1, rebuilt_prompt_fragments.len()); assert!(rebuilt_prompt_fragments[0].text().contains("lint-fix")); @@ -294,7 +294,7 @@ async fn orchestrator_catalog_snapshot_caches_failure() -> TestResult { .await; let initial_fragments = registry.context_contributors()[0] - .contribute(&session_store, &thread_store) + .contribute_thread_context(&session_store, &thread_store) .await; assert!(initial_fragments.is_empty()); let EventMsg::Warning(warning) = event_rx.try_recv()?.msg else { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index d51a30addf..a71eb64fe4 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2992,7 +2992,7 @@ pub struct TurnContextNetworkItem { /// context updates, and again after mid-turn compaction when replacement /// history re-establishes full context, so resume/fork replay can recover the /// latest durable baseline. -#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)] pub struct TurnContextItem { #[serde(default, skip_serializing_if = "Option::is_none")] pub turn_id: Option, From e8dd1b45cbfe241abf85c6696f7ab07e85a0c5d5 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 18 Jun 2026 10:50:15 -0700 Subject: [PATCH 67/70] Fix goal-first live threads missing from thread/list (#28808) Fixes #28263. ## Why When a thread starts with `/goal`, the goal extension can update SQLite goal state before the thread has any user-turn rollout items. `thread/list` and `thread/search` rely on persisted listing metadata, so a goal-first live thread could be absent from app-server listings after restart even though the goal itself existed. This regressed when goal handling moved out of core: the core path wrote the goal update through the live thread rollout path, while the extension-backed app-server path only updated goal state and emitted the live notification. ## What - Add `GoalSetOutcome::thread_goal_updated_item()` so the goal extension owns the canonical `ThreadGoalUpdated` rollout item shape. - Expose a narrow `CodexThread::append_rollout_items()` helper that appends through the live thread and keeps derived SQLite metadata in sync. - When app-server sets a goal on an active live thread, persist the goal update through that live-thread path. - Add an app-server regression test that starts a live thread with `thread/goal/set` and verifies it appears in state-DB-only `thread/list`. ## Verification - `env -u CODEX_SQLITE_HOME just test -p codex-app-server goal_first_live_thread_appears_in_state_db_thread_list` --- .../thread_goal_processor.rs | 15 ++++ .../tests/suite/v2/thread_resume.rs | 90 +++++++++++++++++++ codex-rs/core/src/codex_thread.rs | 13 +++ codex-rs/ext/goal/src/api.rs | 11 +++ 4 files changed, 129 insertions(+) diff --git a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs index 2f40ce6602..5878047237 100644 --- a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs @@ -135,6 +135,21 @@ impl ThreadGoalRequestProcessor { .await .map_err(goal_service_error)?; let goal = ThreadGoal::from(outcome.goal.clone()); + + let persist_result = match self.thread_manager.get_thread(thread_id).await { + Ok(thread) => { + // Live goal-first threads can be listed before any user turn is written. + // Use the live path so JSONL and SQLite preview metadata stay in sync. + thread + .append_rollout_items(&[outcome.thread_goal_updated_item()]) + .await + } + Err(_) => Ok(()), + }; + if let Err(err) = persist_result { + warn!("failed to persist goal update for live thread {thread_id}: {err}"); + } + self.outgoing .send_response( request_id.clone(), 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 03e30e373e..c191263d1c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -33,6 +33,7 @@ use codex_app_server_protocol::ThreadGoalClearResponse; use codex_app_server_protocol::ThreadGoalSetResponse; use codex_app_server_protocol::ThreadGoalStatus; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams; use codex_app_server_protocol::ThreadMetadataUpdateParams; use codex_app_server_protocol::ThreadReadParams; @@ -464,6 +465,95 @@ async fn thread_goal_get_rejects_unmaterialized_thread() -> Result<()> { Ok(()) } +#[tokio::test] +async fn goal_first_live_thread_appears_in_state_db_thread_list() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + let codex_home_path = normalized_existing_path(codex_home.path())?; + create_config_toml(&codex_home_path, &server.uri())?; + let config_path = codex_home_path.join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace("personality = true\n", "personality = true\ngoals = true\n"), + )?; + + let sqlite_home = codex_home_path + .as_path() + .to_str() + .expect("test codex home should be utf-8"); + let mut mcp = TestAppServer::new_without_managed_config_with_env( + &codex_home_path, + &[("CODEX_SQLITE_HOME", Some(sqlite_home))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2-codex".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, cwd, .. } = to_response::(start_resp)?; + + let goal_id = mcp + .send_raw_request( + "thread/goal/set", + Some(json!({ + "threadId": thread.id.clone(), + "objective": "keep the goal-first thread visible", + "status": "paused", + })), + ) + .await?; + let goal_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(goal_id)), + ) + .await??; + let _goal: ThreadGoalSetResponse = to_response(goal_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/updated"), + ) + .await??; + + let list_id = mcp + .send_raw_request( + "thread/list", + Some(json!({ + "limit": 10, + "modelProviders": ["mock_provider"], + "sourceKinds": ["vscode"], + "archived": false, + "cwd": cwd.as_path().to_string_lossy().to_string(), + "useStateDbOnly": true, + })), + ) + .await?; + let list_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let list: ThreadListResponse = to_response(list_resp)?; + assert_eq!( + list.data + .iter() + .map(|thread| &thread.id) + .collect::>(), + vec![&thread.id] + ); + + Ok(()) +} + #[tokio::test] async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 99e0391d3b..1df188fc31 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -24,6 +24,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::Op; +use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; @@ -535,6 +536,18 @@ impl CodexThread { live_thread.update_metadata(patch, include_archived).await } + /// Appends rollout items through the live thread so derived metadata stays in sync. + pub async fn append_rollout_items(&self, items: &[RolloutItem]) -> ThreadStoreResult<()> { + let live_thread = self + .codex + .session + .live_thread_for_persistence("append rollout items") + .map_err(|err| ThreadStoreError::Internal { + message: err.to_string(), + })?; + live_thread.append_items(items).await + } + pub fn state_db(&self) -> Option { self.codex.state_db() } diff --git a/codex-rs/ext/goal/src/api.rs b/codex-rs/ext/goal/src/api.rs index a4dc93409e..807f481d0d 100644 --- a/codex-rs/ext/goal/src/api.rs +++ b/codex-rs/ext/goal/src/api.rs @@ -6,8 +6,11 @@ use std::sync::PoisonError; use std::sync::Weak; use codex_protocol::ThreadId; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; +use codex_protocol::protocol::ThreadGoalUpdatedEvent; use codex_protocol::protocol::validate_thread_goal_objective; use crate::runtime::GoalRuntimeHandle; @@ -61,6 +64,14 @@ pub struct GoalSetOutcome { } impl GoalSetOutcome { + pub fn thread_goal_updated_item(&self) -> RolloutItem { + RolloutItem::EventMsg(EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { + thread_id: self.goal.thread_id, + turn_id: None, + goal: self.goal.clone(), + })) + } + pub async fn apply_runtime_effects(&self, goal_service: &GoalService) { if let Some(runtime) = goal_service.runtime_for_thread(self.goal.thread_id) && let Err(err) = runtime From 4c7228e423839fb6bd6e2f2a6abeb397ab3f2e28 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Thu, 18 Jun 2026 11:03:42 -0700 Subject: [PATCH 68/70] [codex] Initialize exec-server OpenTelemetry at startup (#25019) ## Summary - Initialize stderr tracing and the configured OpenTelemetry provider for local and remote `codex exec-server` startup. - Instrument the local and remote server entrypoints with a root runtime span. - Keep raw Noise environment, registration, and stream identifiers out of exported spans while preserving them in local debug events. - Keep telemetry setup in a focused CLI module instead of growing the top-level command entrypoint. ## Stack - Previous: none (`#27058` has merged) - Next: #27466 ## Validation - `just test -p codex-exec-server --lib` (139 passed) - `just test -p codex-cli --test exec_server` (3 passed) - `just bazel-lock-check` - `just fix -p codex-exec-server -p codex-cli` - `just fmt` --------- Co-authored-by: Richard Lee --- codex-rs/cli/src/exec_server_telemetry.rs | 41 +++++++++++++++++++ codex-rs/cli/src/main.rs | 19 ++++++--- codex-rs/cli/tests/exec_server.rs | 15 +++++++ .../exec-server/src/noise_relay/harness.rs | 10 ++--- codex-rs/exec-server/src/relay.rs | 14 +++---- codex-rs/exec-server/src/remote.rs | 5 +++ codex-rs/exec-server/src/server.rs | 5 +++ 7 files changed, 88 insertions(+), 21 deletions(-) create mode 100644 codex-rs/cli/src/exec_server_telemetry.rs diff --git a/codex-rs/cli/src/exec_server_telemetry.rs b/codex-rs/cli/src/exec_server_telemetry.rs new file mode 100644 index 0000000000..4d688a0159 --- /dev/null +++ b/codex-rs/cli/src/exec_server_telemetry.rs @@ -0,0 +1,41 @@ +use tracing_subscriber::EnvFilter; +use tracing_subscriber::prelude::*; + +const DEFAULT_ANALYTICS_ENABLED: bool = false; +const DEFAULT_LOG_FILTER: &str = "error,opentelemetry_sdk=off,opentelemetry_otlp=off"; +const OTEL_SERVICE_NAME: &str = "codex-exec-server"; + +pub(crate) fn init( + config: Option<&codex_core::config::Config>, +) -> Result> { + let fmt_layer = tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .with_filter(stderr_env_filter()); + let otel = match config { + Some(config) => codex_core::otel_init::build_provider( + config, + env!("CARGO_PKG_VERSION"), + Some(OTEL_SERVICE_NAME), + DEFAULT_ANALYTICS_ENABLED, + ), + None => Ok(None), + }; + let provider = otel.as_ref().ok().and_then(Option::as_ref); + codex_core::otel_init::record_process_start(provider, OTEL_SERVICE_NAME); + + let otel_logger_layer = provider.and_then(|otel| otel.logger_layer()); + let otel_tracing_layer = provider.and_then(|otel| otel.tracing_layer()); + let _ = tracing_subscriber::registry() + .with(fmt_layer) + .with(otel_tracing_layer) + .with(otel_logger_layer) + .try_init(); + tracing::callsite::rebuild_interest_cache(); + otel +} + +fn stderr_env_filter() -> EnvFilter { + EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new(DEFAULT_LOG_FILTER)) + .unwrap_or_else(|_| EnvFilter::new("error")) +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index f6c5fbfb98..8b633265e5 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -49,6 +49,7 @@ mod app_cmd; #[cfg(any(target_os = "macos", target_os = "windows"))] mod desktop_app; mod doctor; +mod exec_server_telemetry; mod marketplace_cmd; mod mcp_cmd; mod plugin_cmd; @@ -1692,6 +1693,9 @@ async fn run_exec_server_command( .environment_id .ok_or_else(|| anyhow::anyhow!("--environment-id is required when --remote is set"))?; let config = load_exec_server_config(root_config_overrides, strict_config).await?; + let _otel = exec_server_telemetry::init(Some(&config)) + .inspect_err(|err| eprintln!("Could not create otel exporter: {err}")) + .ok(); let auth_provider = load_exec_server_remote_auth_provider(&config, &base_url, cmd.use_agent_identity_auth) .await?; @@ -1706,12 +1710,15 @@ async fn run_exec_server_command( codex_exec_server::run_remote_environment(remote_config, runtime_paths).await?; Ok(()) } else { - if strict_config { - // Local exec-server startup does not consume Config, but strict - // mode should still reject unknown fields before opening a listener. - let _validated_config = - load_exec_server_config(root_config_overrides, strict_config).await?; - } + let config_result = load_exec_server_config(root_config_overrides, strict_config).await; + let config = if strict_config { + Some(config_result?) + } else { + config_result.ok() + }; + let _otel = exec_server_telemetry::init(config.as_ref()) + .inspect_err(|err| eprintln!("Could not create otel exporter: {err}")) + .ok(); let listen_url = cmd .listen .as_deref() diff --git a/codex-rs/cli/tests/exec_server.rs b/codex-rs/cli/tests/exec_server.rs index 0afe0d37b5..5e412a9de1 100644 --- a/codex-rs/cli/tests/exec_server.rs +++ b/codex-rs/cli/tests/exec_server.rs @@ -1,6 +1,7 @@ use std::path::Path; use anyhow::Result; +use predicates::prelude::PredicateBooleanExt; use predicates::str::contains; use tempfile::TempDir; @@ -33,3 +34,17 @@ foo = "bar" Ok(()) } + +#[test] +fn local_exec_server_ignores_invalid_config_without_strict_config() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write(codex_home.path().join("config.toml"), "not valid toml = [")?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["exec-server", "--listen", "stdio"]) + .assert() + .success() + .stderr(contains("not valid toml").not()); + + Ok(()) +} diff --git a/codex-rs/exec-server/src/noise_relay/harness.rs b/codex-rs/exec-server/src/noise_relay/harness.rs index ef75f25263..1989525224 100644 --- a/codex-rs/exec-server/src/noise_relay/harness.rs +++ b/codex-rs/exec-server/src/noise_relay/harness.rs @@ -86,12 +86,10 @@ where let (outgoing_tx, mut outgoing_rx) = mpsc::channel(CHANNEL_CAPACITY); let (incoming_tx, incoming_rx) = mpsc::channel(CHANNEL_CAPACITY); let (disconnected_tx, disconnected_rx) = watch::channel(false); - let stream_span = tracing::debug_span!( - "noise_relay.stream", - noise_side = "harness", - environment_id = %environment_id, - executor_registration_id = %executor_registration_id, - stream_id = %stream_id, + let stream_span = tracing::debug_span!("noise_relay.stream", noise_side = "harness",); + debug!( + environment_id, + executor_registration_id, stream_id, "Noise harness relay details" ); let websocket_task = tokio::spawn(async move { diff --git a/codex-rs/exec-server/src/relay.rs b/codex-rs/exec-server/src/relay.rs index 1202ec2d12..033da4b2a7 100644 --- a/codex-rs/exec-server/src/relay.rs +++ b/codex-rs/exec-server/src/relay.rs @@ -440,15 +440,7 @@ pub(crate) trait HarnessKeyValidator: Send + Sync { /// /// Parsing the first Noise message authenticates the harness key. Only a /// successful registry check turns that pending handshake into a virtual stream. -#[tracing::instrument( - level = "debug", - skip_all, - fields( - noise_side = "executor", - environment_id = %environment_id, - executor_registration_id = %executor_registration_id, - ) -)] +#[tracing::instrument(level = "debug", skip_all, fields(noise_side = "executor"))] pub(crate) async fn run_multiplexed_environment( stream: WebSocketStream, processor: ConnectionProcessor, @@ -460,6 +452,10 @@ pub(crate) async fn run_multiplexed_environment( S: AsyncRead + AsyncWrite + Unpin + Send + 'static, V: HarnessKeyValidator + Clone + 'static, { + debug!( + environment_id, + executor_registration_id, "Noise executor relay details" + ); let (mut websocket_sink, mut websocket_stream) = stream.split(); let (physical_outgoing_tx, mut physical_outgoing_rx) = mpsc::channel::>(CHANNEL_CAPACITY); diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index e2111318eb..199e787542 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -408,6 +408,11 @@ impl RemoteEnvironmentConfig { /// reconnects. The registration and rendezvous URL are also reused until /// rendezvous rejects the URL, at which point the next attempt registers again. /// The websocket carries cleartext routing metadata and encrypted payloads. +#[tracing::instrument( + name = "codex.exec_server", + skip_all, + fields(otel.kind = "internal") +)] pub async fn run_remote_environment( config: RemoteEnvironmentConfig, runtime_paths: ExecServerRuntimePaths, diff --git a/codex-rs/exec-server/src/server.rs b/codex-rs/exec-server/src/server.rs index bf33eb77ba..d443b0ed1f 100644 --- a/codex-rs/exec-server/src/server.rs +++ b/codex-rs/exec-server/src/server.rs @@ -13,6 +13,11 @@ pub use transport::ExecServerListenUrlParseError; use crate::ExecServerRuntimePaths; +#[tracing::instrument( + name = "codex.exec_server", + skip_all, + fields(otel.kind = "internal") +)] pub async fn run_main( listen_url: &str, runtime_paths: ExecServerRuntimePaths, From afbb69a2fb52d7cb9b2f4fa1c50948dcb6d5a029 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Thu, 18 Jun 2026 11:04:30 -0700 Subject: [PATCH 69/70] [codex] Fix Windows sandbox runtime ACL refresh (#28943) ## Why Codex Desktop repairs sandbox-user read/execute access for binaries copied to `%LOCALAPPDATA%\OpenAI\Codex\bin`, but Computer Use launches its bundled Node runtime from `%LOCALAPPDATA%\OpenAI\Codex\runtimes`. On fresh Windows installations, `CodexSandboxUsers` may therefore be unable to execute the bundled Node binary. The command runner starts, but `CreateProcessAsUserW` fails with error 5 (`ACCESS_DENIED`), causing the Node REPL to exit before Computer Use can discover applications. This is a follow-up to #21564, which added the original runtime `bin` ACL repair. ## What changed - Expand the Codex Desktop runtime ACL roots from only `bin` to both `bin` and `runtimes`. - Apply the existing inherited read/execute ACL repair to each runtime directory when it exists. - Rename the setup helper to reflect that it now handles multiple runtime paths. ## Validation - `cargo fmt -- --check` - `just test -p codex-windows-sandbox` was run: 113 tests passed and five environment-dependent legacy execution tests failed because `CreateRestrictedToken` returned error 87. --- .../src/bin/setup_main/win.rs | 2 +- .../bin/setup_main/win/setup_runtime_bin.rs | 145 +++++++++--------- 2 files changed, 76 insertions(+), 71 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs index e3376f4055..e1146987bf 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs @@ -819,7 +819,7 @@ fn run_setup_full(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Res } if refresh_only { - setup_runtime_bin::ensure_codex_app_runtime_bin_readable( + setup_runtime_bin::ensure_codex_app_runtime_paths_readable( sandbox_group_psid, &mut refresh_errors, log, diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs index 47cacf72e0..509670e36b 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs @@ -10,83 +10,88 @@ use windows_sys::Win32::Security::OBJECT_INHERIT_ACE; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_EXECUTE; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; -pub(super) fn ensure_codex_app_runtime_bin_readable( +pub(super) fn ensure_codex_app_runtime_paths_readable( sandbox_group_psid: *mut c_void, refresh_errors: &mut Vec, log: &mut dyn Write, ) -> Result<()> { - let local_app_data = std::env::var_os("LOCALAPPDATA") + let local_app_data = local_app_data_root(); + let Some(local_app_data) = local_app_data else { + return Ok(()); + }; + + let read_execute_mask = FILE_GENERIC_READ | FILE_GENERIC_EXECUTE; + let codex_root = local_app_data.join("OpenAI").join("Codex"); + + for runtime_path in [codex_root.join("bin"), codex_root.join("runtimes")] { + if !runtime_path.is_dir() { + continue; + } + + let has_access = match path_mask_allows( + &runtime_path, + &[sandbox_group_psid], + read_execute_mask, + /*require_all_bits*/ true, + ) { + Ok(has_access) => has_access, + Err(err) => { + refresh_errors.push(format!( + "runtime read/execute mask check failed on {} for sandbox_group: {err}", + runtime_path.display() + )); + super::log_line( + log, + &format!( + "runtime read/execute mask check failed on {} for sandbox_group: {err}; continuing", + runtime_path.display() + ), + )?; + false + } + }; + if has_access { + continue; + } + + super::log_line( + log, + &format!( + "granting read/execute ACE to {} for sandbox users", + runtime_path.display() + ), + )?; + let result = unsafe { + ensure_allow_mask_aces_with_inheritance( + &runtime_path, + &[sandbox_group_psid], + read_execute_mask, + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE, + ) + }; + if let Err(err) = result { + refresh_errors.push(format!( + "grant read/execute ACE failed on {} for sandbox_group: {err}", + runtime_path.display() + )); + super::log_line( + log, + &format!( + "grant read/execute ACE failed on {} for sandbox_group: {err}", + runtime_path.display() + ), + )?; + } + } + Ok(()) +} + +fn local_app_data_root() -> Option { + std::env::var_os("LOCALAPPDATA") .map(PathBuf::from) .or_else(|| { std::env::var_os("USERPROFILE") .map(PathBuf::from) .map(|profile| profile.join("AppData").join("Local")) - }); - let Some(local_app_data) = local_app_data else { - return Ok(()); - }; - - // Codex desktop copies bundled Windows binaries out of WindowsApps to this - // fixed LocalAppData cache before launching codex.exe. - let runtime_bin_dir = local_app_data.join("OpenAI").join("Codex").join("bin"); - if !runtime_bin_dir.is_dir() { - return Ok(()); - } - - let read_execute_mask = FILE_GENERIC_READ | FILE_GENERIC_EXECUTE; - let has_access = match path_mask_allows( - &runtime_bin_dir, - &[sandbox_group_psid], - read_execute_mask, - /*require_all_bits*/ true, - ) { - Ok(has_access) => has_access, - Err(err) => { - refresh_errors.push(format!( - "runtime bin read/execute mask check failed on {} for sandbox_group: {err}", - runtime_bin_dir.display() - )); - super::log_line( - log, - &format!( - "runtime bin read/execute mask check failed on {} for sandbox_group: {err}; continuing", - runtime_bin_dir.display() - ), - )?; - false - } - }; - if has_access { - return Ok(()); - } - - super::log_line( - log, - &format!( - "granting read/execute ACE to {} for sandbox users", - runtime_bin_dir.display() - ), - )?; - let result = unsafe { - ensure_allow_mask_aces_with_inheritance( - &runtime_bin_dir, - &[sandbox_group_psid], - read_execute_mask, - OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE, - ) - }; - if let Err(err) = result { - refresh_errors.push(format!( - "grant read/execute ACE failed on {} for sandbox_group: {err}", - runtime_bin_dir.display() - )); - super::log_line( - log, - &format!( - "grant read/execute ACE failed on {} for sandbox_group: {err}", - runtime_bin_dir.display() - ), - )?; - } - Ok(()) + }) } From 636a2594c6f1ab691a79473388872487c8368ba8 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Thu, 18 Jun 2026 11:05:53 -0700 Subject: [PATCH 70/70] Synchronize realtime notification test requests (#28946) ## What Deliver the scripted realtime notification batch after the assistant text append request instead of after the preceding developer text append request. ## Why The batch ends with an upstream error that closes the realtime conversation. When it is emitted after the developer append, it races the subsequent assistant append: the app-server RPC can acknowledge the append before its downstream WebSocket send completes, and the test intermittently observes three requests instead of four. Making the fake server wait for the assistant append before emitting the terminal batch establishes the ordering the test asserts without sleeps or production-code changes. ## Validation - `git diff --check` - CI (the failure is timing-dependent and most reproducible in the Windows Bazel shard) --- codex-rs/app-server/tests/suite/v2/realtime_conversation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index c09278bb88..d9f7d6fd37 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -538,6 +538,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { "session": { "id": "sess_backend", "instructions": "backend prompt" } })], vec![], + vec![], vec![ json!({ "type": "response.output_audio.delta", @@ -590,7 +591,6 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { "message": "upstream boom" }), ], - vec![], ]]) .await;