From 83af3abc685149ede307cc754057bc597d7e0169 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Tue, 19 May 2026 13:31:57 -0700 Subject: [PATCH 01/63] Refactor exec-server websocket pump (#23327) ## Why Exec-server websocket handling had separate reader and writer tasks for the same socket. That made websocket control-frame handling asymmetric: the task reading frames could observe `Ping`, but the task allowed to write frames was elsewhere. This PR moves each physical websocket onto one always-running pump so the socket owner can handle application frames and websocket control frames together. ## What changed - Refactored direct exec-server websocket connections in `connection.rs` to use one task that owns the websocket for outbound JSON-RPC, inbound JSON-RPC, periodic keepalive pings, and `Ping` -> `Pong` replies. - Refactored relay websocket handling in `relay.rs` the same way for both the harness-side logical connection and the multiplexed executor physical socket. - Preserved the existing keepalive ownership policy: outbound direct websocket clients still send periodic pings, inbound Axum accepts only reply with pongs, and relay physical websocket endpoints keep their existing periodic pings. - Added focused websocket pump tests for ping/pong, binary JSON-RPC, relay data, malformed relay text frames, and close/disconnect behavior. - Reconnect behavior is intentionally left for a follow-up. ## Validation - Devbox Bazel focused unit target: - `//codex-rs/exec-server:exec-server-unit-tests --test_filter='websocket_connection_|harness_connection_|multiplexed_executor_'` --- codex-rs/exec-server/src/connection.rs | 468 +++++++++++++------- codex-rs/exec-server/src/relay.rs | 568 ++++++++++++++++--------- 2 files changed, 697 insertions(+), 339 deletions(-) diff --git a/codex-rs/exec-server/src/connection.rs b/codex-rs/exec-server/src/connection.rs index cf504bbea7..b211c504ac 100644 --- a/codex-rs/exec-server/src/connection.rs +++ b/codex-rs/exec-server/src/connection.rs @@ -323,37 +323,20 @@ impl JsonRpcConnection { where S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { - let (websocket_writer, websocket_reader) = stream.split(); - Self::from_websocket_parts( - websocket_writer, - websocket_reader, - connection_label, - Some(WEBSOCKET_KEEPALIVE_INTERVAL), - ) + Self::from_websocket_stream(stream, connection_label, /*ping_interval*/ None) } pub(crate) fn from_axum_websocket(stream: AxumWebSocket, connection_label: String) -> Self { - let (websocket_writer, websocket_reader) = stream.split(); - Self::from_websocket_parts( - websocket_writer, - websocket_reader, - connection_label, - // Axum only wraps inbound exec-server websocket accepts. Outbound websocket clients - // own keepalive pings so one side does not accidentally create redundant traffic. - /*keepalive_interval*/ - None, - ) + Self::from_websocket_stream(stream, connection_label, Some(WEBSOCKET_KEEPALIVE_INTERVAL)) } - fn from_websocket_parts( - mut websocket_writer: W, - mut websocket_reader: R, + fn from_websocket_stream( + mut websocket: T, connection_label: String, - keepalive_interval: Option, + ping_interval: Option, ) -> Self where - W: Sink + Unpin + Send + 'static, - R: Stream> + Unpin + Send + 'static, + T: Sink + Stream> + Unpin + Send + 'static, M: JsonRpcWebSocketMessage, E: std::fmt::Display + Send + 'static, { @@ -361,118 +344,106 @@ impl JsonRpcConnection { let (incoming_tx, incoming_rx) = mpsc::channel(CHANNEL_CAPACITY); let (disconnected_tx, disconnected_rx) = watch::channel(false); - let reader_label = connection_label.clone(); - let incoming_tx_for_reader = incoming_tx.clone(); - let disconnected_tx_for_reader = disconnected_tx.clone(); - let reader_task = tokio::spawn(async move { + let websocket_task = tokio::spawn(async move { + let mut ping_interval = ping_interval.map(|ping_interval| { + let mut interval = tokio::time::interval_at( + tokio::time::Instant::now() + ping_interval, + ping_interval, + ); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + interval + }); + loop { - match websocket_reader.next().await { - Some(Ok(message)) => match message.parse_jsonrpc_frame() { - Ok(JsonRpcWebSocketFrame::Message(message)) => { - if incoming_tx_for_reader - .send(JsonRpcConnectionEvent::Message(message)) - .await - .is_err() - { - break; - } + tokio::select! { + maybe_message = outgoing_rx.recv() => { + let Some(message) = maybe_message else { + break; + }; + if let Err(reason) = send_websocket_jsonrpc_message( + &mut websocket, + &connection_label, + &message, + ) + .await + { + send_disconnected(&incoming_tx, &disconnected_tx, Some(reason)).await; + break; } - Err(err) => { - send_malformed_message( - &incoming_tx_for_reader, - Some(format!( - "failed to parse websocket JSON-RPC message from {reader_label}: {err}" - )), - ) - .await; + } + _ = async { + match ping_interval.as_mut() { + Some(interval) => interval.tick().await, + None => std::future::pending().await, } - Ok(JsonRpcWebSocketFrame::Close) => { + } => { + if let Err(err) = websocket.send(M::ping()).await { send_disconnected( - &incoming_tx_for_reader, - &disconnected_tx_for_reader, - /*reason*/ None, + &incoming_tx, + &disconnected_tx, + Some(format!( + "failed to write websocket ping to {connection_label}: {err}" + )), ) .await; break; } - Ok(JsonRpcWebSocketFrame::Ignore) => {} - }, - Some(Err(err)) => { - send_disconnected( - &incoming_tx_for_reader, - &disconnected_tx_for_reader, - Some(format!( - "failed to read websocket JSON-RPC message from {reader_label}: {err}" - )), - ) - .await; - break; } - None => { - send_disconnected( - &incoming_tx_for_reader, - &disconnected_tx_for_reader, - /*reason*/ None, - ) - .await; - break; - } - } - } - }); - - let writer_task = tokio::spawn(async move { - if let Some(keepalive_interval) = keepalive_interval { - let mut keepalive = tokio::time::interval_at( - tokio::time::Instant::now() + keepalive_interval, - keepalive_interval, - ); - keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - loop { - tokio::select! { - maybe_message = outgoing_rx.recv() => { - let Some(message) = maybe_message else { - break; - }; - if let Err(reason) = send_websocket_jsonrpc_message( - &mut websocket_writer, - &connection_label, - &message, - ) - .await - { - send_disconnected(&incoming_tx, &disconnected_tx, Some(reason)).await; - break; - } - } - _ = keepalive.tick() => { - if let Err(err) = websocket_writer.send(M::ping()).await { + incoming_message = websocket.next() => { + match incoming_message { + Some(Ok(message)) => match message.parse_jsonrpc_frame() { + Ok(JsonRpcWebSocketFrame::Message(message)) => { + if incoming_tx + .send(JsonRpcConnectionEvent::Message(message)) + .await + .is_err() + { + break; + } + } + Ok(JsonRpcWebSocketFrame::Close) => { + send_disconnected( + &incoming_tx, + &disconnected_tx, + /*reason*/ None, + ) + .await; + break; + } + Ok(JsonRpcWebSocketFrame::Ignore) => {} + Err(err) => { + send_malformed_message( + &incoming_tx, + Some(format!( + "failed to parse websocket JSON-RPC message from {connection_label}: {err}" + )), + ) + .await; + } + }, + Some(Err(err)) => { send_disconnected( &incoming_tx, &disconnected_tx, Some(format!( - "failed to write websocket ping to {connection_label}: {err}" + "failed to read websocket JSON-RPC message from {connection_label}: {err}" )), ) .await; break; } + None => { + send_disconnected( + &incoming_tx, + &disconnected_tx, + /*reason*/ None, + ) + .await; + break; + } } } } - } else { - while let Some(message) = outgoing_rx.recv().await { - if let Err(reason) = send_websocket_jsonrpc_message( - &mut websocket_writer, - &connection_label, - &message, - ) - .await - { - send_disconnected(&incoming_tx, &disconnected_tx, Some(reason)).await; - break; - } - } } }); @@ -480,7 +451,7 @@ impl JsonRpcConnection { outgoing_tx, incoming_rx, disconnected_rx, - task_handles: vec![reader_task, writer_task], + task_handles: vec![websocket_task], transport: JsonRpcTransport::Plain, } } @@ -619,34 +590,250 @@ fn serialize_jsonrpc_message(message: &JSONRPCMessage) -> Result, + #[tokio::test] + async fn websocket_connection_sends_configured_ping() -> anyhow::Result<()> { + let (client_websocket, mut server_websocket) = websocket_pair().await?; + let connection = JsonRpcConnection::from_websocket_stream( + client_websocket, + "test".into(), + Some(WEBSOCKET_KEEPALIVE_INTERVAL), + ); + + let message = timeout(Duration::from_secs(1), server_websocket.next()) + .await? + .expect("websocket should stay open")?; + assert!(matches!(message, Message::Ping(_))); + + drop(connection); + Ok(()) } - impl Sink for TestWebSocketSink { + #[tokio::test] + async fn websocket_connection_ignores_server_pong() -> anyhow::Result<()> { + let (client_websocket, mut server_websocket) = websocket_pair().await?; + let mut connection = JsonRpcConnection::from_websocket(client_websocket, "test".into()); + + server_websocket + .send(Message::Pong(b"check".to_vec().into())) + .await?; + assert!( + timeout(Duration::from_millis(50), connection.incoming_rx.recv()) + .await + .is_err() + ); + + drop(connection); + Ok(()) + } + + #[tokio::test] + async fn websocket_connection_reports_server_close() -> anyhow::Result<()> { + let (client_websocket, mut server_websocket) = websocket_pair().await?; + let mut connection = JsonRpcConnection::from_websocket(client_websocket, "test".into()); + + server_websocket.close(None).await?; + assert!(matches!( + timeout(Duration::from_secs(1), connection.incoming_rx.recv()).await?, + Some(JsonRpcConnectionEvent::Disconnected { reason: None }) + )); + + drop(connection); + Ok(()) + } + + #[tokio::test] + async fn websocket_connection_accepts_binary_jsonrpc_message() -> anyhow::Result<()> { + let (client_websocket, mut server_websocket) = websocket_pair().await?; + let mut connection = JsonRpcConnection::from_websocket(client_websocket, "test".into()); + let message = JSONRPCMessage::Request(JSONRPCRequest { + id: RequestId::Integer(1), + method: "test".to_string(), + params: None, + trace: None, + }); + + server_websocket + .send(Message::Binary(serde_json::to_vec(&message)?.into())) + .await?; + assert!(matches!( + timeout(Duration::from_secs(1), connection.incoming_rx.recv()).await?, + Some(JsonRpcConnectionEvent::Message(actual)) if actual == message + )); + + drop(connection); + Ok(()) + } + + #[tokio::test] + async fn websocket_connection_keeps_outbound_message_while_send_is_backpressured() + -> anyhow::Result<()> { + let (websocket, control, mut outbound_rx) = + ControlledWebSocket::new(/*write_ready*/ false); + let mut connection = JsonRpcConnection::from_websocket_stream( + websocket, + "test".into(), + /*ping_interval*/ None, + ); + let message = test_jsonrpc_message(); + + connection.outgoing_tx.send(message.clone()).await?; + control.wait_for_blocked_write().await?; + control.send_inbound(Message::Pong(b"check".to_vec().into()))?; + assert!( + timeout(Duration::from_millis(50), connection.incoming_rx.recv()) + .await + .is_err() + ); + + control.set_write_ready(); + assert!(matches!( + timeout(Duration::from_secs(1), outbound_rx.next()).await?, + Some(Message::Text(text)) if serde_json::from_str::(&text)? == message + )); + drop(connection); + Ok(()) + } + + async fn websocket_pair() -> anyhow::Result<( + WebSocketStream>, + WebSocketStream, + )> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let websocket_url = format!("ws://{}", listener.local_addr()?); + let server_task = tokio::spawn(async move { + let (stream, _) = listener.accept().await?; + accept_async(stream).await.map_err(anyhow::Error::from) + }); + let (client_websocket, _) = connect_async(websocket_url).await?; + let server_websocket = server_task.await??; + Ok((client_websocket, server_websocket)) + } + + fn test_jsonrpc_message() -> JSONRPCMessage { + JSONRPCMessage::Request(JSONRPCRequest { + id: RequestId::Integer(1), + method: "test".to_string(), + params: None, + trace: None, + }) + } + + struct ControlledWebSocket { + inbound_rx: futures_mpsc::UnboundedReceiver>, + outbound_tx: futures_mpsc::UnboundedSender, + write_ready: Arc, + write_blocked: Arc, + write_blocked_waker: Arc, + write_waker: Arc, + } + + struct ControlledWebSocketHandle { + inbound_tx: futures_mpsc::UnboundedSender>, + write_ready: Arc, + write_blocked: Arc, + write_blocked_waker: Arc, + write_waker: Arc, + } + + impl ControlledWebSocket { + fn new( + write_ready: bool, + ) -> ( + Self, + ControlledWebSocketHandle, + futures_mpsc::UnboundedReceiver, + ) { + let (inbound_tx, inbound_rx) = futures_mpsc::unbounded(); + let (outbound_tx, outbound_rx) = futures_mpsc::unbounded(); + let write_ready = Arc::new(AtomicBool::new(write_ready)); + let write_blocked = Arc::new(AtomicBool::new(false)); + let write_blocked_waker = Arc::new(AtomicWaker::new()); + let write_waker = Arc::new(AtomicWaker::new()); + ( + Self { + inbound_rx, + outbound_tx, + write_ready: Arc::clone(&write_ready), + write_blocked: Arc::clone(&write_blocked), + write_blocked_waker: Arc::clone(&write_blocked_waker), + write_waker: Arc::clone(&write_waker), + }, + ControlledWebSocketHandle { + inbound_tx, + write_ready, + write_blocked, + write_blocked_waker, + write_waker, + }, + outbound_rx, + ) + } + } + + impl ControlledWebSocketHandle { + fn send_inbound(&self, message: Message) -> anyhow::Result<()> { + self.inbound_tx + .unbounded_send(Ok(message)) + .map_err(anyhow::Error::from) + } + + fn set_write_ready(&self) { + self.write_ready.store(true, Ordering::Release); + self.write_waker.wake(); + } + + async fn wait_for_blocked_write(&self) -> anyhow::Result<()> { + timeout( + Duration::from_secs(1), + futures::future::poll_fn(|cx| { + if self.write_blocked.load(Ordering::Acquire) { + Poll::Ready(()) + } else { + self.write_blocked_waker.register(cx.waker()); + Poll::Pending + } + }), + ) + .await?; + Ok(()) + } + } + + impl Sink for ControlledWebSocket { type Error = std::convert::Infallible; - fn poll_ready( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - ) -> Poll> { - Poll::Ready(Ok(())) + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.write_ready.load(Ordering::Acquire) { + Poll::Ready(Ok(())) + } else { + self.write_blocked.store(true, Ordering::Release); + self.write_blocked_waker.wake(); + self.write_waker.register(cx.waker()); + Poll::Pending + } } fn start_send(self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> { - self.get_mut() - .message_tx + self.outbound_tx .unbounded_send(item) - .expect("test websocket receiver should stay open"); + .expect("test outbound receiver should stay open"); Ok(()) } @@ -665,24 +852,11 @@ mod tests { } } - #[tokio::test] - async fn websocket_connection_sends_keepalive_ping() { - let (message_tx, mut message_rx) = futures_mpsc::unbounded::(); - let websocket_writer = TestWebSocketSink { message_tx }; - let websocket_reader = stream::pending::>(); - let connection = JsonRpcConnection::from_websocket_parts( - websocket_writer, - websocket_reader, - "test".into(), - Some(WEBSOCKET_KEEPALIVE_INTERVAL), - ); + impl Stream for ControlledWebSocket { + type Item = Result; - let message = timeout(Duration::from_secs(1), message_rx.next()) - .await - .expect("keepalive ping should arrive before timeout") - .expect("keepalive ping should be sent"); - assert!(matches!(message, Message::Ping(_))); - - drop(connection); + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inbound_rx).poll_next(cx) + } } } diff --git a/codex-rs/exec-server/src/relay.rs b/codex-rs/exec-server/src/relay.rs index 7470a6290c..b3708f1d83 100644 --- a/codex-rs/exec-server/src/relay.rs +++ b/codex-rs/exec-server/src/relay.rs @@ -1,7 +1,9 @@ use std::collections::HashMap; use codex_app_server_protocol::JSONRPCMessage; +use futures::Sink; use futures::SinkExt; +use futures::Stream; use futures::StreamExt; use prost::Message as ProstMessage; use tokio::io::AsyncRead; @@ -19,7 +21,6 @@ use crate::connection::CHANNEL_CAPACITY; use crate::connection::JsonRpcConnection; use crate::connection::JsonRpcConnectionEvent; use crate::connection::JsonRpcTransport; -use crate::connection::WEBSOCKET_KEEPALIVE_INTERVAL; use crate::relay_proto::RelayData; use crate::relay_proto::RelayMessageFrame; use crate::relay_proto::RelayResume; @@ -140,121 +141,25 @@ fn jsonrpc_payload(message: &JSONRPCMessage) -> Result, ExecServerError> serde_json::to_vec(message).map_err(ExecServerError::Json) } -pub(crate) fn harness_connection_from_websocket( - stream: WebSocketStream, +pub(crate) fn harness_connection_from_websocket( + stream: T, connection_label: String, ) -> JsonRpcConnection where - S: AsyncRead + AsyncWrite + Unpin + Send + 'static, + T: Sink + Stream> + Unpin + Send + 'static, + E: std::fmt::Display + Send + 'static, { let stream_id = Uuid::new_v4().to_string(); - let (mut websocket_writer, mut websocket_reader) = stream.split(); 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 reader_label = connection_label; - let reader_stream_id = stream_id.clone(); - let incoming_tx_for_reader = incoming_tx; - let disconnected_tx_for_reader = disconnected_tx.clone(); - let reader_task = tokio::spawn(async move { - loop { - match websocket_reader.next().await { - Some(Ok(Message::Binary(payload))) => { - let frame = match decode_relay_message_frame(payload.as_ref()) { - Ok(frame) => frame, - Err(err) => { - let _ = incoming_tx_for_reader - .send(JsonRpcConnectionEvent::MalformedMessage { - reason: format!( - "failed to parse relay message frame from {reader_label}: {err}" - ), - }) - .await; - continue; - } - }; - if frame.stream_id != reader_stream_id { - continue; - } - let kind = match frame.validate() { - Ok(kind) => kind, - Err(err) => { - let _ = incoming_tx_for_reader - .send(JsonRpcConnectionEvent::MalformedMessage { - reason: err.to_string(), - }) - .await; - continue; - } - }; - match kind { - RelayFrameBodyKind::Data => match frame.into_jsonrpc_message() { - Ok(message) => { - if incoming_tx_for_reader - .send(JsonRpcConnectionEvent::Message(message)) - .await - .is_err() - { - break; - } - } - Err(err) => { - let _ = incoming_tx_for_reader - .send(JsonRpcConnectionEvent::MalformedMessage { - reason: err.to_string(), - }) - .await; - } - }, - RelayFrameBodyKind::Reset => { - let _ = disconnected_tx_for_reader.send(true); - let _ = incoming_tx_for_reader - .send(JsonRpcConnectionEvent::Disconnected { - reason: frame.into_reset_reason(), - }) - .await; - break; - } - RelayFrameBodyKind::Ack - | RelayFrameBodyKind::Resume - | RelayFrameBodyKind::Heartbeat => {} - } - } - Some(Ok(Message::Close(_))) | None => { - let _ = disconnected_tx_for_reader.send(true); - let _ = incoming_tx_for_reader - .send(JsonRpcConnectionEvent::Disconnected { reason: None }) - .await; - break; - } - Some(Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_))) => {} - Some(Ok(Message::Text(_))) => { - let _ = incoming_tx_for_reader - .send(JsonRpcConnectionEvent::MalformedMessage { - reason: "relay exec-server transport expects binary protobuf frames" - .to_string(), - }) - .await; - } - Some(Err(err)) => { - let _ = disconnected_tx_for_reader.send(true); - let _ = incoming_tx_for_reader - .send(JsonRpcConnectionEvent::Disconnected { - reason: Some(format!( - "failed to read relay websocket frame from {reader_label}: {err}" - )), - }) - .await; - break; - } - } - } - }); - - let writer_task = tokio::spawn(async move { + let websocket_task = tokio::spawn(async move { + let mut websocket = stream; + let reader_label = connection_label; + let reader_stream_id = stream_id.clone(); let resume = RelayMessageFrame::resume(stream_id.clone()); - if websocket_writer + if websocket .send(Message::Binary(encode_relay_message_frame(&resume).into())) .await .is_err() @@ -263,11 +168,6 @@ where return; } - let mut keepalive = tokio::time::interval_at( - tokio::time::Instant::now() + WEBSOCKET_KEEPALIVE_INTERVAL, - WEBSOCKET_KEEPALIVE_INTERVAL, - ); - keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let mut next_seq = 0u32; loop { tokio::select! { @@ -284,7 +184,7 @@ where }; let frame = RelayMessageFrame::data(stream_id.clone(), next_seq, payload); next_seq = next_seq.wrapping_add(1); - if websocket_writer + if websocket .send(Message::Binary(encode_relay_message_frame(&frame).into())) .await .is_err() @@ -293,10 +193,96 @@ where break; } } - _ = keepalive.tick() => { - if websocket_writer.send(Message::Ping(Vec::new().into())).await.is_err() { - let _ = disconnected_tx.send(true); - break; + incoming_message = websocket.next() => { + match incoming_message { + Some(Ok(Message::Binary(payload))) => { + let frame = match decode_relay_message_frame(payload.as_ref()) { + Ok(frame) => frame, + Err(err) => { + let _ = incoming_tx + .send(JsonRpcConnectionEvent::MalformedMessage { + reason: format!( + "failed to parse relay message frame from {reader_label}: {err}" + ), + }) + .await; + continue; + } + }; + if frame.stream_id != reader_stream_id { + continue; + } + let kind = match frame.validate() { + Ok(kind) => kind, + Err(err) => { + let _ = incoming_tx + .send(JsonRpcConnectionEvent::MalformedMessage { + reason: err.to_string(), + }) + .await; + continue; + } + }; + match kind { + RelayFrameBodyKind::Data => match frame.into_jsonrpc_message() { + Ok(message) => { + if incoming_tx + .send(JsonRpcConnectionEvent::Message(message)) + .await + .is_err() + { + break; + } + } + Err(err) => { + let _ = incoming_tx + .send(JsonRpcConnectionEvent::MalformedMessage { + reason: err.to_string(), + }) + .await; + } + }, + RelayFrameBodyKind::Reset => { + let _ = disconnected_tx.send(true); + let _ = incoming_tx + .send(JsonRpcConnectionEvent::Disconnected { + reason: frame.into_reset_reason(), + }) + .await; + break; + } + RelayFrameBodyKind::Ack + | RelayFrameBodyKind::Resume + | RelayFrameBodyKind::Heartbeat => {} + } + } + Some(Ok(Message::Close(_))) | None => { + let _ = disconnected_tx.send(true); + let _ = incoming_tx + .send(JsonRpcConnectionEvent::Disconnected { reason: None }) + .await; + break; + } + Some(Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_))) => {} + Some(Ok(Message::Text(_))) => { + let _ = incoming_tx + .send(JsonRpcConnectionEvent::MalformedMessage { + reason: "relay exec-server transport expects binary protobuf frames" + .to_string(), + }) + .await; + } + Some(Err(err)) => { + let _ = disconnected_tx.send(true); + let _ = incoming_tx + .send(JsonRpcConnectionEvent::Disconnected { + reason: Some(format!( + "failed to read relay websocket frame from {reader_label}: {err}" + )), + }) + .await; + break; + } } } } @@ -307,7 +293,7 @@ where outgoing_tx, incoming_rx, disconnected_rx, - task_handles: vec![reader_task, writer_task], + task_handles: vec![websocket_task], transport: JsonRpcTransport::Plain, } } @@ -318,59 +304,42 @@ pub(crate) async fn run_multiplexed_executor( ) where S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { - let (mut websocket_writer, mut websocket_reader) = stream.split(); + let mut websocket = stream; let (physical_outgoing_tx, mut physical_outgoing_rx) = mpsc::channel::>(CHANNEL_CAPACITY); - let writer_task = tokio::spawn(async move { - let mut keepalive = tokio::time::interval_at( - tokio::time::Instant::now() + WEBSOCKET_KEEPALIVE_INTERVAL, - WEBSOCKET_KEEPALIVE_INTERVAL, - ); - keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - loop { - tokio::select! { - maybe_encoded = physical_outgoing_rx.recv() => { - let Some(encoded) = maybe_encoded else { - break; - }; - if websocket_writer - .send(Message::Binary(encoded.into())) - .await - .is_err() - { - break; - } - } - _ = keepalive.tick() => { - if websocket_writer.send(Message::Ping(Vec::new().into())).await.is_err() { - break; - } - } - } - } - }); let mut streams: HashMap = HashMap::new(); loop { - let frame = match websocket_reader.next().await { - Some(Ok(Message::Binary(payload))) => { - match decode_relay_message_frame(payload.as_ref()) { - Ok(frame) => frame, - Err(err) => { - warn!("dropping malformed relay message frame from harness: {err}"); - continue; - } + let frame = tokio::select! { + maybe_encoded = physical_outgoing_rx.recv() => { + let Some(encoded) = maybe_encoded else { + break; + }; + if websocket.send(Message::Binary(encoded.into())).await.is_err() { + break; } - } - Some(Ok(Message::Close(_))) | None => break, - Some(Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_))) => continue, - Some(Ok(Message::Text(_))) => { - warn!("dropping non-binary relay message frame from harness"); continue; } - Some(Err(err)) => { - debug!("multiplexed executor websocket read failed: {err}"); - break; + incoming_message = websocket.next() => match incoming_message { + Some(Ok(Message::Binary(payload))) => { + match decode_relay_message_frame(payload.as_ref()) { + Ok(frame) => frame, + Err(err) => { + warn!("dropping malformed relay message frame from harness: {err}"); + continue; + } + } + } + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_))) => continue, + Some(Ok(Message::Text(_))) => { + warn!("dropping non-binary relay message frame from harness"); + continue; + } + Some(Err(err)) => { + debug!("multiplexed executor websocket read failed: {err}"); + break; + } } }; @@ -423,7 +392,6 @@ pub(crate) async fn run_multiplexed_executor( stream.disconnect(/*reason*/ None).await; } drop(physical_outgoing_tx); - let _ = writer_task.await; } struct VirtualStream { @@ -492,8 +460,20 @@ fn spawn_virtual_stream( #[cfg(test)] mod tests { + use std::pin::Pin; + use std::sync::Arc; + use std::sync::atomic::AtomicBool; + use std::sync::atomic::Ordering; + use std::task::Context; + use std::task::Poll; use std::time::Duration; + use codex_app_server_protocol::JSONRPCRequest; + use codex_app_server_protocol::RequestId; + use futures::Sink; + use futures::Stream; + use futures::channel::mpsc as futures_mpsc; + use futures::task::AtomicWaker; use tokio::net::TcpListener; use tokio::time::timeout; use tokio_tungstenite::accept_async; @@ -502,40 +482,107 @@ mod tests { use super::*; - fn test_runtime_paths() -> anyhow::Result { - crate::ExecServerRuntimePaths::new( - std::env::current_exe()?, - /*codex_linux_sandbox_exe*/ None, - ) - .map_err(anyhow::Error::from) - } - #[tokio::test] - async fn multiplexed_executor_sends_keepalive_ping() -> anyhow::Result<()> { + async fn harness_connection_receives_relay_data() -> anyhow::Result<()> { let (client_websocket, mut server_websocket) = websocket_pair().await?; - let executor_task = tokio::spawn(run_multiplexed_executor( - client_websocket, - ConnectionProcessor::new(test_runtime_paths()?), + let mut connection = + harness_connection_from_websocket(client_websocket, "test".to_string()); + let stream_id = read_resume_stream_id(&mut server_websocket).await?; + let message = test_jsonrpc_message(); + + server_websocket + .send(Message::Binary( + encode_relay_message_frame(&RelayMessageFrame::data( + stream_id, + /*seq*/ 0, + jsonrpc_payload(&message)?, + )) + .into(), + )) + .await?; + assert!(matches!( + timeout(Duration::from_secs(1), connection.incoming_rx.recv()).await?, + Some(JsonRpcConnectionEvent::Message(actual)) if actual == message )); - read_keepalive_ping(&mut server_websocket).await?; - - executor_task.abort(); - let _ = executor_task.await; + drop(connection); Ok(()) } #[tokio::test] - async fn harness_connection_sends_keepalive_ping() -> anyhow::Result<()> { + async fn harness_connection_reports_text_frames_as_malformed() -> anyhow::Result<()> { let (client_websocket, mut server_websocket) = websocket_pair().await?; - let connection = harness_connection_from_websocket(client_websocket, "test".to_string()); + let mut connection = + harness_connection_from_websocket(client_websocket, "test".to_string()); - read_keepalive_ping(&mut server_websocket).await?; + read_resume_stream_id(&mut server_websocket).await?; + server_websocket.send(Message::Text("nope".into())).await?; + assert!(matches!( + timeout(Duration::from_secs(1), connection.incoming_rx.recv()).await?, + Some(JsonRpcConnectionEvent::MalformedMessage { reason }) + if reason == "relay exec-server transport expects binary protobuf frames" + )); drop(connection); Ok(()) } + #[tokio::test] + async fn harness_connection_reports_server_close() -> anyhow::Result<()> { + let (client_websocket, mut server_websocket) = websocket_pair().await?; + let mut connection = + harness_connection_from_websocket(client_websocket, "test".to_string()); + + read_resume_stream_id(&mut server_websocket).await?; + server_websocket.close(None).await?; + assert!(matches!( + timeout(Duration::from_secs(1), connection.incoming_rx.recv()).await?, + Some(JsonRpcConnectionEvent::Disconnected { reason: None }) + )); + + drop(connection); + Ok(()) + } + + #[tokio::test] + async fn harness_connection_keeps_outbound_frame_while_send_is_backpressured() + -> anyhow::Result<()> { + let (websocket, control, mut outbound_rx) = + ControlledWebSocket::new(/*write_ready*/ true); + let mut connection = harness_connection_from_websocket(websocket, "test".to_string()); + let Message::Binary(resume_payload) = timeout(Duration::from_secs(1), outbound_rx.next()) + .await? + .expect("resume frame") + else { + anyhow::bail!("expected relay resume frame"); + }; + let stream_id = decode_relay_message_frame(resume_payload.as_ref())?.stream_id; + let message = test_jsonrpc_message(); + + control.set_write_blocked(); + connection.outgoing_tx.send(message.clone()).await?; + control.wait_for_blocked_write().await?; + control.send_inbound(Message::Pong(b"check".to_vec().into()))?; + assert!( + timeout(Duration::from_millis(50), connection.incoming_rx.recv()) + .await + .is_err() + ); + + control.set_write_ready(); + let Message::Binary(data_payload) = timeout(Duration::from_secs(1), outbound_rx.next()) + .await? + .expect("data frame") + else { + anyhow::bail!("expected relay data frame"); + }; + let frame = decode_relay_message_frame(data_payload.as_ref())?; + assert_eq!(frame.stream_id, stream_id); + assert_eq!(frame.into_jsonrpc_message()?, message); + drop(connection); + Ok(()) + } + async fn websocket_pair() -> anyhow::Result<( WebSocketStream>, WebSocketStream, @@ -551,18 +598,155 @@ mod tests { Ok((client_websocket, server_websocket)) } - async fn read_keepalive_ping( + async fn read_resume_stream_id( websocket: &mut WebSocketStream, - ) -> anyhow::Result<()> { - loop { - let Some(message) = timeout(Duration::from_secs(1), websocket.next()).await? else { - anyhow::bail!("websocket closed before keepalive ping"); - }; - match message? { - Message::Ping(_) => return Ok(()), - Message::Binary(_) | Message::Text(_) | Message::Pong(_) | Message::Frame(_) => {} - Message::Close(_) => anyhow::bail!("websocket closed before keepalive ping"), + ) -> anyhow::Result { + let message = timeout(Duration::from_secs(1), websocket.next()) + .await? + .expect("websocket should stay open")?; + let Message::Binary(payload) = message else { + anyhow::bail!("expected relay resume frame, got {message:?}"); + }; + let frame = decode_relay_message_frame(payload.as_ref())?; + assert_eq!(frame.validate()?, RelayFrameBodyKind::Resume); + Ok(frame.stream_id) + } + + fn test_jsonrpc_message() -> JSONRPCMessage { + JSONRPCMessage::Request(JSONRPCRequest { + id: RequestId::Integer(1), + method: "test".to_string(), + params: None, + trace: None, + }) + } + + struct ControlledWebSocket { + inbound_rx: futures_mpsc::UnboundedReceiver>, + outbound_tx: futures_mpsc::UnboundedSender, + write_ready: Arc, + write_blocked: Arc, + write_blocked_waker: Arc, + write_waker: Arc, + } + + struct ControlledWebSocketHandle { + inbound_tx: futures_mpsc::UnboundedSender>, + write_ready: Arc, + write_blocked: Arc, + write_blocked_waker: Arc, + write_waker: Arc, + } + + impl ControlledWebSocket { + fn new( + write_ready: bool, + ) -> ( + Self, + ControlledWebSocketHandle, + futures_mpsc::UnboundedReceiver, + ) { + let (inbound_tx, inbound_rx) = futures_mpsc::unbounded(); + let (outbound_tx, outbound_rx) = futures_mpsc::unbounded(); + let write_ready = Arc::new(AtomicBool::new(write_ready)); + let write_blocked = Arc::new(AtomicBool::new(false)); + let write_blocked_waker = Arc::new(AtomicWaker::new()); + let write_waker = Arc::new(AtomicWaker::new()); + ( + Self { + inbound_rx, + outbound_tx, + write_ready: Arc::clone(&write_ready), + write_blocked: Arc::clone(&write_blocked), + write_blocked_waker: Arc::clone(&write_blocked_waker), + write_waker: Arc::clone(&write_waker), + }, + ControlledWebSocketHandle { + inbound_tx, + write_ready, + write_blocked, + write_blocked_waker, + write_waker, + }, + outbound_rx, + ) + } + } + + impl ControlledWebSocketHandle { + fn send_inbound(&self, message: Message) -> anyhow::Result<()> { + self.inbound_tx + .unbounded_send(Ok(message)) + .map_err(anyhow::Error::from) + } + + fn set_write_blocked(&self) { + self.write_ready.store(false, Ordering::Release); + } + + fn set_write_ready(&self) { + self.write_ready.store(true, Ordering::Release); + self.write_waker.wake(); + } + + async fn wait_for_blocked_write(&self) -> anyhow::Result<()> { + timeout( + Duration::from_secs(1), + futures::future::poll_fn(|cx| { + if self.write_blocked.load(Ordering::Acquire) { + Poll::Ready(()) + } else { + self.write_blocked_waker.register(cx.waker()); + Poll::Pending + } + }), + ) + .await?; + Ok(()) + } + } + + impl Sink for ControlledWebSocket { + type Error = std::convert::Infallible; + + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.write_ready.load(Ordering::Acquire) { + Poll::Ready(Ok(())) + } else { + self.write_blocked.store(true, Ordering::Release); + self.write_blocked_waker.wake(); + self.write_waker.register(cx.waker()); + Poll::Pending } } + + fn start_send(self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> { + self.outbound_tx + .unbounded_send(item) + .expect("test outbound receiver should stay open"); + Ok(()) + } + + fn poll_flush( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + } + + impl Stream for ControlledWebSocket { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inbound_rx).poll_next(cx) + } } } From 40be41763c94a76755c8d18754502e4a9403fb8f Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 19 May 2026 18:01:38 -0300 Subject: [PATCH 02/63] fix(tui): preserve modified enter in plan questions (#23536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Plan mode questionnaires reuse the shared composer for free-form answers, but the surrounding `request_user_input` overlay still treated every `KeyCode::Enter` as “advance to the next question.” That made `Shift+Enter` insert a newline in the composer and then immediately advance the questionnaire anyway. Fixes #23448. ## What Changed - pass the live `RuntimeKeymap` into `RequestUserInputOverlay` so its embedded composer honors existing `/keymap` composer/editor remaps - advance free-form questions only on the configured composer submit binding, instead of any Enter-shaped key event - add regressions for `Shift+Enter` newline behavior and configured composer submit bindings inside the questionnaire UI ## How to Test 1. Start Codex in Plan mode and trigger a `request_user_input` questionnaire with a free-form answer field. 2. Focus the free-form field, type a line, then press `Shift+Enter`. 3. Confirm the answer gains a newline and the questionnaire stays on the same question. 4. Press the configured submit binding, or plain `Enter` with the default keymap, and confirm the questionnaire advances as before. Targeted tests: - `cargo test -p codex-tui bottom_pane::request_user_input::tests::freeform_ -- --nocapture` ## Notes - `cargo test -p codex-tui` still reaches an unrelated existing stack overflow in `app::tests::discard_side_thread_removes_agent_navigation_entry` on this checkout. - `just argument-comment-lint` is locally blocked by Bazel analysis failing in external `compiler-rt` before the lint runs. --- codex-rs/tui/src/bottom_pane/mod.rs | 2 +- .../src/bottom_pane/request_user_input/mod.rs | 194 +++++++++++++++--- ...t_user_input_freeform_remapped_submit.snap | 13 ++ 3 files changed, 185 insertions(+), 24 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_submit.snap diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 3f93c13898..5aeb2a4cfd 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1315,7 +1315,7 @@ impl BottomPane { self.has_input_focus, self.enhanced_keys_supported, self.disable_paste_burst, - self.keymap.list.clone(), + self.keymap.clone(), ); self.pause_status_timer_for_modal(); self.set_composer_input_enabled( 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 68c6755e45..13cbc34daa 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 @@ -4,7 +4,7 @@ //! - Each question can be answered by selecting one option and/or providing notes. //! - Notes are stored per question and appended as extra answers. //! - Typing while focused on options jumps into notes to keep freeform input fast. -//! - Enter advances to the next question; the last question submits all answers. +//! - The composer submit binding advances to the next question; the last question submits all answers. //! - Freeform-only questions submit an empty answer list when empty. use std::collections::HashMap; use std::collections::VecDeque; @@ -29,8 +29,10 @@ use crate::bottom_pane::scroll_state::ScrollState; use crate::bottom_pane::selection_popup_common::GenericDisplayRow; use crate::bottom_pane::selection_popup_common::measure_rows_height; use crate::history_cell; +use crate::key_hint::KeyBinding; use crate::key_hint::KeyBindingListExt; use crate::keymap::ListKeymap; +use crate::keymap::RuntimeKeymap; use crate::render::renderable::Renderable; #[cfg(test)] @@ -140,6 +142,7 @@ pub(crate) struct RequestUserInputOverlay { done: bool, pending_submission_draft: Option, confirm_unanswered: Option, + composer_submit_keys: Vec, list_keymap: ListKeymap, } @@ -158,7 +161,7 @@ impl RequestUserInputOverlay { has_input_focus, enhanced_keys_supported, disable_paste_burst, - crate::keymap::RuntimeKeymap::defaults().list, + RuntimeKeymap::defaults(), ) } @@ -168,7 +171,7 @@ impl RequestUserInputOverlay { has_input_focus: bool, enhanced_keys_supported: bool, disable_paste_burst: bool, - list_keymap: ListKeymap, + keymap: RuntimeKeymap, ) -> Self { // Use the same composer widget, but disable popups/slash-commands and // image-path attachment so it behaves like a focused notes field. @@ -180,6 +183,7 @@ impl RequestUserInputOverlay { disable_paste_burst, ChatComposerConfig::plain_text(), ); + composer.set_keymap_bindings(&keymap); // The overlay renders its own footer hints, so keep the composer footer empty. composer.set_footer_hint_override(Some(Vec::new())); let mut overlay = Self { @@ -193,7 +197,8 @@ impl RequestUserInputOverlay { done: false, pending_submission_draft: None, confirm_unanswered: None, - list_keymap, + composer_submit_keys: keymap.composer.submit.clone(), + list_keymap: keymap.list, }; overlay.reset_for_request(); overlay.ensure_focus_available(); @@ -477,14 +482,23 @@ impl RequestUserInputOverlay { let question_count = self.question_count(); let is_last_question = self.current_index().saturating_add(1) >= question_count; - let enter_tip = if question_count == 1 { - FooterTip::highlighted("enter to submit answer") - } else if is_last_question { - FooterTip::highlighted("enter to submit all") + let submit_key = if self.focus_is_notes() || !self.has_options() { + self.composer_submit_keys + .first() + .map(KeyBinding::display_label) } else { - FooterTip::new("enter to submit answer") + Some("enter".to_string()) }; - tips.push(enter_tip); + if let Some(submit_key) = submit_key { + let submit_tip = if question_count == 1 { + FooterTip::highlighted(format!("{submit_key} to submit answer")) + } else if is_last_question { + FooterTip::highlighted(format!("{submit_key} to submit all")) + } else { + FooterTip::new(format!("{submit_key} to submit answer")) + }; + tips.push(submit_tip); + } if question_count > 1 { if self.has_options() && !self.focus_is_notes() { tips.push(FooterTip::new("←/→ to navigate questions")); @@ -1054,6 +1068,20 @@ impl BottomPaneView for RequestUserInputOverlay { return; } + if self.focus_is_notes() && self.composer_submit_keys.is_pressed(key_event) { + self.ensure_selected_for_notes(); + self.pending_submission_draft = Some(self.capture_composer_draft()); + let (result, _) = self.composer.handle_key_event(key_event); + if !self.handle_composer_input_result(result) { + self.pending_submission_draft = None; + if self.has_options() { + self.select_current_option(/*committed*/ true); + } + self.go_next_or_submit(); + } + return; + } + // Question navigation is always available. match key_event { KeyEvent { @@ -1201,19 +1229,6 @@ impl BottomPaneView for RequestUserInputOverlay { self.sync_composer_placeholder(); return; } - if matches!(key_event.code, KeyCode::Enter) { - self.ensure_selected_for_notes(); - self.pending_submission_draft = Some(self.capture_composer_draft()); - let (result, _) = self.composer.handle_key_event(key_event); - if !self.handle_composer_input_result(result) { - self.pending_submission_draft = None; - if self.has_options() { - self.select_current_option(/*committed*/ true); - } - self.go_next_or_submit(); - } - return; - } if self.has_options() && matches!(key_event.code, KeyCode::Up | KeyCode::Down) { let options_len = self.options_len(); match key_event.code { @@ -2008,6 +2023,28 @@ mod tests { ); } + #[test] + fn freeform_footer_shows_configured_submit_binding() { + let (tx, _rx) = test_sender(); + let mut keymap = RuntimeKeymap::defaults(); + keymap.composer.submit = vec![crate::key_hint::ctrl(KeyCode::Char('j'))]; + let overlay = RequestUserInputOverlay::new_with_keymap( + request_event("turn-1", vec![question_without_options("q1", "Notes")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + + let tips = overlay.footer_tips(); + let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::>(); + assert_eq!( + tip_texts, + vec!["ctrl + j to submit answer", "esc to interrupt"] + ); + } + #[test] fn tab_opens_notes_when_option_selected() { let (tx, _rx) = test_sender(); @@ -2374,6 +2411,97 @@ mod tests { assert_eq!(overlay.unanswered_count(), 2); } + #[test] + fn freeform_shift_enter_inserts_newline_without_advancing() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_without_options("q1", "Notes"), + question_without_options("q2", "More"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ true, + /*disable_paste_burst*/ false, + ); + + overlay + .composer + .set_text_content("Draft".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); + + overlay.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)); + + assert_eq!(overlay.current_index(), 0); + assert_eq!(overlay.composer.current_text_with_pending(), "Draft\n"); + assert_eq!(overlay.answers[0].answer_committed, false); + } + + #[test] + fn freeform_uses_configured_composer_submit_binding() { + let (tx, _rx) = test_sender(); + let mut keymap = RuntimeKeymap::defaults(); + keymap.composer.submit = vec![crate::key_hint::ctrl(KeyCode::Char('j'))]; + let mut overlay = RequestUserInputOverlay::new_with_keymap( + request_event( + "turn-1", + vec![ + question_without_options("q1", "Notes"), + question_without_options("q2", "More"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + + overlay + .composer + .set_text_content("Draft".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); + + overlay.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)); + + assert_eq!(overlay.current_index(), 1); + assert_eq!(overlay.answers[0].answer_committed, true); + } + + #[test] + fn freeform_submit_binding_wins_over_question_navigation() { + let (tx, _rx) = test_sender(); + let mut keymap = RuntimeKeymap::defaults(); + keymap.composer.submit = vec![crate::key_hint::ctrl(KeyCode::Char('n'))]; + let mut overlay = RequestUserInputOverlay::new_with_keymap( + request_event( + "turn-1", + vec![ + question_without_options("q1", "Notes"), + question_without_options("q2", "More"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + + overlay + .composer + .set_text_content("Draft".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); + + overlay.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)); + + assert_eq!(overlay.current_index(), 1); + assert_eq!(overlay.answers[0].answer_committed, true); + } + #[test] fn freeform_questions_submit_empty_when_empty() { let (tx, mut rx) = test_sender(); @@ -3025,6 +3153,26 @@ mod tests { ); } + #[test] + fn request_user_input_freeform_remapped_submit_snapshot() { + let (tx, _rx) = test_sender(); + let mut keymap = RuntimeKeymap::defaults(); + keymap.composer.submit = vec![crate::key_hint::ctrl(KeyCode::Char('j'))]; + let overlay = RequestUserInputOverlay::new_with_keymap( + request_event("turn-1", vec![question_without_options("q1", "Goal")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + let area = Rect::new(0, 0, 120, 10); + insta::assert_snapshot!( + "request_user_input_freeform_remapped_submit", + render_snapshot(&overlay, area) + ); + } + #[test] fn request_user_input_multi_question_first_snapshot() { let (tx, _rx) = test_sender(); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_submit.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_submit.snap new file mode 100644 index 0000000000..a2aeaa4d3e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_submit.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 1/1 (1 unanswered) + Share details. + + › Type your answer (optional) + + + + ctrl + j to submit answer | esc to interrupt From 954a9c85792f2d0560c3b98cfa6e0a1cd8f9e844 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 19 May 2026 14:19:38 -0700 Subject: [PATCH 03/63] Fix empty rollout path app-server handling (#23400) ## Summary - Coerce `path: ""` to `None` at the v2 protocol params deserialization boundary for `thread/resume` and `thread/fork`. - Restore the pre-ThreadStore running-thread resume behavior: if `threadId` is already running, rejoin it by id and treat a non-empty `path` only as a consistency check; otherwise cold resume keeps `history > path > threadId` precedence. - Add protocol, resume, and fork regression coverage for empty path payloads; refresh app-server schema fixtures for the clarified params docs. ## Tests - `just fmt` - `just write-app-server-schema` - `cargo test -p codex-app-server-protocol thread_path_params_deserialize_empty_path_as_none` - `cargo test -p codex-app-server-protocol --test schema_fixtures` - `cargo test -p codex-app-server empty_path` - `RUST_MIN_STACK=8388608 cargo test -p codex-app-server --test all thread_resume_rejects_mismatched_path_for_running_thread_id` - `RUST_MIN_STACK=8388608 cargo test -p codex-app-server --test all thread_resume_uses_path_over_non_running_thread_id` --- .../schema/json/ClientRequest.json | 4 +- .../codex_app_server_protocol.schemas.json | 4 +- .../codex_app_server_protocol.v2.schemas.json | 4 +- .../schema/json/v2/ThreadForkParams.json | 2 +- .../schema/json/v2/ThreadResumeParams.json | 2 +- .../schema/typescript/v2/ThreadForkParams.ts | 3 +- .../typescript/v2/ThreadResumeParams.ts | 9 +- .../src/protocol/serde_helpers.rs | 10 ++ .../src/protocol/v2/tests.rs | 27 ++++ .../src/protocol/v2/thread.rs | 24 ++- .../request_processors/thread_processor.rs | 38 ++--- .../app-server/tests/suite/v2/thread_fork.rs | 40 +++++ .../tests/suite/v2/thread_resume.rs | 137 ++++++++++-------- 13 files changed, 203 insertions(+), 101 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 2621a0bd1e..58aa1c4449 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -3007,7 +3007,7 @@ "type": "object" }, "ThreadForkParams": { - "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using a non-empty path, the thread_id param will be ignored. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ @@ -3413,7 +3413,7 @@ ] }, "ThreadResumeParams": { - "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ 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 e219f42551..df4514e7c9 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 @@ -15370,7 +15370,7 @@ }, "ThreadForkParams": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using a non-empty path, the thread_id param will be ignored. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ @@ -16868,7 +16868,7 @@ }, "ThreadResumeParams": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ 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 fb1a8d2505..1741b612f3 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 @@ -13194,7 +13194,7 @@ }, "ThreadForkParams": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using a non-empty path, the thread_id param will be ignored. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ @@ -14692,7 +14692,7 @@ }, "ThreadResumeParams": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index 102cfa0299..9d2f834dd8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -77,7 +77,7 @@ "type": "string" } }, - "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using a non-empty path, the thread_id param will be ignored. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ 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 253ebf4572..027989479a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -963,7 +963,7 @@ "type": "string" } }, - "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts index 6076a4bb14..c5109b2c7d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts @@ -12,7 +12,8 @@ import type { ThreadSource } from "./ThreadSource"; * 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. * 2. By path: load the thread from disk by path and fork it into a new thread. * - * If using path, the thread_id param will be ignored. + * If using a non-empty path, the thread_id param will be ignored. + * Empty string path values are treated as absent. * * Prefer using thread_id whenever possible. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts index 6d1dbdca4f..0ec8953435 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts @@ -13,8 +13,13 @@ import type { SandboxMode } from "./SandboxMode"; * 2. By history: instantiate the thread from memory and resume it. * 3. By path: load the thread from disk by path and resume it. * - * The precedence is: history > path > thread_id. - * If using history or path, the thread_id param will be ignored. + * For non-running threads, the precedence is: history > non-empty path > thread_id. + * If using history or a non-empty path for a non-running thread, the thread_id + * param will be ignored. + * + * If thread_id identifies a running thread, app-server rejoins that thread and + * treats a non-empty path as a consistency check against the active rollout path. + * Empty string path values are treated as absent. * * Prefer using thread_id whenever possible. */ diff --git a/codex-rs/app-server-protocol/src/protocol/serde_helpers.rs b/codex-rs/app-server-protocol/src/protocol/serde_helpers.rs index 0e35ebdba7..6d6747a5cc 100644 --- a/codex-rs/app-server-protocol/src/protocol/serde_helpers.rs +++ b/codex-rs/app-server-protocol/src/protocol/serde_helpers.rs @@ -1,8 +1,18 @@ +use std::path::PathBuf; + use serde::Deserialize; use serde::Deserializer; use serde::Serialize; use serde::Serializer; +pub fn deserialize_empty_path_as_none<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let path = Option::::deserialize(deserializer)?; + Ok(path.filter(|path| !path.as_os_str().is_empty())) +} + pub fn deserialize_double_option<'de, T, D>(deserializer: D) -> Result>, D::Error> where T: Deserialize<'de>, 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 0e7161d5e8..8c3ca5e6c6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -661,6 +661,33 @@ fn permission_profile_selection_uses_id_string() { ); } +#[test] +fn thread_path_params_deserialize_empty_path_as_none() { + let resume: ThreadResumeParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "path": "", + })) + .expect("thread/resume params deserialize"); + assert_eq!(resume.path, None); + + let fork: ThreadForkParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "path": "", + })) + .expect("thread/fork params deserialize"); + assert_eq!(fork.path, None); + + let resume_with_path: ThreadResumeParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "path": "/tmp/resume-thread.jsonl", + })) + .expect("thread/resume params deserialize"); + assert_eq!( + resume_with_path.path, + Some(PathBuf::from("/tmp/resume-thread.jsonl")) + ); +} + #[test] fn fs_get_metadata_response_round_trips_minimal_fields() { let response = FsGetMetadataResponse { 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 41994e59b6..059bef05f2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -229,8 +229,13 @@ pub struct ThreadStartResponse { /// 2. By history: instantiate the thread from memory and resume it. /// 3. By path: load the thread from disk by path and resume it. /// -/// The precedence is: history > path > thread_id. -/// If using history or path, the thread_id param will be ignored. +/// For non-running threads, the precedence is: history > non-empty path > thread_id. +/// If using history or a non-empty path for a non-running thread, the thread_id +/// param will be ignored. +/// +/// If thread_id identifies a running thread, app-server rejoins that thread and +/// treats a non-empty path as a consistency check against the active rollout path. +/// Empty string path values are treated as absent. /// /// Prefer using thread_id whenever possible. pub struct ThreadResumeParams { @@ -244,8 +249,14 @@ pub struct ThreadResumeParams { pub history: Option>, /// [UNSTABLE] Specify the rollout path to resume from. - /// If specified, the thread_id param will be ignored. + /// If specified for a non-running thread, the thread_id param will be ignored. + /// If thread_id identifies a running thread, the path must match the active + /// rollout path. #[experimental("thread/resume.path")] + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_empty_path_as_none" + )] #[ts(optional = nullable)] pub path: Option, @@ -346,7 +357,8 @@ pub struct ThreadResumeResponse { /// 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. /// 2. By path: load the thread from disk by path and fork it into a new thread. /// -/// If using path, the thread_id param will be ignored. +/// If using a non-empty path, the thread_id param will be ignored. +/// Empty string path values are treated as absent. /// /// Prefer using thread_id whenever possible. pub struct ThreadForkParams { @@ -355,6 +367,10 @@ pub struct ThreadForkParams { /// [UNSTABLE] Specify the rollout path to fork from. /// If specified, the thread_id param will be ignored. #[experimental("thread/fork.path")] + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_empty_path_as_none" + )] #[ts(optional = nullable)] pub path: Option, 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 f89f4a6503..6b6e1729a5 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -2639,31 +2639,6 @@ impl ThreadRequestProcessor { ))); } None - } else if params.path.is_some() { - let source_thread = self - .read_stored_thread_for_resume( - ¶ms.thread_id, - params.path.as_ref(), - /*include_history*/ true, - ) - .await?; - let existing_thread_id = source_thread.thread_id; - if let Ok(existing_thread) = self.thread_manager.get_thread(existing_thread_id).await { - if let (Some(requested_path), Some(active_path)) = ( - params.path.as_ref(), - existing_thread.rollout_path().as_ref(), - ) && requested_path != active_path - { - return Err(invalid_request(format!( - "cannot resume running thread {existing_thread_id} with stale path: requested `{}`, active `{}`", - requested_path.display(), - active_path.display() - ))); - } - Some((existing_thread_id, existing_thread, source_thread)) - } else { - None - } } else if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id) && let Ok(existing_thread) = self.thread_manager.get_thread(existing_thread_id).await { @@ -2686,6 +2661,19 @@ impl ThreadRequestProcessor { }; if let Some((existing_thread_id, existing_thread, source_thread)) = running_thread { + let existing_thread_rollout_path = existing_thread.rollout_path(); + let active_path = existing_thread_rollout_path + .as_ref() + .or(source_thread.rollout_path.as_ref()); + if let (Some(requested_path), Some(active_path)) = (params.path.as_ref(), active_path) + && requested_path != active_path + { + return Err(invalid_request(format!( + "cannot resume running thread {existing_thread_id} with stale path: requested `{}`, active `{}`", + requested_path.display(), + active_path.display() + ))); + } let redact_resume_payloads = should_redact_thread_resume_payloads(app_server_client_name.as_deref()); let history_items = source_thread diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 3eb262bd2b..cc89dd71cb 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -454,6 +454,46 @@ async fn thread_fork_rejects_unmaterialized_thread() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_fork_with_empty_path_uses_thread_id() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let conversation_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: conversation_id.clone(), + path: Some(std::path::PathBuf::new()), + thread_source: Some(ThreadSource::User), + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + + assert_eq!( + thread.forked_from_id.as_deref(), + Some(conversation_id.as_str()) + ); + Ok(()) +} + #[tokio::test] async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> { let server = MockServer::start().await; 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 8bcad54efe..036f2368a0 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -184,6 +184,70 @@ async fn thread_resume_rejects_unmaterialized_thread() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_resume_with_empty_path_uses_running_thread_id() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".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, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "materialize rollout".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + path: Some(PathBuf::new()), + exclude_turns: true, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + thread: resumed, .. + } = to_response::(resume_resp)?; + + assert_eq!(resumed.id, thread.id); + Ok(()) +} + #[tokio::test] async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -2143,7 +2207,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { } #[tokio::test] -async fn thread_resume_uses_path_over_thread_id_when_thread_is_running() -> Result<()> { +async fn thread_resume_rejects_mismatched_path_for_running_thread_id() -> Result<()> { let server = responses::start_mock_server().await; let first_body = responses::sse(vec![ responses::ev_response_created("resp-1"), @@ -2223,7 +2287,6 @@ async fn thread_resume_uses_path_over_thread_id_when_thread_is_running() -> Resu ) .await??; - let other_thread_id = ThreadId::new().to_string(); let stale_path = rollout_path(codex_home.path(), "2025-01-01T00-00-00", &thread_id); std::fs::create_dir_all(stale_path.parent().expect("stale path parent"))?; let thread_uuid = Uuid::parse_str(&thread_id)?; @@ -2255,7 +2318,7 @@ async fn thread_resume_uses_path_over_thread_id_when_thread_is_running() -> Resu let stale_resume_id = primary .send_thread_resume_request(ThreadResumeParams { - thread_id: other_thread_id.clone(), + thread_id: thread_id.clone(), path: Some(stale_path), ..Default::default() }) @@ -2271,23 +2334,6 @@ async fn thread_resume_uses_path_over_thread_id_when_thread_is_running() -> Resu stale_resume_err.error.message ); - let resume_by_path_id = primary - .send_thread_resume_request(ThreadResumeParams { - thread_id: other_thread_id.clone(), - path: thread.path, - ..Default::default() - }) - .await?; - let resume_by_path_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - primary.read_stream_until_response_message(RequestId::Integer(resume_by_path_id)), - ) - .await??; - let ThreadResumeResponse { - thread: resumed, .. - } = to_response::(resume_by_path_resp)?; - assert_eq!(resumed.id, thread_id); - primary .interrupt_turn_and_wait_for_aborted(thread_id, running_turn.id, DEFAULT_READ_TIMEOUT) .await?; @@ -2984,53 +3030,22 @@ async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> { } #[tokio::test] -async fn thread_resume_uses_path_over_invalid_thread_id() -> Result<()> { +async fn thread_resume_uses_path_over_non_running_thread_id() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let RestartedThreadFixture { + mut mcp, + thread_id, + rollout_file_path, + .. + } = start_materialized_thread_and_restart(codex_home.path(), "materialize").await?; - let start_id = mcp - .send_thread_start_request(ThreadStartParams { - model: Some("gpt-5.4".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, .. } = to_response::(start_resp)?; - - let turn_id = mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id.clone(), - input: vec![UserInput::Text { - text: "materialize".to_string(), - text_elements: Vec::new(), - }], - ..Default::default() - }) - .await?; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), - ) - .await??; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await??; - - let thread_path = thread.path.clone().expect("thread path"); let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { - thread_id: "not-a-valid-thread-id".to_string(), - path: Some(thread_path.to_path_buf()), + thread_id: ThreadId::new().to_string(), + path: Some(rollout_file_path), ..Default::default() }) .await?; @@ -3043,7 +3058,7 @@ async fn thread_resume_uses_path_over_invalid_thread_id() -> Result<()> { let ThreadResumeResponse { thread: resumed, .. } = to_response::(resume_resp)?; - assert_eq!(resumed.id, thread.id); + assert_eq!(resumed.id, thread_id); Ok(()) } From 1509ae6d8dd73459cb3afe41f24625717c2057fd Mon Sep 17 00:00:00 2001 From: starr-openai Date: Tue, 19 May 2026 14:38:03 -0700 Subject: [PATCH 04/63] Route local-only app-server gating through processors (#23551) ## Summary - move local-only app-server gating out of `MessageProcessor` - let `fs/*`, `command/exec`, and `process/spawn` resolve local availability inside their owning processors - keep `fs/*` mounted for the future environment-param path while preserving current no-local error behavior ## Validation - not run locally per Codex repo guidance --- codex-rs/app-server/src/message_processor.rs | 78 ++++++------------- .../command_exec_processor.rs | 12 +++ .../src/request_processors/fs_processor.rs | 30 ++++--- .../process_exec_processor.rs | 17 +++- 4 files changed, 73 insertions(+), 64 deletions(-) diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index fdcd44d01a..e37c9afd22 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -7,7 +7,6 @@ use std::sync::atomic::AtomicBool; use crate::attestation::app_server_attestation_provider; use crate::config_manager::ConfigManager; use crate::connection_rpc_gate::ConnectionRpcGate; -use crate::error_code::internal_error; use crate::error_code::invalid_request; use crate::extensions::guardian_agent_spawner; use crate::extensions::thread_extensions; @@ -166,11 +165,10 @@ pub(crate) struct MessageProcessor { command_exec_processor: CommandExecRequestProcessor, process_exec_processor: ProcessExecRequestProcessor, config_processor: ConfigRequestProcessor, - environment_manager: Arc, environment_processor: EnvironmentRequestProcessor, external_agent_config_processor: ExternalAgentConfigRequestProcessor, feedback_processor: FeedbackRequestProcessor, - fs_processor: Option, + fs_processor: FsRequestProcessor, git_processor: GitRequestProcessor, initialize_processor: InitializeRequestProcessor, marketplace_processor: MarketplaceRequestProcessor, @@ -274,23 +272,6 @@ pub(crate) struct MessageProcessorArgs { } impl MessageProcessor { - fn fs_processor(&self) -> Result<&FsRequestProcessor, JSONRPCErrorError> { - self.fs_processor - .as_ref() - .ok_or_else(|| internal_error("local filesystem is not configured")) - } - - fn require_local_environment(&self) -> Result<(), JSONRPCErrorError> { - // CCA filters these local-only RPCs before they reach app-server, but - // keep a Codex-side backstop so no-local app-server modes fail safely - // if a client still invokes one directly. - self.environment_manager - .try_local_environment() - .is_some() - .then_some(()) - .ok_or_else(|| internal_error("local environment is not configured")) - } - /// Create a new `MessageProcessor`, retaining a handle to the outgoing /// `Sender` so handlers can enqueue messages to be written to stdout. pub(crate) fn new(args: MessageProcessorArgs) -> Self { @@ -375,8 +356,12 @@ impl MessageProcessor { Arc::clone(&config), outgoing.clone(), config_manager.clone(), + Arc::clone(&environment_manager_for_requests), + ); + let process_exec_processor = ProcessExecRequestProcessor::new( + outgoing.clone(), + Arc::clone(&environment_manager_for_requests), ); - let process_exec_processor = ProcessExecRequestProcessor::new(outgoing.clone()); let feedback_processor = FeedbackRequestProcessor::new( auth_manager.clone(), Arc::clone(&thread_manager), @@ -480,17 +465,10 @@ impl MessageProcessor { ); let environment_processor = EnvironmentRequestProcessor::new(thread_manager.environment_manager()); - // `fs/*` is a local-host filesystem surface. Do not construct it when - // the manager intentionally has no local environment. - let fs_processor = thread_manager - .environment_manager() - .try_local_environment() - .map(|environment| { - FsRequestProcessor::new( - environment.get_filesystem(), - FsWatchManager::new(outgoing.clone()), - ) - }); + let fs_processor = FsRequestProcessor::new( + Arc::clone(&environment_manager_for_requests), + FsWatchManager::new(outgoing.clone()), + ); let windows_sandbox_processor = WindowsSandboxRequestProcessor::new( outgoing.clone(), Arc::clone(&config), @@ -505,7 +483,6 @@ impl MessageProcessor { command_exec_processor, process_exec_processor, config_processor, - environment_manager: environment_manager_for_requests, environment_processor, external_agent_config_processor, feedback_processor, @@ -729,9 +706,7 @@ impl MessageProcessor { ) { session_state.rpc_gate.shutdown().await; self.outgoing.connection_closed(connection_id).await; - if let Some(fs_processor) = &self.fs_processor { - fs_processor.connection_closed(connection_id).await; - } + self.fs_processor.connection_closed(connection_id).await; self.command_exec_processor .connection_closed(connection_id) .await; @@ -937,47 +912,47 @@ impl MessageProcessor { self.environment_processor.environment_add(params).await } ClientRequest::FsReadFile { params, .. } => self - .fs_processor()? + .fs_processor .read_file(params) .await .map(|response| Some(response.into())), ClientRequest::FsWriteFile { params, .. } => self - .fs_processor()? + .fs_processor .write_file(params) .await .map(|response| Some(response.into())), ClientRequest::FsCreateDirectory { params, .. } => self - .fs_processor()? + .fs_processor .create_directory(params) .await .map(|response| Some(response.into())), ClientRequest::FsGetMetadata { params, .. } => self - .fs_processor()? + .fs_processor .get_metadata(params) .await .map(|response| Some(response.into())), ClientRequest::FsReadDirectory { params, .. } => self - .fs_processor()? + .fs_processor .read_directory(params) .await .map(|response| Some(response.into())), ClientRequest::FsRemove { params, .. } => self - .fs_processor()? + .fs_processor .remove(params) .await .map(|response| Some(response.into())), ClientRequest::FsCopy { params, .. } => self - .fs_processor()? + .fs_processor .copy(params) .await .map(|response| Some(response.into())), ClientRequest::FsWatch { params, .. } => self - .fs_processor()? + .fs_processor .watch(connection_id, params) .await .map(|response| Some(response.into())), ClientRequest::FsUnwatch { params, .. } => self - .fs_processor()? + .fs_processor .unwatch(connection_id, params) .await .map(|response| Some(response.into())), @@ -1306,7 +1281,6 @@ impl MessageProcessor { .await .map(|response| Some(response.into())), ClientRequest::OneOffCommandExec { params, .. } => { - self.require_local_environment()?; self.command_exec_processor .one_off_command_exec(&request_id, params) .await @@ -1326,13 +1300,11 @@ impl MessageProcessor { .command_exec_terminate(request_id.clone(), params) .await } - ClientRequest::ProcessSpawn { params, .. } => { - self.require_local_environment()?; - self.process_exec_processor - .process_spawn(request_id.clone(), params) - .await - .map(|()| None) - } + ClientRequest::ProcessSpawn { params, .. } => self + .process_exec_processor + .process_spawn(request_id.clone(), params) + .await + .map(|()| None), ClientRequest::ProcessWriteStdin { params, .. } => { self.process_exec_processor .process_write_stdin(request_id.clone(), params) 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 1219eb17f9..930cc6d18f 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 @@ -6,6 +6,7 @@ pub(crate) struct CommandExecRequestProcessor { config: Arc, outgoing: Arc, config_manager: ConfigManager, + environment_manager: Arc, command_exec_manager: CommandExecManager, } @@ -15,12 +16,14 @@ impl CommandExecRequestProcessor { config: Arc, outgoing: Arc, config_manager: ConfigManager, + environment_manager: Arc, ) -> Self { Self { arg0_paths, config, outgoing, config_manager, + environment_manager, command_exec_manager: CommandExecManager::default(), } } @@ -30,6 +33,7 @@ impl CommandExecRequestProcessor { request_id: &ConnectionRequestId, params: CommandExecParams, ) -> Result, JSONRPCErrorError> { + self.require_local_environment()?; self.exec_one_off_command(request_id, params) .await .map(|()| None) @@ -74,6 +78,14 @@ impl CommandExecRequestProcessor { .await; } + fn require_local_environment(&self) -> Result<(), JSONRPCErrorError> { + self.environment_manager + .try_local_environment() + .is_some() + .then_some(()) + .ok_or_else(|| internal_error("local environment is not configured")) + } + async fn exec_one_off_command( &self, request_id: &ConnectionRequestId, diff --git a/codex-rs/app-server/src/request_processors/fs_processor.rs b/codex-rs/app-server/src/request_processors/fs_processor.rs index 01b9b20bfd..99a8620b4f 100644 --- a/codex-rs/app-server/src/request_processors/fs_processor.rs +++ b/codex-rs/app-server/src/request_processors/fs_processor.rs @@ -26,6 +26,7 @@ use codex_app_server_protocol::FsWriteFileResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_exec_server::CopyOptions; use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::RemoveOptions; use std::io; @@ -33,21 +34,28 @@ use std::sync::Arc; #[derive(Clone)] pub(crate) struct FsRequestProcessor { - file_system: Arc, + environment_manager: Arc, fs_watch_manager: FsWatchManager, } impl FsRequestProcessor { pub(crate) fn new( - file_system: Arc, + environment_manager: Arc, fs_watch_manager: FsWatchManager, ) -> Self { Self { - file_system, + environment_manager, fs_watch_manager, } } + fn file_system(&self) -> Result, JSONRPCErrorError> { + self.environment_manager + .try_local_environment() + .map(|environment| environment.get_filesystem()) + .ok_or_else(|| internal_error("local filesystem is not configured")) + } + pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) { self.fs_watch_manager.connection_closed(connection_id).await; } @@ -57,7 +65,7 @@ impl FsRequestProcessor { params: FsReadFileParams, ) -> Result { let bytes = self - .file_system + .file_system()? .read_file(¶ms.path, /*sandbox*/ None) .await .map_err(map_fs_error)?; @@ -75,7 +83,7 @@ impl FsRequestProcessor { "fs/writeFile requires valid base64 dataBase64: {err}" )) })?; - self.file_system + self.file_system()? .write_file(¶ms.path, bytes, /*sandbox*/ None) .await .map_err(map_fs_error)?; @@ -86,7 +94,7 @@ impl FsRequestProcessor { &self, params: FsCreateDirectoryParams, ) -> Result { - self.file_system + self.file_system()? .create_directory( ¶ms.path, CreateDirectoryOptions { @@ -104,7 +112,7 @@ impl FsRequestProcessor { params: FsGetMetadataParams, ) -> Result { let metadata = self - .file_system + .file_system()? .get_metadata(¶ms.path, /*sandbox*/ None) .await .map_err(map_fs_error)?; @@ -122,7 +130,7 @@ impl FsRequestProcessor { params: FsReadDirectoryParams, ) -> Result { let entries = self - .file_system + .file_system()? .read_directory(¶ms.path, /*sandbox*/ None) .await .map_err(map_fs_error)?; @@ -142,7 +150,7 @@ impl FsRequestProcessor { &self, params: FsRemoveParams, ) -> Result { - self.file_system + self.file_system()? .remove( ¶ms.path, RemoveOptions { @@ -160,7 +168,7 @@ impl FsRequestProcessor { &self, params: FsCopyParams, ) -> Result { - self.file_system + self.file_system()? .copy( ¶ms.source_path, ¶ms.destination_path, @@ -179,6 +187,7 @@ impl FsRequestProcessor { connection_id: ConnectionId, params: FsWatchParams, ) -> Result { + self.file_system()?; self.fs_watch_manager.watch(connection_id, params).await } @@ -187,6 +196,7 @@ impl FsRequestProcessor { connection_id: ConnectionId, params: FsUnwatchParams, ) -> Result { + self.file_system()?; self.fs_watch_manager.unwatch(connection_id, params).await } } diff --git a/codex-rs/app-server/src/request_processors/process_exec_processor.rs b/codex-rs/app-server/src/request_processors/process_exec_processor.rs index 5742d0e4d5..0b84c7f7b9 100644 --- a/codex-rs/app-server/src/request_processors/process_exec_processor.rs +++ b/codex-rs/app-server/src/request_processors/process_exec_processor.rs @@ -23,6 +23,7 @@ use codex_app_server_protocol::ServerNotification; use codex_core::exec::ExecExpiration; use codex_core::exec::ExecExpirationOutcome; use codex_core::exec::IO_DRAIN_TIMEOUT_MS; +use codex_exec_server::EnvironmentManager; use codex_protocol::exec_output::bytes_to_string_smart; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; @@ -48,13 +49,18 @@ const OUTPUT_CHUNK_SIZE_HINT: usize = 64 * 1024; #[derive(Clone)] pub(crate) struct ProcessExecRequestProcessor { outgoing: Arc, + environment_manager: Arc, process_exec_manager: ProcessExecManager, } impl ProcessExecRequestProcessor { - pub(crate) fn new(outgoing: Arc) -> Self { + pub(crate) fn new( + outgoing: Arc, + environment_manager: Arc, + ) -> Self { Self { outgoing, + environment_manager, process_exec_manager: ProcessExecManager::default(), } } @@ -64,6 +70,7 @@ impl ProcessExecRequestProcessor { request_id: ConnectionRequestId, params: ProcessSpawnParams, ) -> Result<(), JSONRPCErrorError> { + self.require_local_environment()?; let ProcessSpawnParams { command, process_handle, @@ -173,6 +180,14 @@ impl ProcessExecRequestProcessor { .connection_closed(connection_id) .await; } + + fn require_local_environment(&self) -> Result<(), JSONRPCErrorError> { + self.environment_manager + .try_local_environment() + .is_some() + .then_some(()) + .ok_or_else(|| internal_error("local environment is not configured")) + } } #[derive(Clone, Default)] From 8335b56c3351c0053ee7598d4954f9bfb42f13ac Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Tue, 19 May 2026 14:45:37 -0700 Subject: [PATCH 05/63] Split plugin install discovery into list and request tools (#23372) ## Summary - Add `list_available_plugins_to_install` as the inventory step for plugin and connector install suggestions. - Slim `request_plugin_install` so it only handles the actual elicitation, instead of carrying the full discoverable list in its prompt. - Emit send-time telemetry when an install elicitation is dispatched, including requested tool identity in the event payload. - Emit install-result telemetry through `SessionTelemetry`, including tool type, user response action, and completion status. - Update registration and tests to cover the new two-step flow while keeping the existing `tool_suggest` feature gate unchanged. ## Testing - `just fmt` - `cargo test -p codex-tools` - `cargo test -p codex-core request_plugin_install` - `cargo test -p codex-core list_available_plugins_to_install` - `cargo test -p codex-core install_suggestion_tools_can_be_registered_without_search_tool` - `cargo test -p codex-otel manager_records_plugin_install_suggestion_metric` - `cargo test -p codex-otel manager_records_plugin_install_elicitation_sent_metric` - `just fix -p codex-core` - `just fix -p codex-tools` - `just fix -p codex-otel` - `cargo check -p codex-core` --- codex-rs/core/src/mcp_tool_call.rs | 6 +- codex-rs/core/src/session/mcp.rs | 79 +++++++- codex-rs/core/src/session/mcp_tests.rs | 57 ++++++ codex-rs/core/src/session/tests.rs | 3 +- .../list_available_plugins_to_install.rs | 173 ++++++++++++++++++ .../list_available_plugins_to_install_spec.rs | 45 +++++ codex-rs/core/src/tools/handlers/mod.rs | 3 + .../tools/handlers/request_plugin_install.rs | 44 +++-- .../handlers/request_plugin_install_spec.rs | 125 +------------ codex-rs/core/src/tools/spec_plan.rs | 7 +- codex-rs/core/src/tools/spec_plan_tests.rs | 80 +++++++- .../tests/suite/request_plugin_install.rs | 29 ++- codex-rs/otel/src/events/session_telemetry.rs | 63 +++++++ codex-rs/otel/src/metrics/names.rs | 2 + codex-rs/otel/tests/suite/manager_metrics.rs | 99 ++++++++++ codex-rs/tools/src/lib.rs | 2 + codex-rs/tools/src/tool_discovery.rs | 8 +- 17 files changed, 667 insertions(+), 158 deletions(-) create mode 100644 codex-rs/core/src/tools/handlers/list_available_plugins_to_install.rs create mode 100644 codex-rs/core/src/tools/handlers/list_available_plugins_to_install_spec.rs diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 902ede574e..6dc9f82465 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -661,7 +661,8 @@ async fn maybe_request_codex_apps_auth_elicitation( }; let response = sess .request_mcp_server_elicitation(turn_context, request_id, params) - .await; + .await + .response; if !response .as_ref() .is_some_and(|response| response.action == ElicitationAction::Accept) @@ -1325,7 +1326,8 @@ async fn maybe_request_mcp_tool_approval( ); let decision = parse_mcp_tool_approval_elicitation_response( sess.request_mcp_server_elicitation(turn_context.as_ref(), request_id, params) - .await, + .await + .response, &question_id, ); let decision = normalize_approval_decision_for_mode(decision, approval_mode); diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 47201414de..738a328671 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -5,6 +5,7 @@ use codex_mcp::ElicitationReviewerHandle; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY as MCP_ELICITATION_APPROVAL_KIND_KEY; use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL as MCP_ELICITATION_APPROVAL_KIND_MCP_TOOL_CALL; +use codex_protocol::mcp_approval_meta::APPROVAL_KIND_TOOL_SUGGESTION as MCP_ELICITATION_APPROVAL_KIND_TOOL_SUGGESTION; use codex_protocol::mcp_approval_meta::APPROVALS_REVIEWER_KEY as MCP_ELICITATION_APPROVALS_REVIEWER_KEY; use codex_protocol::mcp_approval_meta::CONNECTOR_DESCRIPTION_KEY as MCP_ELICITATION_CONNECTOR_DESCRIPTION_KEY; use codex_protocol::mcp_approval_meta::CONNECTOR_ID_KEY as MCP_ELICITATION_CONNECTOR_ID_KEY; @@ -21,6 +22,10 @@ use rmcp::model::Meta; use serde_json::Map; const MCP_ELICITATION_DECLINE_MESSAGE_KEY: &str = "message"; +const TOOL_SUGGESTION_ACTION_INSTALL: &str = "install"; +const TOOL_SUGGESTION_ACTION_KEY: &str = "suggest_type"; +const TOOL_SUGGESTION_TOOL_ID_KEY: &str = "tool_id"; +const TOOL_SUGGESTION_TOOL_TYPE_KEY: &str = "tool_type"; #[derive(Debug, PartialEq)] enum GuardianElicitationReview { @@ -33,6 +38,18 @@ struct GuardianMcpElicitationReviewer { session: std::sync::Weak, } +pub(crate) struct McpServerElicitationOutcome { + pub(crate) response: Option, + pub(crate) sent: bool, +} + +#[derive(Debug, PartialEq, Eq)] +struct PluginInstallElicitationTelemetryMetadata { + tool_type: String, + tool_id: String, + tool_name: String, +} + impl GuardianMcpElicitationReviewer { fn new(session: &Arc) -> Self { Self { @@ -70,7 +87,7 @@ impl Session { turn_context: &TurnContext, request_id: RequestId, params: McpServerElicitationRequestParams, - ) -> Option { + ) -> McpServerElicitationOutcome { if self .services .mcp_connection_manager @@ -78,11 +95,14 @@ impl Session { .await .elicitations_auto_deny() { - return Some(ElicitationResponse { - action: codex_rmcp_client::ElicitationAction::Accept, - content: Some(serde_json::json!({})), - meta: None, - }); + return McpServerElicitationOutcome { + response: Some(ElicitationResponse { + action: codex_rmcp_client::ElicitationAction::Accept, + content: Some(serde_json::json!({})), + meta: None, + }), + sent: false, + }; } let server_name = params.server_name.clone(); @@ -98,7 +118,10 @@ impl Session { warn!( "failed to serialize MCP elicitation schema for server_name: {server_name}, request_id: {request_id}: {err:#}" ); - return None; + return McpServerElicitationOutcome { + response: None, + sent: false, + }; } }; codex_protocol::approvals::ElicitationRequest::Form { @@ -154,11 +177,24 @@ impl Session { id, request, }); + let plugin_install_telemetry = plugin_install_elicitation_telemetry_metadata(&event); turn_context .turn_metadata_state .mark_user_input_requested_during_turn(); self.send_event(turn_context, event).await; - rx_response.await.ok() + if let Some(plugin_install_telemetry) = plugin_install_telemetry { + turn_context + .session_telemetry + .record_plugin_install_elicitation_sent( + plugin_install_telemetry.tool_type.as_str(), + plugin_install_telemetry.tool_id.as_str(), + plugin_install_telemetry.tool_name.as_str(), + ); + } + McpServerElicitationOutcome { + response: rx_response.await.ok(), + sent: true, + } } #[expect( @@ -551,6 +587,33 @@ fn metadata_owned_string(meta: &Map, key: &str) -> Option .map(ToOwned::to_owned) } +fn plugin_install_elicitation_telemetry_metadata( + event: &EventMsg, +) -> Option { + let EventMsg::ElicitationRequest(ElicitationRequestEvent { request, .. }) = event else { + return None; + }; + let codex_protocol::approvals::ElicitationRequest::Form { + meta: Some(Value::Object(meta)), + .. + } = request + else { + return None; + }; + if metadata_str(meta, MCP_ELICITATION_APPROVAL_KIND_KEY) + != Some(MCP_ELICITATION_APPROVAL_KIND_TOOL_SUGGESTION) + || metadata_str(meta, TOOL_SUGGESTION_ACTION_KEY) != Some(TOOL_SUGGESTION_ACTION_INSTALL) + { + return None; + } + + Some(PluginInstallElicitationTelemetryMetadata { + tool_type: metadata_owned_string(meta, TOOL_SUGGESTION_TOOL_TYPE_KEY)?, + tool_id: metadata_owned_string(meta, TOOL_SUGGESTION_TOOL_ID_KEY)?, + tool_name: metadata_owned_string(meta, MCP_ELICITATION_TOOL_NAME_KEY)?, + }) +} + fn mcp_elicitation_request_id(id: &RequestId) -> String { match id { rmcp::model::NumberOrString::String(value) => value.to_string(), diff --git a/codex-rs/core/src/session/mcp_tests.rs b/codex-rs/core/src/session/mcp_tests.rs index 31b304faa5..0e3664019b 100644 --- a/codex-rs/core/src/session/mcp_tests.rs +++ b/codex-rs/core/src/session/mcp_tests.rs @@ -96,6 +96,63 @@ fn guardian_elicitation_review_request_defaults_missing_tool_params() { assert_eq!(arguments, Some(json!({}))); } +#[test] +fn plugin_install_elicitation_telemetry_metadata_requires_install_tool_suggestion() { + let event = EventMsg::ElicitationRequest(ElicitationRequestEvent { + turn_id: Some("turn-1".to_string()), + server_name: "codex_apps".to_string(), + id: codex_protocol::mcp::RequestId::String("request-1".to_string()), + request: codex_protocol::approvals::ElicitationRequest::Form { + meta: Some(json!({ + "codex_approval_kind": "tool_suggestion", + "suggest_type": "install", + "tool_type": "plugin", + "tool_id": "slack@openai-curated", + "tool_name": "Slack", + })), + message: "Install Slack?".to_string(), + requested_schema: json!({ + "type": "object", + "properties": {}, + }), + }, + }); + + assert_eq!( + plugin_install_elicitation_telemetry_metadata(&event), + Some(PluginInstallElicitationTelemetryMetadata { + tool_type: "plugin".to_string(), + tool_id: "slack@openai-curated".to_string(), + tool_name: "Slack".to_string(), + }) + ); + + let enable_event = EventMsg::ElicitationRequest(ElicitationRequestEvent { + turn_id: Some("turn-1".to_string()), + server_name: "codex_apps".to_string(), + id: codex_protocol::mcp::RequestId::String("request-2".to_string()), + request: codex_protocol::approvals::ElicitationRequest::Form { + meta: Some(json!({ + "codex_approval_kind": "tool_suggestion", + "suggest_type": "enable", + "tool_type": "plugin", + "tool_id": "slack@openai-curated", + "tool_name": "Slack", + })), + message: "Enable Slack?".to_string(), + requested_schema: json!({ + "type": "object", + "properties": {}, + }), + }, + }); + + assert_eq!( + plugin_install_elicitation_telemetry_metadata(&enable_event), + None + ); +} + #[test] fn guardian_elicitation_review_request_requires_opt_in() { let request = form_request(meta(json!({ diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 88e9092da3..bfe3cfb3fe 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -328,13 +328,14 @@ async fn request_mcp_server_elicitation_auto_accepts_when_auto_deny_is_enabled() .await; assert_eq!( - response, + response.response, Some(ElicitationResponse { action: ElicitationAction::Accept, content: Some(json!({})), meta: None, }) ); + assert!(!response.sent); assert!(rx.try_recv().is_err()); } diff --git a/codex-rs/core/src/tools/handlers/list_available_plugins_to_install.rs b/codex-rs/core/src/tools/handlers/list_available_plugins_to_install.rs new file mode 100644 index 0000000000..143eb5a800 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/list_available_plugins_to_install.rs @@ -0,0 +1,173 @@ +use codex_tools::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME; +use codex_tools::ListAvailablePluginsToInstallResult; +use codex_tools::RequestPluginInstallEntry; +use codex_tools::ToolName; +use codex_tools::ToolSpec; + +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; +use crate::tools::handlers::list_available_plugins_to_install_spec::create_list_available_plugins_to_install_tool; +use crate::tools::registry::CoreToolRuntime; +use crate::tools::registry::ToolExecutor; + +const MAX_LIST_AVAILABLE_PLUGINS_TO_INSTALL_DESCRIPTION_CHARS: usize = 240; + +pub struct ListAvailablePluginsToInstallHandler { + tools: Vec, +} + +impl ListAvailablePluginsToInstallHandler { + pub(crate) fn new(mut tools: Vec) -> Self { + tools.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.id.cmp(&right.id)) + }); + Self { tools } + } + + fn result(&self) -> ListAvailablePluginsToInstallResult { + ListAvailablePluginsToInstallResult { + tools: self + .tools + .iter() + .map(|tool| RequestPluginInstallEntry { + id: tool.id.clone(), + name: tool.name.clone(), + description: tool.description.as_ref().map(|description| { + truncate_to_char_boundary( + description, + MAX_LIST_AVAILABLE_PLUGINS_TO_INSTALL_DESCRIPTION_CHARS, + ) + .to_string() + }), + tool_type: tool.tool_type, + has_skills: tool.has_skills, + mcp_server_names: tool.mcp_server_names.clone(), + app_connector_ids: tool.app_connector_ids.clone(), + }) + .collect(), + } + } +} + +#[async_trait::async_trait] +impl ToolExecutor for ListAvailablePluginsToInstallHandler { + fn tool_name(&self) -> ToolName { + ToolName::plain(LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME) + } + + fn spec(&self) -> Option { + Some(create_list_available_plugins_to_install_tool()) + } + + fn supports_parallel_tool_calls(&self) -> bool { + false + } + + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + let ToolInvocation { payload, .. } = invocation; + match payload { + ToolPayload::Function { .. } => {} + _ => { + return Err(FunctionCallError::Fatal(format!( + "{LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME} handler received unsupported payload" + ))); + } + } + + let content = serde_json::to_string(&self.result()).map_err(|err| { + FunctionCallError::Fatal(format!( + "failed to serialize {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME} response: {err}" + )) + })?; + + Ok(boxed_tool_output(FunctionToolOutput::from_text( + content, + Some(true), + ))) + } +} + +impl CoreToolRuntime for ListAvailablePluginsToInstallHandler {} + +fn truncate_to_char_boundary(value: &str, max_chars: usize) -> &str { + match value.char_indices().nth(max_chars) { + Some((index, _)) => &value[..index], + None => value, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_tools::DiscoverableToolType; + use pretty_assertions::assert_eq; + + #[test] + fn list_tool_does_not_support_parallel_calls() { + assert!( + !ListAvailablePluginsToInstallHandler::new(Vec::new()).supports_parallel_tool_calls() + ); + } + + #[test] + fn result_truncates_candidate_descriptions() { + let handler = ListAvailablePluginsToInstallHandler::new(vec![ + RequestPluginInstallEntry { + id: "sample@openai-curated".to_string(), + name: "Sample Plugin".to_string(), + description: Some( + "x".repeat(MAX_LIST_AVAILABLE_PLUGINS_TO_INSTALL_DESCRIPTION_CHARS + 1), + ), + tool_type: DiscoverableToolType::Plugin, + has_skills: true, + mcp_server_names: vec!["sample-mcp".to_string()], + app_connector_ids: vec!["connector-sample".to_string()], + }, + RequestPluginInstallEntry { + id: "calendar@openai-curated".to_string(), + name: "Calendar".to_string(), + description: Some("calendar".to_string()), + tool_type: DiscoverableToolType::Plugin, + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }, + ]); + + assert_eq!( + handler.result(), + ListAvailablePluginsToInstallResult { + tools: vec![ + RequestPluginInstallEntry { + id: "calendar@openai-curated".to_string(), + name: "Calendar".to_string(), + description: Some("calendar".to_string()), + tool_type: DiscoverableToolType::Plugin, + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }, + RequestPluginInstallEntry { + id: "sample@openai-curated".to_string(), + name: "Sample Plugin".to_string(), + description: Some( + "x".repeat(MAX_LIST_AVAILABLE_PLUGINS_TO_INSTALL_DESCRIPTION_CHARS,) + ), + tool_type: DiscoverableToolType::Plugin, + has_skills: true, + mcp_server_names: vec!["sample-mcp".to_string()], + app_connector_ids: vec!["connector-sample".to_string()], + }, + ], + } + ); + } +} diff --git a/codex-rs/core/src/tools/handlers/list_available_plugins_to_install_spec.rs b/codex-rs/core/src/tools/handlers/list_available_plugins_to_install_spec.rs new file mode 100644 index 0000000000..ac3120753d --- /dev/null +++ b/codex-rs/core/src/tools/handlers/list_available_plugins_to_install_spec.rs @@ -0,0 +1,45 @@ +use codex_tools::JsonSchema; +use codex_tools::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME; +use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME; +use codex_tools::ResponsesApiTool; +use codex_tools::TOOL_SEARCH_TOOL_NAME; +use codex_tools::ToolSpec; +pub(crate) fn create_list_available_plugins_to_install_tool() -> ToolSpec { + let description = format!( + "# List plugin/connector install candidates\n\nUse this tool only when both are true:\n- The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list.\n- `{TOOL_SEARCH_TOOL_NAME}` is not available, or it has already been called and did not find or make the requested tool callable.\n\nReturns known plugins and connectors that can be passed to `{REQUEST_PLUGIN_INSTALL_TOOL_NAME}`. When both a plugin and a connector match, prefer the plugin; use the connector only when its corresponding plugin is already installed.\n" + ); + + ToolSpec::Function(ResponsesApiTool { + name: LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME.to_string(), + description, + strict: false, + defer_loading: None, + parameters: JsonSchema::object(Default::default(), Some(Vec::new()), Some(false.into())), + output_schema: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn create_list_available_plugins_to_install_tool_uses_expected_wire_shape() { + assert_eq!( + create_list_available_plugins_to_install_tool(), + ToolSpec::Function(ResponsesApiTool { + name: "list_available_plugins_to_install".to_string(), + description: "# List plugin/connector install candidates\n\nUse this tool only when both are true:\n- The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list.\n- `tool_search` is not available, or it has already been called and did not find or make the requested tool callable.\n\nReturns known plugins and connectors that can be passed to `request_plugin_install`. When both a plugin and a connector match, prefer the plugin; use the connector only when its corresponding plugin is already installed.\n".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + Default::default(), + Some(Vec::new()), + Some(false.into()), + ), + output_schema: None, + }) + ); + } +} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index fe956faa37..b2cbe62907 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -6,6 +6,8 @@ mod dynamic; pub(crate) mod extension_tools; mod goal; pub(crate) mod goal_spec; +mod list_available_plugins_to_install; +pub(crate) mod list_available_plugins_to_install_spec; mod mcp; mod mcp_resource; pub(crate) mod mcp_resource_spec; @@ -54,6 +56,7 @@ pub use dynamic::DynamicToolHandler; pub use goal::CreateGoalHandler; pub use goal::GetGoalHandler; pub use goal::UpdateGoalHandler; +pub use list_available_plugins_to_install::ListAvailablePluginsToInstallHandler; pub use mcp::McpHandler; pub use mcp_resource::ListMcpResourceTemplatesHandler; pub use mcp_resource::ListMcpResourcesHandler; 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 3b53fd007a..ffb5bb568b 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -8,17 +8,16 @@ use codex_rmcp_client::ElicitationResponse; use codex_tools::DiscoverableTool; use codex_tools::DiscoverableToolAction; use codex_tools::DiscoverableToolType; +use codex_tools::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME; use codex_tools::REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE; use codex_tools::REQUEST_PLUGIN_INSTALL_PERSIST_KEY; use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME; use codex_tools::RequestPluginInstallArgs; -use codex_tools::RequestPluginInstallEntry; use codex_tools::RequestPluginInstallResult; use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_tools::all_requested_connectors_picked_up; use codex_tools::build_request_plugin_install_elicitation_request; -use codex_tools::collect_request_plugin_install_entries; use codex_tools::filter_request_plugin_install_discoverable_tools_for_client; use codex_tools::verified_connector_install_completed; use rmcp::model::RequestId; @@ -38,18 +37,7 @@ use crate::tools::handlers::request_plugin_install_spec::create_request_plugin_i use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -#[derive(Default)] -pub struct RequestPluginInstallHandler { - discoverable_tools: Vec, -} - -impl RequestPluginInstallHandler { - pub(crate) fn new(discoverable_tools: &[DiscoverableTool]) -> Self { - Self { - discoverable_tools: collect_request_plugin_install_entries(discoverable_tools), - } - } -} +pub struct RequestPluginInstallHandler; #[async_trait::async_trait] impl ToolExecutor for RequestPluginInstallHandler { @@ -58,7 +46,7 @@ impl ToolExecutor for RequestPluginInstallHandler { } fn spec(&self) -> Option { - Some(create_request_plugin_install_tool(&self.discoverable_tools)) + Some(create_request_plugin_install_tool()) } fn supports_parallel_tool_calls(&self) -> bool { @@ -142,7 +130,7 @@ impl ToolExecutor for RequestPluginInstallHandler { .find(|tool| tool.tool_type() == args.tool_type && tool.id() == args.tool_id) .ok_or_else(|| { FunctionCallError::RespondToModel(format!( - "tool_id must match one of the discoverable tools exposed by {REQUEST_PLUGIN_INSTALL_TOOL_NAME}" + "tool_id must match one of the discoverable tools returned by {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}" )) })?; @@ -155,9 +143,10 @@ impl ToolExecutor for RequestPluginInstallHandler { suggest_reason, &tool, ); - let response = session + let elicitation = session .request_mcp_server_elicitation(turn.as_ref(), request_id, params) .await; + let response = elicitation.response; if let Some(response) = response.as_ref() { maybe_persist_disabled_install_request(&session, &turn, &tool, response).await; } @@ -177,6 +166,27 @@ impl ToolExecutor for RequestPluginInstallHandler { .await; } + if elicitation.sent { + let tool_type = match args.tool_type { + DiscoverableToolType::Connector => "connector", + DiscoverableToolType::Plugin => "plugin", + }; + let response_action = match response.as_ref().map(|response| &response.action) { + Some(ElicitationAction::Accept) => "accept", + Some(ElicitationAction::Decline) => "decline", + Some(ElicitationAction::Cancel) => "cancel", + None => "unavailable", + }; + turn.session_telemetry.record_plugin_install_suggestion( + tool_type, + tool.id(), + tool.name(), + response_action, + user_confirmed, + completed, + ); + } + let content = serde_json::to_string(&RequestPluginInstallResult { completed, user_confirmed, 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 d8b0a042c4..ed143283e7 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 @@ -1,15 +1,11 @@ -use codex_tools::DiscoverableToolType; use codex_tools::JsonSchema; +use codex_tools::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME; use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME; -use codex_tools::RequestPluginInstallEntry; use codex_tools::ResponsesApiTool; -use codex_tools::TOOL_SEARCH_TOOL_NAME; use codex_tools::ToolSpec; use std::collections::BTreeMap; -pub(crate) fn create_request_plugin_install_tool( - discoverable_tools: &[RequestPluginInstallEntry], -) -> ToolSpec { +pub(crate) fn create_request_plugin_install_tool() -> ToolSpec { let properties = BTreeMap::from([ ( "tool_type".to_string(), @@ -35,9 +31,8 @@ pub(crate) fn create_request_plugin_install_tool( ), ]); - let discoverable_tools = format_discoverable_tools(discoverable_tools); let description = format!( - "# Request plugin/connector install\n\nUse this tool only to ask the user to install one known plugin or connector from the list below. The list contains known candidates that are not currently installed.\n\nUse this ONLY when all of the following are true:\n- The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list.\n- `{TOOL_SEARCH_TOOL_NAME}` is not available, or it has already been called and did not find or make the requested tool callable.\n- The plugin or connector is one of the known installable plugins or connectors listed below. Only ask to install plugins or connectors from this list.\n\nDo not use this tool for adjacent capabilities, broad recommendations, or tools that merely seem useful. Only use when the user explicitly asks to use that exact listed plugin or connector.\n\nKnown plugins/connectors available to install:\n{discoverable_tools}\n\nWorkflow:\n\n1. Check the current context and active `tools` list first. If current active tools aren't relevant and `{TOOL_SEARCH_TOOL_NAME}` is available, only call this tool after `{TOOL_SEARCH_TOOL_NAME}` has already been tried and found no relevant tool.\n2. Match the user's explicit request against the known plugin/connector list above. Only proceed when one listed plugin or connector exactly fits.\n3. If we found both connectors and plugins to install, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not.\n4. If one plugin or connector clearly fits, call `{REQUEST_PLUGIN_INSTALL_TOOL_NAME}` with:\n - `tool_type`: `connector` or `plugin`\n - `action_type`: `install`\n - `tool_id`: exact id from the known plugin/connector list above\n - `suggest_reason`: concise one-line user-facing reason this plugin or connector can help with the current request\n5. After the request flow completes:\n - if the user finished the install flow, continue by searching again or using the newly available plugin or connector\n - if the user did not finish, continue without that plugin or connector, and don't request it again unless the user explicitly asks for it.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools." + "# 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." ); ToolSpec::Function(ResponsesApiTool { @@ -59,74 +54,6 @@ pub(crate) fn create_request_plugin_install_tool( }) } -fn format_discoverable_tools(discoverable_tools: &[RequestPluginInstallEntry]) -> String { - let mut discoverable_tools = discoverable_tools.to_vec(); - discoverable_tools.sort_by(|left, right| { - left.name - .cmp(&right.name) - .then_with(|| left.id.cmp(&right.id)) - }); - - discoverable_tools - .into_iter() - .map(|tool| { - let description = tool_description_or_fallback(&tool); - format!( - "- {} (id: `{}`, type: {}, action: install): {}", - tool.name, - tool.id, - discoverable_tool_type_str(tool.tool_type), - description - ) - }) - .collect::>() - .join("\n") -} - -fn tool_description_or_fallback(tool: &RequestPluginInstallEntry) -> String { - if let Some(description) = tool - .description - .as_deref() - .map(str::trim) - .filter(|description| !description.is_empty()) - { - return description.to_string(); - } - - match tool.tool_type { - DiscoverableToolType::Connector => "No description provided.".to_string(), - DiscoverableToolType::Plugin => plugin_summary(tool), - } -} - -fn plugin_summary(tool: &RequestPluginInstallEntry) -> String { - let mut capabilities = Vec::new(); - if tool.has_skills { - capabilities.push("skills".to_string()); - } - if !tool.mcp_server_names.is_empty() { - capabilities.push(format!("MCP servers: {}", tool.mcp_server_names.join(", "))); - } - if !tool.app_connector_ids.is_empty() { - capabilities.push(format!( - "app connectors: {}", - tool.app_connector_ids.join(", ") - )); - } - if capabilities.is_empty() { - "No description provided.".to_string() - } else { - capabilities.join("; ") - } -} - -fn discoverable_tool_type_str(tool_type: DiscoverableToolType) -> &'static str { - match tool_type { - DiscoverableToolType::Connector => "connector", - DiscoverableToolType::Plugin => "plugin", - } -} - #[cfg(test)] mod tests { use super::*; @@ -135,54 +62,16 @@ mod tests { use std::collections::BTreeMap; #[test] - fn create_request_plugin_install_tool_uses_plugin_summary_fallback() { + fn create_request_plugin_install_tool_uses_expected_wire_shape() { let expected_description = concat!( "# Request plugin/connector install\n\n", - "Use this tool only to ask the user to install one known plugin or connector from the list below. The list contains known candidates that are not currently installed.\n\n", - "Use this ONLY when all of the following are true:\n", - "- The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list.\n", - "- `tool_search` is not available, or it has already been called and did not find or make the requested tool callable.\n", - "- The plugin or connector is one of the known installable plugins or connectors listed below. Only ask to install plugins or connectors from this list.\n\n", - "Do not use this tool for adjacent capabilities, broad recommendations, or tools that merely seem useful. Only use when the user explicitly asks to use that exact listed plugin or connector.\n\n", - "Known plugins/connectors available to install:\n", - "- GitHub (id: `github`, type: plugin, action: install): skills; MCP servers: github-mcp; app connectors: github-app\n", - "- Slack (id: `slack@openai-curated`, type: connector, action: install): No description provided.\n\n", - "Workflow:\n\n", - "1. Check the current context and active `tools` list first. If current active tools aren't relevant and `tool_search` is available, only call this tool after `tool_search` has already been tried and found no relevant tool.\n", - "2. Match the user's explicit request against the known plugin/connector list above. Only proceed when one listed plugin or connector exactly fits.\n", - "3. If we found both connectors and plugins to install, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not.\n", - "4. If one plugin or connector clearly fits, call `request_plugin_install` with:\n", - " - `tool_type`: `connector` or `plugin`\n", - " - `action_type`: `install`\n", - " - `tool_id`: exact id from the known plugin/connector list above\n", - " - `suggest_reason`: concise one-line user-facing reason this plugin or connector can help with the current request\n", - "5. After the request flow completes:\n", - " - if the user finished the install flow, continue by searching again or using the newly available plugin or connector\n", - " - if the user did not finish, continue without that plugin or connector, and don't request it again unless the user explicitly asks for it.\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", + "Do 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\n", "IMPORTANT: DO NOT call this tool in parallel with other tools.", ); assert_eq!( - create_request_plugin_install_tool(&[ - RequestPluginInstallEntry { - id: "slack@openai-curated".to_string(), - name: "Slack".to_string(), - description: None, - tool_type: DiscoverableToolType::Connector, - has_skills: false, - mcp_server_names: Vec::new(), - app_connector_ids: Vec::new(), - }, - RequestPluginInstallEntry { - id: "github".to_string(), - name: "GitHub".to_string(), - description: None, - tool_type: DiscoverableToolType::Plugin, - has_skills: true, - mcp_server_names: vec!["github-mcp".to_string()], - app_connector_ids: vec!["github-app".to_string()], - }, - ]), + create_request_plugin_install_tool(), ToolSpec::Function(ResponsesApiTool { name: "request_plugin_install".to_string(), description: expected_description.to_string(), diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index d75d56404d..ab9d02f97c 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -7,6 +7,7 @@ use crate::tools::handlers::CodeModeWaitHandler; use crate::tools::handlers::CreateGoalHandler; use crate::tools::handlers::DynamicToolHandler; use crate::tools::handlers::GetGoalHandler; +use crate::tools::handlers::ListAvailablePluginsToInstallHandler; use crate::tools::handlers::ListMcpResourceTemplatesHandler; use crate::tools::handlers::ListMcpResourcesHandler; use crate::tools::handlers::McpHandler; @@ -69,6 +70,7 @@ use codex_tools::ToolOutput; use codex_tools::ToolSpec; use codex_tools::can_request_original_image_detail; use codex_tools::collect_code_mode_exec_prompt_tool_definitions; +use codex_tools::collect_request_plugin_install_entries; use codex_tools::default_namespace_description; use codex_tools::request_user_input_available_modes; use codex_tools::shell_command_backend_for_features; @@ -522,7 +524,10 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut && let Some(discoverable_tools) = context.discoverable_tools.filter(|tools| !tools.is_empty()) { - planned_tools.add_runtime(RequestPluginInstallHandler::new(discoverable_tools)); + planned_tools.add_runtime(ListAvailablePluginsToInstallHandler::new( + collect_request_plugin_install_entries(discoverable_tools), + )); + planned_tools.add_runtime(RequestPluginInstallHandler); } if environment_mode.has_environment() && turn_context.model_info.apply_patch_tool_type.is_some() diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 22276af550..a936ac0ea1 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -19,6 +19,7 @@ use codex_protocol::protocol::SubAgentSource; use codex_tools::DiscoverablePluginInfo; use codex_tools::DiscoverableTool; use codex_tools::ResponsesApiNamespaceTool; +use codex_tools::ResponsesApiTool; use codex_tools::ToolExposure; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -524,7 +525,10 @@ async fn request_plugin_install_requires_all_discovery_features_and_discoverable }, ) .await; - plan.assert_visible_lacks(&["request_plugin_install"]); + plan.assert_visible_lacks(&[ + "list_available_plugins_to_install", + "request_plugin_install", + ]); } let no_candidates = probe(|turn| { @@ -534,7 +538,10 @@ async fn request_plugin_install_requires_all_discovery_features_and_discoverable ); }) .await; - no_candidates.assert_visible_lacks(&["request_plugin_install"]); + no_candidates.assert_visible_lacks(&[ + "list_available_plugins_to_install", + "request_plugin_install", + ]); let enabled = probe_with( |turn| { @@ -549,7 +556,74 @@ async fn request_plugin_install_requires_all_discovery_features_and_discoverable }, ) .await; - enabled.assert_visible_contains(&["request_plugin_install"]); + enabled.assert_visible_contains(&[ + "list_available_plugins_to_install", + "request_plugin_install", + ]); +} + +#[tokio::test] +async fn install_suggestion_tools_stay_visible_without_tool_search() { + let plan = probe_with( + |turn| { + turn.model_info.supports_search_tool = false; + set_features( + turn, + &[Feature::ToolSuggest, Feature::Apps, Feature::Plugins], + ); + }, + ToolPlanInputs { + discoverable_tools: Some(vec![discoverable_plugin("github", "GitHub")]), + ..ToolPlanInputs::default() + }, + ) + .await; + + plan.assert_visible_contains(&[ + "list_available_plugins_to_install", + "request_plugin_install", + ]); + plan.assert_visible_lacks(&["tool_search"]); +} + +#[tokio::test] +async fn request_plugin_install_description_defers_inventory_to_list_tool() { + let plan = probe_with( + |turn| { + set_features( + turn, + &[Feature::ToolSuggest, Feature::Apps, Feature::Plugins], + ); + }, + ToolPlanInputs { + discoverable_tools: Some(vec![discoverable_plugin("github", "GitHub")]), + ..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, + .. + }) = plan.visible_spec("request_plugin_install") + 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("github")); } #[tokio::test] diff --git a/codex-rs/core/tests/suite/request_plugin_install.rs b/codex-rs/core/tests/suite/request_plugin_install.rs index 443ec7495f..60db47b2ff 100644 --- a/codex-rs/core/tests/suite/request_plugin_install.rs +++ b/codex-rs/core/tests/suite/request_plugin_install.rs @@ -22,6 +22,7 @@ use core_test_support::test_codex::test_codex; use serde_json::Value; const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; +const LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME: &str = "list_available_plugins_to_install"; const REQUEST_PLUGIN_INSTALL_TOOL_NAME: &str = "request_plugin_install"; const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2"; @@ -125,6 +126,12 @@ async fn request_plugin_install_is_available_without_search_tool_after_discovery !tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME), "tools list should not include {TOOL_SEARCH_TOOL_NAME}: {tools:?}" ); + assert!( + tools + .iter() + .any(|name| name == LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME), + "tools list should include {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}: {tools:?}" + ); assert!( tools .iter() @@ -132,18 +139,26 @@ async fn request_plugin_install_is_available_without_search_tool_after_discovery "tools list should include {REQUEST_PLUGIN_INSTALL_TOOL_NAME}: {tools:?}" ); + 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 to ask the user to install one known plugin or connector from the list below" - )); - assert!(description.contains( - "`tool_search` is not available, or it has already been called and did not find or make the requested tool callable." - )); - assert!(description.contains( - "Only use when the user explicitly asks to use that exact listed plugin or connector." + "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(()) diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 6394c73ea2..6a77125e70 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -8,6 +8,8 @@ use crate::metrics::API_CALL_DURATION_METRIC; use crate::metrics::MetricsClient; use crate::metrics::MetricsConfig; use crate::metrics::MetricsError; +use crate::metrics::PLUGIN_INSTALL_ELICITATION_SENT_METRIC; +use crate::metrics::PLUGIN_INSTALL_SUGGESTION_METRIC; use crate::metrics::PROFILE_USAGE_METRIC; use crate::metrics::RESPONSES_API_ENGINE_IAPI_TBT_DURATION_METRIC; use crate::metrics::RESPONSES_API_ENGINE_IAPI_TTFT_DURATION_METRIC; @@ -230,6 +232,67 @@ impl SessionTelemetry { ); } + /// Records the moment a plugin or connector install elicitation is dispatched. + pub fn record_plugin_install_elicitation_sent( + &self, + tool_type: &str, + tool_id: &str, + tool_name: &str, + ) { + self.counter( + PLUGIN_INSTALL_ELICITATION_SENT_METRIC, + /*inc*/ 1, + &[("tool_type", tool_type)], + ); + log_and_trace_event!( + self, + common: { + event.name = "codex.plugin_install_elicitation_sent", + plugin_install.tool_type = tool_type, + plugin_install.tool_id = tool_id, + plugin_install.tool_name = tool_name, + }, + log: {}, + trace: {}, + ); + } + + /// Records the outcome of a surfaced plugin or connector install suggestion. + pub fn record_plugin_install_suggestion( + &self, + tool_type: &str, + tool_id: &str, + tool_name: &str, + response_action: &str, + user_confirmed: bool, + completed: bool, + ) { + let completed_tag = if completed { "true" } else { "false" }; + self.counter( + PLUGIN_INSTALL_SUGGESTION_METRIC, + /*inc*/ 1, + &[ + ("tool_type", tool_type), + ("response_action", response_action), + ("completed", completed_tag), + ], + ); + log_and_trace_event!( + self, + common: { + event.name = "codex.plugin_install_suggestion", + plugin_install.tool_type = tool_type, + plugin_install.tool_id = tool_id, + plugin_install.tool_name = tool_name, + plugin_install.response_action = response_action, + plugin_install.user_confirmed = user_confirmed, + plugin_install.completed = completed, + }, + log: {}, + trace: {}, + ); + } + pub fn start_timer(&self, name: &str, tags: &[(&str, &str)]) -> Result { let Some(metrics) = &self.metrics else { return Err(MetricsError::ExporterDisabled); diff --git a/codex-rs/otel/src/metrics/names.rs b/codex-rs/otel/src/metrics/names.rs index 17a39816a7..429db8116e 100644 --- a/codex-rs/otel/src/metrics/names.rs +++ b/codex-rs/otel/src/metrics/names.rs @@ -37,6 +37,8 @@ pub const GOAL_BLOCKED_METRIC: &str = "codex.goal.blocked"; pub const GOAL_TOKEN_COUNT_METRIC: &str = "codex.goal.token_count"; pub const GOAL_DURATION_SECONDS_METRIC: &str = "codex.goal.duration_s"; pub const PROFILE_USAGE_METRIC: &str = "codex.profile.usage"; +pub const PLUGIN_INSTALL_ELICITATION_SENT_METRIC: &str = "codex.plugins.install_elicitation.sent"; +pub const PLUGIN_INSTALL_SUGGESTION_METRIC: &str = "codex.plugins.install_suggestion"; pub const CURATED_PLUGINS_STARTUP_SYNC_METRIC: &str = "codex.plugins.startup_sync"; pub const CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC: &str = "codex.plugins.startup_sync.final"; pub const HOOK_RUN_METRIC: &str = "codex.hooks.run"; diff --git a/codex-rs/otel/tests/suite/manager_metrics.rs b/codex-rs/otel/tests/suite/manager_metrics.rs index d95f42dfc5..e13de3f432 100644 --- a/codex-rs/otel/tests/suite/manager_metrics.rs +++ b/codex-rs/otel/tests/suite/manager_metrics.rs @@ -2,6 +2,8 @@ use crate::harness::attributes_to_map; use crate::harness::build_metrics_with_defaults; use crate::harness::find_metric; use crate::harness::latest_metrics; +use codex_otel::PLUGIN_INSTALL_ELICITATION_SENT_METRIC; +use codex_otel::PLUGIN_INSTALL_SUGGESTION_METRIC; use codex_otel::Result; use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; @@ -161,3 +163,100 @@ fn manager_attaches_optional_service_name_tag() -> Result<()> { Ok(()) } + +#[test] +fn manager_records_plugin_install_suggestion_metric() -> Result<()> { + let (metrics, exporter) = build_metrics_with_defaults(&[])?; + let manager = SessionTelemetry::new( + ThreadId::new(), + "gpt-5.1", + "gpt-5.1", + Some("account-id".to_string()), + /*account_email*/ None, + Some(TelemetryAuthMode::ApiKey), + "test_originator".to_string(), + /*log_user_prompts*/ false, + "tty".to_string(), + SessionSource::Cli, + ) + .with_metrics_without_metadata_tags(metrics); + + manager.record_plugin_install_suggestion( + "connector", + "connector_calendar", + "Google Calendar", + "accept", + /*user_confirmed*/ true, + /*completed*/ false, + ); + manager.shutdown_metrics()?; + + let resource_metrics = latest_metrics(&exporter); + let metric = find_metric(&resource_metrics, PLUGIN_INSTALL_SUGGESTION_METRIC) + .expect("plugin install suggestion metric missing"); + let attrs = match metric.data() { + AggregatedMetrics::U64(data) => match data { + MetricData::Sum(sum) => { + let points: Vec<_> = sum.data_points().collect(); + assert_eq!(points.len(), 1); + attributes_to_map(points[0].attributes()) + } + _ => panic!("unexpected counter aggregation"), + }, + _ => panic!("unexpected counter data type"), + }; + + assert_eq!( + attrs, + BTreeMap::from([ + ("completed".to_string(), "false".to_string()), + ("response_action".to_string(), "accept".to_string()), + ("tool_type".to_string(), "connector".to_string()), + ]) + ); + + Ok(()) +} + +#[test] +fn manager_records_plugin_install_elicitation_sent_metric() -> Result<()> { + let (metrics, exporter) = build_metrics_with_defaults(&[])?; + let manager = SessionTelemetry::new( + ThreadId::new(), + "gpt-5.1", + "gpt-5.1", + Some("account-id".to_string()), + /*account_email*/ None, + Some(TelemetryAuthMode::ApiKey), + "test_originator".to_string(), + /*log_user_prompts*/ false, + "tty".to_string(), + SessionSource::Cli, + ) + .with_metrics_without_metadata_tags(metrics); + + manager.record_plugin_install_elicitation_sent("plugin", "slack@openai-curated", "Slack"); + manager.shutdown_metrics()?; + + let resource_metrics = latest_metrics(&exporter); + let metric = find_metric(&resource_metrics, PLUGIN_INSTALL_ELICITATION_SENT_METRIC) + .expect("plugin install elicitation sent metric missing"); + let attrs = match metric.data() { + AggregatedMetrics::U64(data) => match data { + MetricData::Sum(sum) => { + let points: Vec<_> = sum.data_points().collect(); + assert_eq!(points.len(), 1); + attributes_to_map(points[0].attributes()) + } + _ => panic!("unexpected counter aggregation"), + }, + _ => panic!("unexpected counter data type"), + }; + + assert_eq!( + attrs, + BTreeMap::from([("tool_type".to_string(), "plugin".to_string())]) + ); + + Ok(()) +} diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 4d972af2cf..7b64776dca 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -71,6 +71,8 @@ pub use tool_discovery::DiscoverablePluginInfo; pub use tool_discovery::DiscoverableTool; pub use tool_discovery::DiscoverableToolAction; pub use tool_discovery::DiscoverableToolType; +pub use tool_discovery::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME; +pub use tool_discovery::ListAvailablePluginsToInstallResult; pub use tool_discovery::REQUEST_PLUGIN_INSTALL_TOOL_NAME; pub use tool_discovery::RequestPluginInstallEntry; pub use tool_discovery::TOOL_SEARCH_DEFAULT_LIMIT; diff --git a/codex-rs/tools/src/tool_discovery.rs b/codex-rs/tools/src/tool_discovery.rs index a5beb768a2..ac5aac904b 100644 --- a/codex-rs/tools/src/tool_discovery.rs +++ b/codex-rs/tools/src/tool_discovery.rs @@ -5,6 +5,7 @@ use serde::Serialize; const TUI_CLIENT_NAME: &str = "codex-tui"; pub const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; pub const TOOL_SEARCH_DEFAULT_LIMIT: usize = 8; +pub const LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME: &str = "list_available_plugins_to_install"; pub const REQUEST_PLUGIN_INSTALL_TOOL_NAME: &str = "request_plugin_install"; #[derive(Clone, Debug, PartialEq, Eq)] @@ -99,7 +100,7 @@ pub struct DiscoverablePluginInfo { pub app_connector_ids: Vec, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] pub struct RequestPluginInstallEntry { pub id: String, pub name: String, @@ -110,6 +111,11 @@ pub struct RequestPluginInstallEntry { pub app_connector_ids: Vec, } +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct ListAvailablePluginsToInstallResult { + pub tools: Vec, +} + pub fn collect_request_plugin_install_entries( discoverable_tools: &[DiscoverableTool], ) -> Vec { From c2141c7ce05d8c07e1aa3ce007eba8d496de8f03 Mon Sep 17 00:00:00 2001 From: efrazer-oai Date: Tue, 19 May 2026 14:57:11 -0700 Subject: [PATCH 06/63] fix: serialize unix app-server startup (#23516) # Summary Unix-socket app-server startup can currently race when multiple launch attempts target the same `CODEX_HOME`. Those processes can overlap before the control socket exists, which lets them enter SQLite state initialization concurrently and reproduce the startup corruption pattern seen in SSH mode. This change makes the app-server own that singleton startup guarantee. Unix-socket startup now takes a `CODEX_HOME`-scoped advisory lock before SQLite initialization, runs the existing control-socket preparation check while holding that lock, returns the established `AddrInUse` error when another live listener already owns the socket, and releases the lock once the new listener has bound its socket. # Design decisions - The singleton rule lives in `app-server --listen unix://`, not in a desktop-only caller path, so every Unix-socket launch gets the same race protection. - A duplicate raw app-server launch returns an error instead of silently succeeding. The attach operation remains `app-server proxy`, which continues to connect to an already-running listener. - The lock is held only across the dangerous startup window: socket preparation, SQLite initialization, and socket bind. It is not held for the app-server lifetime. - Listener detection stays in `prepare_control_socket_path(...)`, so the preexisting live-listener and stale-socket behavior remains the single source of truth. # Testing Tests: targeted Unix-socket transport tests on the branch checkout, full `codex-cli` build on `efrazer-db10`, and an SSH-style smoke on `efrazer-db10` covering concurrent app-server starts, explicit duplicate-start errors, and absence of SQLite startup-error matches in launch logs. --- codex-rs/app-server-transport/src/lib.rs | 4 +++ .../app-server-transport/src/transport/mod.rs | 12 +++++++ .../src/transport/unix_socket.rs | 27 +++++++++++++++- .../src/transport/unix_socket_tests.rs | 32 +++++++++++++++++++ codex-rs/app-server/src/lib.rs | 13 ++++++++ codex-rs/app-server/src/transport.rs | 3 ++ 6 files changed, 90 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server-transport/src/lib.rs b/codex-rs/app-server-transport/src/lib.rs index f4eb1fc6a5..8608102f36 100644 --- a/codex-rs/app-server-transport/src/lib.rs +++ b/codex-rs/app-server-transport/src/lib.rs @@ -6,6 +6,7 @@ pub use outgoing_message::OutgoingError; pub use outgoing_message::OutgoingMessage; pub use outgoing_message::OutgoingResponse; pub use outgoing_message::QueuedOutgoingMessage; +pub use transport::AppServerStartupLock; pub use transport::AppServerTransport; pub use transport::AppServerTransportParseError; pub use transport::CHANNEL_CAPACITY; @@ -14,8 +15,11 @@ pub use transport::RemoteControlHandle; pub use transport::RemoteControlStartConfig; pub use transport::RemoteControlUnavailable; pub use transport::TransportEvent; +pub use transport::acquire_app_server_startup_lock; pub use transport::app_server_control_socket_path; +pub use transport::app_server_startup_lock_path; pub use transport::auth; +pub use transport::prepare_control_socket_path; pub use transport::start_control_socket_acceptor; pub use transport::start_remote_control; pub use transport::start_stdio_connection; diff --git a/codex-rs/app-server-transport/src/transport/mod.rs b/codex-rs/app-server-transport/src/transport/mod.rs index a9e806c128..7452f06938 100644 --- a/codex-rs/app-server-transport/src/transport/mod.rs +++ b/codex-rs/app-server-transport/src/transport/mod.rs @@ -35,6 +35,9 @@ pub use remote_control::RemoteControlStartConfig; pub use remote_control::RemoteControlUnavailable; pub use remote_control::start_remote_control; pub use stdio::start_stdio_connection; +pub use unix_socket::AppServerStartupLock; +pub use unix_socket::acquire_app_server_startup_lock; +pub use unix_socket::prepare_control_socket_path; pub use unix_socket::start_control_socket_acceptor; pub use websocket::start_websocket_acceptor; @@ -42,6 +45,7 @@ const OVERLOADED_ERROR_CODE: i64 = -32001; const APP_SERVER_CONTROL_SOCKET_DIR_NAME: &str = "app-server-control"; const APP_SERVER_CONTROL_SOCKET_FILE_NAME: &str = "app-server-control.sock"; +const APP_SERVER_STARTUP_LOCK_FILE_NAME: &str = "app-server-startup.lock"; pub fn app_server_control_socket_path(codex_home: &Path) -> std::io::Result { AbsolutePathBuf::from_absolute_path( @@ -51,6 +55,14 @@ pub fn app_server_control_socket_path(codex_home: &Path) -> std::io::Result std::io::Result { + AbsolutePathBuf::from_absolute_path( + codex_home + .join(APP_SERVER_CONTROL_SOCKET_DIR_NAME) + .join(APP_SERVER_STARTUP_LOCK_FILE_NAME), + ) +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum AppServerTransport { Stdio, diff --git a/codex-rs/app-server-transport/src/transport/unix_socket.rs b/codex-rs/app-server-transport/src/transport/unix_socket.rs index f75d3fe99a..dd46d88280 100644 --- a/codex-rs/app-server-transport/src/transport/unix_socket.rs +++ b/codex-rs/app-server-transport/src/transport/unix_socket.rs @@ -1,3 +1,4 @@ +use std::fs::OpenOptions; use std::io::ErrorKind; use std::io::Result as IoResult; use std::path::Path; @@ -89,7 +90,7 @@ async fn run_control_socket_acceptor( info!("control socket acceptor shutting down"); } -async fn prepare_control_socket_path(socket_path: &Path) -> IoResult<()> { +pub async fn prepare_control_socket_path(socket_path: &Path) -> IoResult<()> { if let Some(parent) = socket_path.parent() { codex_uds::prepare_private_socket_directory(parent).await?; } @@ -130,6 +131,30 @@ async fn prepare_control_socket_path(socket_path: &Path) -> IoResult<()> { tokio::fs::remove_file(socket_path).await } +pub struct AppServerStartupLock { + _file: std::fs::File, +} + +pub async fn acquire_app_server_startup_lock( + startup_lock_path: AbsolutePathBuf, +) -> IoResult { + if let Some(parent) = startup_lock_path.as_path().parent() { + codex_uds::prepare_private_socket_directory(parent).await?; + } + tokio::task::spawn_blocking(move || { + let file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(startup_lock_path.as_path())?; + file.lock()?; + Ok(AppServerStartupLock { _file: file }) + }) + .await + .map_err(|err| std::io::Error::other(format!("startup lock task failed: {err}")))? +} + #[cfg(unix)] async fn set_control_socket_permissions(socket_path: &Path) -> IoResult<()> { use std::os::unix::fs::PermissionsExt; diff --git a/codex-rs/app-server-transport/src/transport/unix_socket_tests.rs b/codex-rs/app-server-transport/src/transport/unix_socket_tests.rs index 0b7dec0a23..ac0b2b00c4 100644 --- a/codex-rs/app-server-transport/src/transport/unix_socket_tests.rs +++ b/codex-rs/app-server-transport/src/transport/unix_socket_tests.rs @@ -1,6 +1,7 @@ use super::AppServerTransport; use super::CHANNEL_CAPACITY; use super::TransportEvent; +use super::acquire_app_server_startup_lock; use super::app_server_control_socket_path; use super::start_control_socket_acceptor; use codex_app_server_protocol::JSONRPCMessage; @@ -140,6 +141,28 @@ async fn control_socket_acceptor_upgrades_and_forwards_websocket_text_messages_a assert_socket_path_removed(socket_path.as_path()); } +#[tokio::test] +async fn app_server_startup_lock_serializes_waiters() { + let temp_dir = tempfile::TempDir::new().expect("temp dir"); + let lock_path = test_startup_lock_path(temp_dir.path()); + let first_lock = acquire_app_server_startup_lock(lock_path.clone()) + .await + .expect("first startup lock should succeed"); + let mut second_lock = tokio::spawn(acquire_app_server_startup_lock(lock_path)); + + assert!( + timeout(Duration::from_millis(100), &mut second_lock) + .await + .is_err() + ); + + drop(first_lock); + second_lock + .await + .expect("second startup lock task should join") + .expect("second startup lock should succeed"); +} + #[cfg(unix)] #[tokio::test] async fn control_socket_file_is_private_after_bind() { @@ -185,6 +208,15 @@ fn test_socket_path(temp_dir: &Path) -> AbsolutePathBuf { .expect("socket path should resolve") } +fn test_startup_lock_path(temp_dir: &Path) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path( + temp_dir + .join("app-server-control") + .join("app-server-startup.lock"), + ) + .expect("startup lock path should resolve") +} + async fn connect_to_socket(socket_path: &Path) -> IoResult { UnixStream::connect(socket_path).await } diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 7b7cbc11c3..ef4106aa14 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -31,7 +31,10 @@ use crate::transport::ConnectionState; use crate::transport::OutboundConnectionState; use crate::transport::RemoteControlStartConfig; use crate::transport::TransportEvent; +use crate::transport::acquire_app_server_startup_lock; +use crate::transport::app_server_startup_lock_path; use crate::transport::auth::policy_from_settings; +use crate::transport::prepare_control_socket_path; use crate::transport::route_outgoing_envelope; use crate::transport::start_control_socket_acceptor; use crate::transport::start_remote_control; @@ -516,6 +519,15 @@ pub async fn run_main_with_transport_options( })?; codex_core::otel_init::record_process_start(otel.as_ref(), OTEL_SERVICE_NAME); codex_core::otel_init::install_sqlite_telemetry(otel.as_ref(), OTEL_SERVICE_NAME); + let unix_socket_startup_lock = match &transport { + AppServerTransport::UnixSocket { socket_path } => { + let startup_lock_path = app_server_startup_lock_path(&codex_home)?; + let startup_lock = acquire_app_server_startup_lock(startup_lock_path).await?; + prepare_control_socket_path(socket_path.as_path()).await?; + Some(startup_lock) + } + _ => None, + }; let state_db = match rollout_state_db::try_init(&config).await { Ok(state_db) => Some(state_db), Err(err) => { @@ -682,6 +694,7 @@ pub async fn run_main_with_transport_options( } AppServerTransport::Off => {} } + drop(unix_socket_startup_lock); let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index f7222bd28f..2be5b55414 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -22,8 +22,11 @@ pub(crate) use codex_app_server_transport::RemoteControlHandle; pub(crate) use codex_app_server_transport::RemoteControlStartConfig; pub(crate) use codex_app_server_transport::RemoteControlUnavailable; pub(crate) use codex_app_server_transport::TransportEvent; +pub(crate) use codex_app_server_transport::acquire_app_server_startup_lock; pub use codex_app_server_transport::app_server_control_socket_path; +pub(crate) use codex_app_server_transport::app_server_startup_lock_path; pub use codex_app_server_transport::auth; +pub(crate) use codex_app_server_transport::prepare_control_socket_path; pub(crate) use codex_app_server_transport::start_control_socket_acceptor; pub(crate) use codex_app_server_transport::start_remote_control; pub(crate) use codex_app_server_transport::start_stdio_connection; From c53da029bcb8afe07e4019fa13dfd46b37d7cd7d Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 19 May 2026 15:40:41 -0700 Subject: [PATCH 07/63] [codex] Honor role-defined spawn service tiers (#22169) ## Why Custom agent roles are ordinary config layers, so a role file can already express `service_tier` just like other config values. The spawned-agent tier path needs to preserve that effective role config and follow the same precedence pattern as model/reasoning. ## What changed - Apply an explicit spawn-time `service_tier` onto the child config before role application, so a role config layer can override it just like role-defined model/reasoning settings do. - Validate the final effective child tier after the final child model is known, while still falling back to the parent tier when no child tier survives. - Add focused integration coverage for both v1 and v2 proving role TOML loads a service tier, spawned children keep that role-configured tier, and a role tier wins over a conflicting spawn-time tier. ## Validation - `just fmt` - `git diff --check` - Local Rust tests not run, per repo guidance; CI should exercise the new coverage. --- codex-rs/core/src/agent/role.rs | 31 ++++- codex-rs/core/src/agent/role_tests.rs | 86 ++++++++++++ .../src/tools/handlers/multi_agents/spawn.rs | 3 + .../src/tools/handlers/multi_agents_common.rs | 56 ++++---- .../src/tools/handlers/multi_agents_tests.rs | 123 ++++++++++++++++++ .../tools/handlers/multi_agents_v2/spawn.rs | 3 + 6 files changed, 275 insertions(+), 27 deletions(-) diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 2ab16cd22a..a38930c00e 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -73,12 +73,14 @@ async fn apply_role_to_config_inner( } let (preserve_current_profile, preserve_current_provider) = preservation_policy(config, &role_layer_toml); + let preserve_current_service_tier = role_layer_toml.get("service_tier").is_none(); *config = reload::build_next_config( config, role_layer_toml, preserve_current_profile, preserve_current_provider, + preserve_current_service_tier, ) .await?; Ok(()) @@ -157,6 +159,7 @@ mod reload { role_layer_toml: TomlValue, preserve_current_profile: bool, preserve_current_provider: bool, + preserve_current_service_tier: bool, ) -> anyhow::Result { let active_profile_name = preserve_current_profile .then_some(config.active_profile.as_deref()) @@ -171,7 +174,11 @@ mod reload { let mut next_config = Config::load_config_with_layer_stack( LOCAL_FS.as_ref(), merged_config, - reload_overrides(config, preserve_current_provider), + reload_overrides( + config, + preserve_current_provider, + preserve_current_service_tier, + ), config.codex_home.clone(), config_layer_stack, ) @@ -261,10 +268,15 @@ mod reload { ConfigLayerEntry::new(ConfigLayerSource::SessionFlags, role_layer_toml) } - fn reload_overrides(config: &Config, preserve_current_provider: bool) -> ConfigOverrides { + fn reload_overrides( + config: &Config, + preserve_current_provider: bool, + preserve_current_service_tier: bool, + ) -> ConfigOverrides { ConfigOverrides { cwd: Some(config.cwd.to_path_buf()), model_provider: preserve_current_provider.then(|| config.model_provider_id.clone()), + service_tier: preserve_current_service_tier.then(|| config.service_tier.clone()), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), ..Default::default() @@ -323,8 +335,11 @@ pub(crate) mod spawn_tool_spec { let reasoning_effort = role_toml .get("model_reasoning_effort") .and_then(TomlValue::as_str); + let service_tier = role_toml + .get("service_tier") + .and_then(TomlValue::as_str); - match (model, reasoning_effort) { + let model_and_reasoning_note = match (model, reasoning_effort) { (Some(model), Some(reasoning_effort)) => format!( "\n- This role's model is set to `{model}` and its reasoning effort is set to `{reasoning_effort}`. These settings cannot be changed." ), @@ -339,7 +354,15 @@ pub(crate) mod spawn_tool_spec { ) } (None, None) => String::new(), - } + }; + let service_tier_note = service_tier + .map(|service_tier| { + format!( + "\n- This role's service tier is set to `{service_tier}`. If it is supported by the resolved model, it takes precedence over a valid spawn request service tier." + ) + }) + .unwrap_or_default(); + format!("{model_and_reasoning_note}{service_tier_note}") }) .unwrap_or_default(); format!("{name}: {{\n{description}{locked_settings_note}\n}}") diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index 1c99fb5950..828fa5e591 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -6,6 +6,7 @@ use crate::skills_load_input_from_config; use codex_config::ConfigLayerStackOrdering; use codex_core_plugins::PluginsManager; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Verbosity; use codex_protocol::openai_models::ReasoningEffort; use codex_utils_absolute_path::test_support::PathExt; @@ -214,6 +215,66 @@ async fn apply_role_preserves_unspecified_keys() { ); } +#[tokio::test] +async fn apply_role_reports_explicit_service_tier() { + let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + let role_path = write_role_config( + &home, + "tiered-role.toml", + r#"developer_instructions = "Stay focused" +service_tier = "priority" +"#, + ) + .await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + nickname_candidates: None, + }, + ); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + assert_eq!( + config.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); +} + +#[tokio::test] +async fn apply_role_preserves_existing_service_tier_without_override() { + let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); + let role_path = write_role_config( + &home, + "default-tier-role.toml", + r#"developer_instructions = "Stay focused" +"#, + ) + .await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + nickname_candidates: None, + }, + ); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + assert_eq!( + config.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); +} + #[tokio::test] async fn apply_role_preserves_active_profile_and_model_provider() { let home = TempDir::new().expect("create temp dir"); @@ -766,6 +827,31 @@ fn spawn_tool_spec_marks_role_locked_reasoning_effort_only() { )); } +#[test] +fn spawn_tool_spec_marks_role_locked_service_tier() { + let tempdir = TempDir::new().expect("create temp dir"); + let role_path = tempdir.path().join("tiered.toml"); + fs::write( + &role_path, + "developer_instructions = \"Stay fast\"\nservice_tier = \"priority\"\n", + ) + .expect("write role config"); + let user_defined_roles = BTreeMap::from([( + "tiered".to_string(), + AgentRoleConfig { + description: Some("Stay fast.".to_string()), + config_file: Some(role_path), + nickname_candidates: None, + }, + )]); + + let spec = spawn_tool_spec::build(&user_defined_roles); + + assert!(spec.contains( + "Stay fast.\n- This role's service tier is set to `priority`. If it is supported by the resolved model, it takes precedence over a valid spawn request service tier." + )); +} + #[test] fn built_in_config_file_contents_resolves_explorer_only() { assert_eq!( diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index ceb46083c5..dc0518de6b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -83,6 +83,9 @@ async fn handle_spawn_agent( .await; let mut config = build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; + if let Some(service_tier) = args.service_tier.as_ref() { + config.service_tier = Some(service_tier.clone()); + } if args.fork_context { reject_full_fork_spawn_overrides(role_name, args.model.as_deref(), args.reasoning_effort)?; } else { 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 968810494d..4f459a0d0e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -344,9 +344,16 @@ pub(crate) async fn apply_spawn_agent_service_tier( parent_service_tier: Option<&str>, requested_service_tier: Option<&str>, ) -> Result<(), FunctionCallError> { - let Some(candidate_service_tier) = requested_service_tier.or(parent_service_tier) else { + let candidate_service_tiers = [ + config.service_tier.clone(), + requested_service_tier.map(str::to_string), + parent_service_tier.map(str::to_string), + ]; + if candidate_service_tiers.iter().all(Option::is_none) { + config.service_tier = None; return Ok(()); - }; + } + let model = config.model.clone().ok_or_else(|| { FunctionCallError::RespondToModel( "spawn_agent could not resolve the child model for service tier validation".to_string(), @@ -358,29 +365,32 @@ pub(crate) async fn apply_spawn_agent_service_tier( .get_model_info(model.as_str(), &config.to_models_manager_config()) .await; - if model_info.supports_service_tier(candidate_service_tier) { - config.service_tier = Some(candidate_service_tier.to_string()); - return Ok(()); + if let Some(requested_service_tier) = requested_service_tier + && !model_info.supports_service_tier(requested_service_tier) + { + let supported_service_tiers = if model_info.service_tiers.is_empty() { + "none".to_string() + } else { + model_info + .service_tiers + .iter() + .map(|tier| tier.id.as_str()) + .collect::>() + .join(", ") + }; + return Err(FunctionCallError::RespondToModel(format!( + "Service tier `{requested_service_tier}` is not supported for model `{model}`. Supported service tiers: {supported_service_tiers}" + ))); } - if requested_service_tier.is_none() { - config.service_tier = None; - return Ok(()); - } - - let supported_service_tiers = if model_info.service_tiers.is_empty() { - "none".to_string() - } else { - model_info - .service_tiers - .iter() - .map(|tier| tier.id.as_str()) - .collect::>() - .join(", ") - }; - Err(FunctionCallError::RespondToModel(format!( - "Service tier `{candidate_service_tier}` is not supported for model `{model}`. Supported service tiers: {supported_service_tiers}" - ))) + config.service_tier = + candidate_service_tiers + .into_iter() + .flatten() + .find(|candidate_service_tier| { + model_info.supports_service_tier(candidate_service_tier.as_str()) + }); + Ok(()) } fn find_spawn_agent_model_name( 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 ad1702b207..d1e2aff3fb 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -704,6 +704,129 @@ service_tier = "priority" } } +#[tokio::test] +async fn spawn_agent_role_service_tier_falls_back_to_supported_parent_tier() { + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + agent_id: String, + } + + let (mut session, turn) = make_session_and_context().await; + let mut turn = turn + .with_model("gpt-5.4".to_string(), &session.services.models_manager) + .await; + tokio::fs::create_dir_all(&turn.config.codex_home) + .await + .expect("codex home should be created"); + let role_config_path = turn.config.codex_home.as_path().join("tiered-role.toml"); + tokio::fs::write( + &role_config_path, + r#"model = "gpt-5.4" +service_tier = "turbo" +"#, + ) + .await + .expect("role config should be written"); + + let role_name = "tiered-role".to_string(); + let mut config = (*turn.config).clone(); + config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); + config.agent_roles.insert( + role_name.clone(), + AgentRoleConfig { + description: Some("Role with an unsupported child tier".to_string()), + config_file: Some(role_config_path), + nickname_candidates: None, + }, + ); + turn.config = Arc::new(config); + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + + let output = SpawnAgentHandler::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "agent_type": role_name + })), + )) + .await + .expect("spawn_agent should fall back to the supported parent tier"); + let (content, _) = expect_text_output(output); + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + let snapshot = manager + .get_thread(parse_agent_id(&result.agent_id)) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + + assert_eq!( + snapshot.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); +} + +#[tokio::test] +async fn spawn_agent_role_service_tier_does_not_hide_invalid_spawn_request() { + let (session, mut turn) = make_session_and_context().await; + tokio::fs::create_dir_all(&turn.config.codex_home) + .await + .expect("codex home should be created"); + let role_config_path = turn.config.codex_home.as_path().join("tiered-role.toml"); + tokio::fs::write( + &role_config_path, + r#"model = "gpt-5.4" +service_tier = "priority" +"#, + ) + .await + .expect("role config should be written"); + + let role_name = "tiered-role".to_string(); + let mut config = (*turn.config).clone(); + config.agent_roles.insert( + role_name.clone(), + AgentRoleConfig { + description: Some("Role with a supported child tier".to_string()), + config_file: Some(role_config_path), + nickname_candidates: None, + }, + ); + turn.config = Arc::new(config); + + let err = SpawnAgentHandler::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "agent_type": role_name, + "service_tier": "turbo" + })), + )) + .await + .expect_err("invalid spawn service tier should still be rejected"); + + assert_eq!( + err, + FunctionCallError::RespondToModel( + "Service tier `turbo` is not supported for model `gpt-5.4`. Supported service tiers: priority" + .to_string() + ) + ); +} + #[tokio::test] async fn spawn_agent_full_history_fork_accepts_explicit_service_tier() { #[derive(Debug, Deserialize)] 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 2daa1f07db..74a87d8a54 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 @@ -82,6 +82,9 @@ async fn handle_spawn_agent( .await; let mut config = build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; + if let Some(service_tier) = args.service_tier.as_ref() { + config.service_tier = Some(service_tier.clone()); + } if matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory)) { reject_full_fork_spawn_overrides(role_name, args.model.as_deref(), args.reasoning_effort)?; } else { From d86352d520b1719a9183a498f428e905c238b40f Mon Sep 17 00:00:00 2001 From: adams-oai Date: Tue, 19 May 2026 15:41:44 -0700 Subject: [PATCH 08/63] Add CUA requirements subsection for locked computer use (#23555) Adds a new top-level section for "CUA" requirements that can allow for disablement of specific features as needed for enterprises. --- .../codex_app_server_protocol.schemas.json | 21 ++++++++ .../codex_app_server_protocol.v2.schemas.json | 21 ++++++++ .../v2/ConfigRequirementsReadResponse.json | 21 ++++++++ .../typescript/v2/ComputerUseRequirements.ts | 5 ++ .../typescript/v2/ConfigRequirements.ts | 3 +- .../schema/typescript/v2/index.ts | 1 + .../src/protocol/v2/config.rs | 8 +++ .../src/protocol/v2/tests.rs | 1 + codex-rs/app-server/README.md | 2 +- .../request_processors/config_processor.rs | 30 +++++++++++ codex-rs/cloud-requirements/src/lib.rs | 16 ++++++ codex-rs/config/src/config_requirements.rs | 53 +++++++++++++++++++ codex-rs/config/src/lib.rs | 1 + .../core/src/config/config_loader_tests.rs | 3 ++ codex-rs/core/src/config/config_tests.rs | 2 + codex-rs/core/src/config/mod.rs | 1 + codex-rs/tui/src/debug_config.rs | 2 + 17 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ComputerUseRequirements.ts 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 df4514e7c9..b50cacefa4 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 @@ -7089,6 +7089,17 @@ ], "type": "object" }, + "ComputerUseRequirements": { + "properties": { + "allowLockedComputerUse": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "Config": { "additionalProperties": true, "properties": { @@ -7635,6 +7646,16 @@ "null" ] }, + "computerUse": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ComputerUseRequirements" + }, + { + "type": "null" + } + ] + }, "enforceResidency": { "anyOf": [ { 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 1741b612f3..9da530463a 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 @@ -3478,6 +3478,17 @@ ], "type": "object" }, + "ComputerUseRequirements": { + "properties": { + "allowLockedComputerUse": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "Config": { "additionalProperties": true, "properties": { @@ -4024,6 +4035,16 @@ "null" ] }, + "computerUse": { + "anyOf": [ + { + "$ref": "#/definitions/ComputerUseRequirements" + }, + { + "type": "null" + } + ] + }, "enforceResidency": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 577cd7bbc9..3515b5d1d1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -60,6 +60,17 @@ } ] }, + "ComputerUseRequirements": { + "properties": { + "allowLockedComputerUse": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "ConfigRequirements": { "properties": { "allowManagedHooksOnly": { @@ -95,6 +106,16 @@ "null" ] }, + "computerUse": { + "anyOf": [ + { + "$ref": "#/definitions/ComputerUseRequirements" + }, + { + "type": "null" + } + ] + }, "enforceResidency": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ComputerUseRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ComputerUseRequirements.ts new file mode 100644 index 0000000000..7a82e7aec8 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ComputerUseRequirements.ts @@ -0,0 +1,5 @@ +// 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. + +export type ComputerUseRequirements = { allowLockedComputerUse: boolean | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts index 1b0625e5e1..5d2755d5ac 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -3,7 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { WebSearchMode } from "../WebSearchMode"; import type { AskForApproval } from "./AskForApproval"; +import type { ComputerUseRequirements } from "./ComputerUseRequirements"; import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; -export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; +export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index dc400a6f8e..7c09be0def 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -62,6 +62,7 @@ export type { CommandExecutionRequestApprovalResponse } from "./CommandExecution export type { CommandExecutionSource } from "./CommandExecutionSource"; export type { CommandExecutionStatus } from "./CommandExecutionStatus"; export type { CommandMigration } from "./CommandMigration"; +export type { ComputerUseRequirements } from "./ComputerUseRequirements"; export type { Config } from "./Config"; export type { ConfigBatchWriteParams } from "./ConfigBatchWriteParams"; export type { ConfigEdit } from "./ConfigEdit"; 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 0b7741c656..25d75b56b4 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -388,6 +388,7 @@ pub struct ConfigRequirements { pub allowed_sandbox_modes: Option>, pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, + pub computer_use: Option, pub feature_requirements: Option>, #[experimental("configRequirements/read.hooks")] pub hooks: Option, @@ -396,6 +397,13 @@ pub struct ConfigRequirements { pub network: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ComputerUseRequirements { + pub allow_locked_computer_use: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] 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 8c3ca5e6c6..2ee583f991 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1730,6 +1730,7 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() allowed_sandbox_modes: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, feature_requirements: None, hooks: None, enforce_residency: None, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 9073282b4e..1b7575141f 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -223,7 +223,7 @@ Example with notification opt-out: - `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. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish). - `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`), lifecycle hook lockdown (`allowManagedHooksOnly`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. +- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), lifecycle hook lockdown (`allowManagedHooksOnly`), 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`. ### Example: Start or resume a thread 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 486970f929..860385a1ff 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -9,6 +9,7 @@ use crate::outgoing_message::OutgoingMessageSender; use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::AppListUpdatedNotification; use codex_app_server_protocol::ClientResponsePayload; +use codex_app_server_protocol::ComputerUseRequirements; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigReadResponse; @@ -429,6 +430,9 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR normalized }), allow_managed_hooks_only: requirements.allow_managed_hooks_only, + computer_use: requirements + .computer_use + .map(map_computer_use_requirements_to_api), feature_requirements: requirements .feature_requirements .map(|requirements| requirements.entries), @@ -440,6 +444,14 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR } } +fn map_computer_use_requirements_to_api( + computer_use: codex_config::ComputerUseRequirementsToml, +) -> ComputerUseRequirements { + ComputerUseRequirements { + allow_locked_computer_use: computer_use.allow_locked_computer_use, + } +} + fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> ManagedHooksRequirements { let ManagedHooksRequirementsToml { managed_dir, @@ -617,6 +629,7 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> #[cfg(test)] mod tests { use super::map_requirements_toml_to_api; + use codex_config::ComputerUseRequirementsToml; use codex_config::ConfigRequirementsToml; use pretty_assertions::assert_eq; @@ -630,4 +643,21 @@ mod tests { assert_eq!(mapped.allow_managed_hooks_only, Some(true)); assert_eq!(mapped.hooks, None); } + + #[test] + fn requirements_api_includes_computer_use_requirements() { + let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + computer_use: Some(ComputerUseRequirementsToml { + allow_locked_computer_use: Some(false), + }), + ..ConfigRequirementsToml::default() + }); + + assert_eq!( + mapped + .computer_use + .and_then(|requirements| requirements.allow_locked_computer_use), + Some(false) + ); + } } diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 8812762cbe..885e907ef8 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -1205,6 +1205,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1288,6 +1289,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1322,6 +1324,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1373,6 +1376,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1524,6 +1528,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1605,6 +1610,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1684,6 +1690,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1891,6 +1898,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1932,6 +1940,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1993,6 +2002,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2050,6 +2060,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2109,6 +2120,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2169,6 +2181,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2229,6 +2242,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2319,6 +2333,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2351,6 +2366,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, guardian_policy_config: None, feature_requirements: None, hooks: None, diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 4e4336a2b5..8e2e21f0c4 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -88,6 +88,7 @@ pub struct ConfigRequirements { pub permission_profile: ConstrainedWithSource, pub web_search_mode: ConstrainedWithSource, pub allow_managed_hooks_only: Option>, + pub computer_use: Option>, pub feature_requirements: Option>, pub managed_hooks: Option>, pub mcp_servers: Option>>, @@ -122,6 +123,7 @@ impl Default for ConfigRequirements { /*source*/ None, ), allow_managed_hooks_only: None, + computer_use: None, feature_requirements: None, managed_hooks: None, mcp_servers: None, @@ -588,6 +590,17 @@ impl fmt::Display for WebSearchModeRequirement { } } +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct ComputerUseRequirementsToml { + pub allow_locked_computer_use: Option, +} + +impl ComputerUseRequirementsToml { + pub fn is_empty(&self) -> bool { + self.allow_locked_computer_use.is_none() + } +} + #[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub struct FeatureRequirementsToml { #[serde(flatten)] @@ -691,6 +704,7 @@ pub struct ConfigRequirementsToml { pub remote_sandbox_config: Option>, pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, + pub computer_use: Option, #[serde(rename = "features", alias = "feature_requirements")] pub feature_requirements: Option, pub hooks: Option, @@ -740,6 +754,7 @@ pub struct ConfigRequirementsWithSources { pub allowed_sandbox_modes: Option>>, pub allowed_web_search_modes: Option>>, pub allow_managed_hooks_only: Option>, + pub computer_use: Option>, pub feature_requirements: Option>, pub hooks: Option>, pub mcp_servers: Option>>, @@ -777,6 +792,7 @@ impl ConfigRequirementsWithSources { remote_sandbox_config: _, allowed_web_search_modes: _, allow_managed_hooks_only: _, + computer_use: _, feature_requirements: _, hooks: _, mcp_servers: _, @@ -807,6 +823,7 @@ impl ConfigRequirementsWithSources { allowed_sandbox_modes, allowed_web_search_modes, allow_managed_hooks_only, + computer_use, feature_requirements, hooks, mcp_servers, @@ -835,6 +852,7 @@ impl ConfigRequirementsWithSources { allowed_sandbox_modes, allowed_web_search_modes, allow_managed_hooks_only, + computer_use, feature_requirements, hooks, mcp_servers, @@ -853,6 +871,7 @@ impl ConfigRequirementsWithSources { remote_sandbox_config: None, allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value), allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value), + computer_use: computer_use.map(|sourced| sourced.value), feature_requirements: feature_requirements.map(|sourced| sourced.value), hooks: hooks.map(|sourced| sourced.value), mcp_servers: mcp_servers.map(|sourced| sourced.value), @@ -937,6 +956,10 @@ impl ConfigRequirementsToml { && self.remote_sandbox_config.is_none() && self.allowed_web_search_modes.is_none() && self.allow_managed_hooks_only.is_none() + && self + .computer_use + .as_ref() + .is_none_or(ComputerUseRequirementsToml::is_empty) && self .feature_requirements .as_ref() @@ -975,6 +998,7 @@ impl TryFrom for ConfigRequirements { allowed_sandbox_modes, allowed_web_search_modes, allow_managed_hooks_only, + computer_use, feature_requirements, hooks, mcp_servers, @@ -1211,6 +1235,7 @@ impl TryFrom for ConfigRequirements { permission_profile, web_search_mode, allow_managed_hooks_only, + computer_use, feature_requirements, managed_hooks, mcp_servers, @@ -1279,6 +1304,7 @@ mod tests { remote_sandbox_config: _, allowed_web_search_modes, allow_managed_hooks_only, + computer_use, feature_requirements, hooks, mcp_servers, @@ -1301,6 +1327,7 @@ mod tests { .map(|value| Sourced::new(value, RequirementSource::Unknown)), allow_managed_hooks_only: allow_managed_hooks_only .map(|value| Sourced::new(value, RequirementSource::Unknown)), + computer_use: computer_use.map(|value| Sourced::new(value, RequirementSource::Unknown)), feature_requirements: feature_requirements .map(|value| Sourced::new(value, RequirementSource::Unknown)), hooks: hooks.map(|value| Sourced::new(value, RequirementSource::Unknown)), @@ -1343,6 +1370,25 @@ mod tests { Ok(()) } + #[test] + fn deserialize_computer_use_requirements() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + [computer_use] + allow_locked_computer_use = false + "#, + )?; + + assert_eq!( + requirements.computer_use, + Some(ComputerUseRequirementsToml { + allow_locked_computer_use: Some(false), + }) + ); + assert!(!requirements.is_empty()); + Ok(()) + } + #[test] fn merge_unset_fields_copies_every_field_and_sets_sources() { let mut target = ConfigRequirementsWithSources::default(); @@ -1362,6 +1408,9 @@ mod tests { let feature_requirements = FeatureRequirementsToml { entries: BTreeMap::from([("personality".to_string(), true)]), }; + let computer_use = ComputerUseRequirementsToml { + allow_locked_computer_use: Some(false), + }; let enforce_residency = ResidencyRequirement::Us; let enforce_source = source.clone(); let guardian_policy_config = "Use the company-managed guardian policy.".to_string(); @@ -1375,6 +1424,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: Some(allowed_web_search_modes.clone()), allow_managed_hooks_only: Some(true), + computer_use: Some(computer_use.clone()), feature_requirements: Some(feature_requirements.clone()), hooks: None, mcp_servers: None, @@ -1409,6 +1459,7 @@ mod tests { /*value*/ true, enforce_source.clone(), )), + computer_use: Some(Sourced::new(computer_use, enforce_source.clone())), feature_requirements: Some(Sourced::new( feature_requirements, enforce_source.clone(), @@ -1452,6 +1503,7 @@ mod tests { allowed_sandbox_modes: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -1500,6 +1552,7 @@ mod tests { allowed_sandbox_modes: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, feature_requirements: None, hooks: None, mcp_servers: None, diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 3b49db638f..32065e3682 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -39,6 +39,7 @@ pub use config_requirements::AppRequirementToml; pub use config_requirements::AppToolRequirementToml; pub use config_requirements::AppToolsRequirementsToml; pub use config_requirements::AppsRequirementsToml; +pub use config_requirements::ComputerUseRequirementsToml; pub use config_requirements::ConfigRequirements; pub use config_requirements::ConfigRequirementsToml; pub use config_requirements::ConfigRequirementsWithSources; diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index cd84f06cda..760b1f3fc5 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -1098,6 +1098,7 @@ allowed_approval_policies = ["on-request"] remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -1156,6 +1157,7 @@ allowed_approval_policies = ["on-request"] remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -1363,6 +1365,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, feature_requirements: None, hooks: None, mcp_servers: None, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 2297314e06..dac1bf317e 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -8636,6 +8636,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() remote_sandbox_config: None, allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]), allow_managed_hooks_only: None, + computer_use: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -9419,6 +9420,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + computer_use: None, feature_requirements: None, hooks: None, mcp_servers: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 336d54b382..93b67deb1a 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2426,6 +2426,7 @@ impl Config { permission_profile: mut constrained_permission_profile, web_search_mode: mut constrained_web_search_mode, allow_managed_hooks_only: _, + computer_use: _, feature_requirements, managed_hooks: _, mcp_servers, diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 7eb4539f1d..2075636e3d 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -700,6 +700,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: Some(vec![WebSearchModeRequirement::Cached]), allow_managed_hooks_only: Some(true), + computer_use: None, guardian_policy_config: Some("Use the managed guardian policy.".to_string()), feature_requirements: Some(FeatureRequirementsToml { entries: BTreeMap::from([("guardian_approval".to_string(), true)]), @@ -914,6 +915,7 @@ approval_policy = "never" remote_sandbox_config: None, allowed_web_search_modes: Some(Vec::new()), allow_managed_hooks_only: None, + computer_use: None, guardian_policy_config: None, feature_requirements: None, hooks: None, From 27c4c67b15935ba76234f64aa9b0c8252a6207c1 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 19 May 2026 15:48:40 -0700 Subject: [PATCH 09/63] Fix: TUI starting in wrong CWD (#23538) This fixes a regression wher codex could start in the wrong directory when a live local app-server socket was present. The issue was that implicit local socket reuse was being treated like an explicit remote workspace session, which dropped the invoking cwd unless --cd was passed. The change separates local socket transport from true remote workspace semantics. - Plain local startup keeps local cwd, trust, resume, picker, and config-refresh behavior. - Explicit --remote keeps the existing remote cwd behavior. - Added coverage for launch target selection and local-session filtering/cwd behavior. Steps to test: - Start a local app-server from a different directory than the repo you want to use. - Launch codex from a project/worktree without --cd. - Confirm the session starts in the invoking directory, not the app-server process directory. - Confirm explicit codex --remote ... still preserves existing remote behavior. --- codex-rs/tui/src/app.rs | 8 +- codex-rs/tui/src/app/event_dispatch.rs | 9 +- codex-rs/tui/src/app/session_lifecycle.rs | 2 +- codex-rs/tui/src/app/test_support.rs | 2 +- codex-rs/tui/src/app/tests.rs | 4 +- codex-rs/tui/src/app_server_session.rs | 17 +- codex-rs/tui/src/lib.rs | 352 ++++++++++++++++++---- codex-rs/tui/src/resume_picker.rs | 40 ++- 8 files changed, 343 insertions(+), 91 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 5cfb520dbe..3164f16acb 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3,6 +3,7 @@ //! This module owns the `App` struct, shared imports, and the high-level run loop that coordinates //! the focused app submodules. +use crate::AppServerTarget; use crate::app_backtrack::BacktrackState; use crate::app_command::AppCommand; use crate::app_event::AppEvent; @@ -82,7 +83,6 @@ use crate::workspace_command::AppServerWorkspaceCommandRunner; use crate::workspace_command::WorkspaceCommandRunner; use codex_ansi_escape::ansi_escape_line; use codex_app_server_client::AppServerRequestHandle; -use codex_app_server_client::RemoteAppServerEndpoint; use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::AskForApproval; @@ -516,7 +516,7 @@ pub(crate) struct App { pub(crate) feedback: codex_feedback::CodexFeedback, feedback_audience: FeedbackAudience, environment_manager: Arc, - remote_app_server_endpoint: Option, + app_server_target: AppServerTarget, /// Set when the user confirms an update; propagated on exit. pub(crate) pending_update_action: Option, @@ -654,7 +654,7 @@ impl App { is_first_run: bool, entered_trust_nux: bool, should_prompt_windows_sandbox_nux_at_startup: bool, - remote_app_server_endpoint: Option, + app_server_target: AppServerTarget, state_db: Option, environment_manager: Arc, startup_hooks_browser: Option, @@ -941,7 +941,7 @@ See the Codex keymap documentation for supported actions and examples." feedback: feedback.clone(), feedback_audience, environment_manager, - remote_app_server_endpoint, + app_server_target, pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 987c9490d7..68bd759e2f 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -57,10 +57,7 @@ impl App { AppEvent::OpenResumePicker => { let picker_app_server = match crate::start_app_server_for_picker( &self.config, - &match self.remote_app_server_endpoint.clone() { - Some(endpoint) => crate::AppServerTarget::Remote { endpoint }, - None => crate::AppServerTarget::Embedded, - }, + &self.app_server_target, self.state_db.clone(), self.environment_manager.clone(), ) @@ -1680,7 +1677,7 @@ impl App { { Ok(()) => { self.chat_widget.update_skill_enabled(path, enabled); - if !app_server.is_remote() + if !app_server.uses_remote_workspace() && let Err(err) = self.refresh_in_memory_config_from_disk().await { tracing::warn!( @@ -1724,7 +1721,7 @@ impl App { { Ok(_) => { self.chat_widget.update_connector_enabled(&id, enabled); - if !app_server.is_remote() + if !app_server.uses_remote_workspace() && let Err(err) = self.refresh_in_memory_config_from_disk().await { tracing::warn!(error = %err, "failed to refresh config after app toggle"); diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 1a57b596b0..6ea53d8b3c 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -633,7 +633,7 @@ impl App { } let current_cwd = self.config.cwd.to_path_buf(); - let resume_cwd = if self.remote_app_server_endpoint.is_some() { + let resume_cwd = if self.app_server_target.uses_remote_workspace() { current_cwd.clone() } else { match crate::session_resume::resolve_cwd_for_resume_or_fork( diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index a22107c226..6b6c424210 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -45,7 +45,7 @@ pub(super) async fn make_test_app() -> App { feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), - remote_app_server_endpoint: None, + app_server_target: crate::AppServerTarget::Embedded, pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 547f70890c..bddf5e544c 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3867,7 +3867,7 @@ async fn make_test_app() -> App { feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), - remote_app_server_endpoint: None, + app_server_target: crate::AppServerTarget::Embedded, pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), @@ -3930,7 +3930,7 @@ async fn make_test_app_with_channels() -> ( feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), - remote_app_server_endpoint: None, + app_server_target: crate::AppServerTarget::Embedded, pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 5ea7bccf97..1bcb4a5597 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -149,10 +149,11 @@ pub(crate) struct AppServerSession { client: AppServerClient, next_request_id: i64, remote_cwd_override: Option, + thread_params_mode: ThreadParamsMode, } -#[derive(Clone, Copy)] -enum ThreadParamsMode { +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ThreadParamsMode { Embedded, Remote, } @@ -182,11 +183,12 @@ pub(crate) enum TurnPermissionsOverride { } impl AppServerSession { - pub(crate) fn new(client: AppServerClient) -> Self { + pub(crate) fn new(client: AppServerClient, thread_params_mode: ThreadParamsMode) -> Self { Self { client, next_request_id: 1, remote_cwd_override: None, + thread_params_mode, } } @@ -199,8 +201,8 @@ impl AppServerSession { self.remote_cwd_override.as_deref() } - pub(crate) fn is_remote(&self) -> bool { - matches!(self.client, AppServerClient::Remote(_)) + pub(crate) fn uses_remote_workspace(&self) -> bool { + matches!(self.thread_params_mode, ThreadParamsMode::Remote) } pub(crate) async fn bootstrap(&mut self, config: &Config) -> Result { @@ -426,10 +428,7 @@ impl AppServerSession { } fn thread_params_mode(&self) -> ThreadParamsMode { - match &self.client { - AppServerClient::InProcess(_) => ThreadParamsMode::Embedded, - AppServerClient::Remote(_) => ThreadParamsMode::Remote, - } + self.thread_params_mode } async fn fork_parent_title_from_app_server( diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 4281ad2923..40cd121c71 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -20,6 +20,7 @@ use app::App; pub use app::AppExitInfo; pub use app::ExitReason; use app_server_session::AppServerSession; +use app_server_session::ThreadParamsMode; use codex_app_server_client::AppServerClient; use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; use codex_app_server_client::InProcessAppServerClient; @@ -312,9 +313,24 @@ async fn start_embedded_app_server( #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum AppServerTarget { Embedded, + LocalDaemon { endpoint: RemoteAppServerEndpoint }, Remote { endpoint: RemoteAppServerEndpoint }, } +impl AppServerTarget { + pub(crate) fn uses_remote_workspace(&self) -> bool { + matches!(self, Self::Remote { .. }) + } + + fn thread_params_mode(&self) -> ThreadParamsMode { + if self.uses_remote_workspace() { + ThreadParamsMode::Remote + } else { + ThreadParamsMode::Embedded + } + } +} + async fn init_state_db_for_app_server_target( config: &Config, app_server_target: &AppServerTarget, @@ -326,7 +342,9 @@ async fn init_state_db_for_app_server_target( err.to_string(), )) }), - AppServerTarget::Remote { .. } => Ok(state_db::get_state_db(config).await), + AppServerTarget::LocalDaemon { .. } | AppServerTarget::Remote { .. } => { + Ok(state_db::get_state_db(config).await) + } } } @@ -496,7 +514,9 @@ async fn start_app_server( ) .await .map(AppServerClient::InProcess), - AppServerTarget::Remote { endpoint } => connect_remote_app_server(endpoint.clone()).await, + AppServerTarget::LocalDaemon { endpoint } | AppServerTarget::Remote { endpoint } => { + connect_remote_app_server(endpoint.clone()).await + } } } @@ -520,7 +540,10 @@ pub(crate) async fn start_app_server_for_picker( environment_manager, ) .await?; - Ok(AppServerSession::new(app_server)) + Ok(AppServerSession::new( + app_server, + target.thread_params_mode(), + )) } #[cfg(test)] @@ -695,7 +718,7 @@ async fn lookup_latest_session_target_with_app_server( ) -> color_eyre::Result> { let response = app_server .thread_list(latest_session_lookup_params( - app_server.is_remote(), + app_server.uses_remote_workspace(), config, cwd_filter, include_non_interactive, @@ -708,7 +731,7 @@ async fn lookup_latest_session_target_with_app_server( } fn latest_session_lookup_params( - is_remote: bool, + uses_remote_workspace: bool, config: &Config, cwd_filter: Option<&Path>, include_non_interactive: bool, @@ -718,7 +741,7 @@ fn latest_session_lookup_params( limit: Some(1), sort_key: Some(AppServerThreadSortKey::UpdatedAt), sort_direction: None, - model_providers: if is_remote { + model_providers: if uses_remote_workspace { None } else { Some(vec![config.model_provider_id.clone()]) @@ -737,7 +760,7 @@ fn config_cwd_for_app_server_target( app_server_target: &AppServerTarget, environment_manager: &EnvironmentManager, ) -> std::io::Result> { - if matches!(app_server_target, AppServerTarget::Remote { .. }) + if app_server_target.uses_remote_workspace() || environment_manager .default_environment() .is_some_and(|environment| environment.is_remote()) @@ -758,12 +781,11 @@ fn should_load_configured_environments( loader_overrides: &LoaderOverrides, app_server_target: &AppServerTarget, ) -> bool { - !loader_overrides.ignore_user_config - && !matches!(app_server_target, AppServerTarget::Remote { .. }) + !loader_overrides.ignore_user_config && !app_server_target.uses_remote_workspace() } fn latest_session_cwd_filter<'a>( - remote_mode: bool, + uses_remote_workspace: bool, remote_cwd_override: Option<&'a Path>, config: &'a Config, show_all: bool, @@ -772,13 +794,62 @@ fn latest_session_cwd_filter<'a>( return None; } - if remote_mode { + if uses_remote_workspace { remote_cwd_override } else { Some(config.cwd.as_path()) } } +fn app_server_target_for_launch( + explicit_remote_endpoint: Option, + default_daemon_socket: Option, + can_reuse_implicit_local_daemon: bool, +) -> AppServerTarget { + match explicit_remote_endpoint { + Some(endpoint) => AppServerTarget::Remote { endpoint }, + None if can_reuse_implicit_local_daemon => { + default_daemon_socket.map_or(AppServerTarget::Embedded, |socket_path| { + AppServerTarget::LocalDaemon { + endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path }, + } + }) + } + None => AppServerTarget::Embedded, + } +} + +fn loader_overrides_are_default(loader_overrides: &LoaderOverrides) -> bool { + let loader_overrides_are_default = loader_overrides.user_config_path.is_none() + && loader_overrides.user_config_profile.is_none() + && loader_overrides.managed_config_path.is_none() + && loader_overrides.system_config_path.is_none() + && loader_overrides.system_requirements_path.is_none() + && !loader_overrides.ignore_managed_requirements + && !loader_overrides.ignore_user_config + && !loader_overrides.ignore_user_and_project_exec_policy_rules + && loader_overrides + .macos_managed_config_requirements_base64 + .is_none(); + #[cfg(target_os = "macos")] + let loader_overrides_are_default = + loader_overrides_are_default && loader_overrides.managed_preferences_base64.is_none(); + loader_overrides_are_default +} + +fn can_reuse_implicit_local_daemon( + cli_kv_overrides: &[(String, toml::Value)], + loader_overrides: &LoaderOverrides, + strict_config: bool, + has_non_replayable_launch_overrides: bool, +) -> bool { + // A reused daemon cannot adopt this invocation's full launch config state. + cli_kv_overrides.is_empty() + && loader_overrides_are_default(loader_overrides) + && !strict_config + && !has_non_replayable_launch_overrides +} + pub async fn run_main( mut cli: Cli, arg0_paths: Arg0DispatchPaths, @@ -830,21 +901,32 @@ pub async fn run_main( } }; - let remote_endpoint = match explicit_remote_endpoint { - Some(endpoint) => Some(endpoint), - None => maybe_probe_default_daemon_socket(&codex_home) - .await - .map(|socket_path| RemoteAppServerEndpoint::UnixSocket { socket_path }), + let mut launch_loader_overrides = loader_overrides.clone(); + if let Some(profile_v2) = cli.config_profile_v2.as_ref() { + let user_config_path = resolve_profile_v2_config_path(&codex_home, profile_v2); + launch_loader_overrides.user_config_path = Some(user_config_path); + launch_loader_overrides.user_config_profile = Some(profile_v2.clone()); + } + let reuse_implicit_local_daemon = can_reuse_implicit_local_daemon( + &cli_kv_overrides, + &launch_loader_overrides, + strict_config, + cli.bypass_hook_trust, + ); + let default_daemon = if explicit_remote_endpoint.is_none() && reuse_implicit_local_daemon { + maybe_probe_default_daemon_socket(&codex_home).await + } else { + None }; - let app_server_target = remote_endpoint - .clone() - .map_or(AppServerTarget::Embedded, |endpoint| { - AppServerTarget::Remote { endpoint } - }); + let app_server_target = app_server_target_for_launch( + explicit_remote_endpoint, + default_daemon, + reuse_implicit_local_daemon, + ); let remote_cwd_override = cli .cwd .clone() - .filter(|_| matches!(app_server_target, AppServerTarget::Remote { .. })); + .filter(|_| app_server_target.uses_remote_workspace()); let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths( arg0_paths.codex_self_exe.clone(), @@ -952,7 +1034,7 @@ pub async fn run_main( model, approval_policy, sandbox_mode, - cwd: if matches!(app_server_target, AppServerTarget::Remote { .. }) { + cwd: if app_server_target.uses_remote_workspace() { None } else { cwd @@ -1072,7 +1154,7 @@ pub async fn run_main( } } - if matches!(app_server_target, AppServerTarget::Embedded) { + if !app_server_target.uses_remote_workspace() { #[allow(clippy::print_stderr)] if let Err(err) = enforce_login_restrictions(&AuthConfig { codex_home: config.codex_home.to_path_buf(), @@ -1180,7 +1262,6 @@ pub async fn run_main( feedback, log_db, state_db, - remote_endpoint, environment_manager, ) .await @@ -1202,10 +1283,9 @@ async fn run_ratatui_app( feedback: codex_feedback::CodexFeedback, log_db: Option, state_db: Option, - remote_endpoint: Option, environment_manager: Arc, ) -> color_eyre::Result { - let remote_mode = matches!(&app_server_target, AppServerTarget::Remote { .. }); + let uses_remote_workspace = app_server_target.uses_remote_workspace(); color_eyre::install()?; tooltips::announcement::prewarm(); @@ -1268,7 +1348,7 @@ async fn run_ratatui_app( ) .await { - Ok(app_server) => AppServerSession::new(app_server), + Ok(app_server) => AppServerSession::new(app_server, app_server_target.thread_params_mode()), Err(err) => { terminal_restore_guard.restore_silently(); session_log::log_session_end(); @@ -1278,7 +1358,8 @@ async fn run_ratatui_app( .with_remote_cwd_override(remote_cwd_override.clone()); let mut app_server = Some(app_server_session); - let should_show_trust_screen_flag = !remote_mode && should_show_trust_screen(&initial_config); + let should_show_trust_screen_flag = + !uses_remote_workspace && should_show_trust_screen(&initial_config); let mut trust_decision_was_made = false; let login_status = if initial_config.model_provider.requires_openai_auth { let Some(app_server) = app_server.as_mut() else { @@ -1328,7 +1409,7 @@ async fn run_ratatui_app( // If this onboarding run included the login step, always refresh cloud requirements and // rebuild config. This avoids missing newly available cloud requirements due to login // status detection edge cases. - if show_login_screen && !remote_mode { + if show_login_screen && !uses_remote_workspace { cloud_requirements = cloud_requirements_loader_for_storage( initial_config.codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, @@ -1341,7 +1422,7 @@ async fn run_ratatui_app( // If the user made an explicit trust decision, or we showed the login flow, reload config // so current process state reflects persisted trust/auth changes. if onboarding_result.directory_trust_decision.is_some() - || (show_login_screen && !remote_mode) + || (show_login_screen && !uses_remote_workspace) { load_config_or_exit( cli_kv_overrides.clone(), @@ -1389,7 +1470,7 @@ async fn run_ratatui_app( } } else if cli.fork_last { let filter_cwd = latest_session_cwd_filter( - remote_mode, + uses_remote_workspace, remote_cwd_override.as_deref(), &config, cli.fork_show_all, @@ -1446,7 +1527,7 @@ async fn run_ratatui_app( } } else if cli.resume_last { let filter_cwd = latest_session_cwd_filter( - remote_mode, + uses_remote_workspace, remote_cwd_override.as_deref(), &config, cli.resume_show_all, @@ -1496,7 +1577,7 @@ async fn run_ratatui_app( }; let current_cwd = config.cwd.clone(); - let allow_prompt = !remote_mode && cli.cwd.is_none(); + let allow_prompt = !uses_remote_workspace && cli.cwd.is_none(); let action_and_target_session_if_resume_or_fork = match &session_selection { resume_picker::SessionSelection::Resume(target_session) => { Some((CwdPromptAction::Resume, target_session)) @@ -1508,7 +1589,7 @@ async fn run_ratatui_app( }; let fallback_cwd = match action_and_target_session_if_resume_or_fork { Some((action, target_session)) => { - if remote_mode { + if uses_remote_workspace { Some(current_cwd.to_path_buf()) } else { match resolve_cwd_for_resume_or_fork( @@ -1614,8 +1695,10 @@ async fn run_ratatui_app( ) .await { - Ok(app_server) => AppServerSession::new(app_server) - .with_remote_cwd_override(remote_cwd_override.clone()), + Ok(app_server) => { + AppServerSession::new(app_server, app_server_target.thread_params_mode()) + .with_remote_cwd_override(remote_cwd_override.clone()) + } Err(err) => { terminal_restore_guard.restore_silently(); session_log::log_session_end(); @@ -1645,7 +1728,7 @@ async fn run_ratatui_app( should_show_trust_screen, // Proxy to: is it a first run in this directory? should_show_trust_screen_flag, // Preserve the startup-time trust NUX signal before onboarding should_prompt_windows_sandbox_nux_at_startup, - remote_endpoint, + app_server_target, state_db, environment_manager, startup_hooks_browser, @@ -1989,6 +2072,117 @@ mod tests { Ok(()) } + #[test] + fn app_server_target_for_launch_uses_local_daemon_for_default_socket() -> color_eyre::Result<()> + { + let socket_path = AbsolutePathBuf::relative_to_current_dir("codex.sock")?; + let target = app_server_target_for_launch( + /*explicit_remote_endpoint*/ None, + Some(socket_path.clone()), + /*can_reuse_implicit_local_daemon*/ true, + ); + + assert_eq!( + target, + AppServerTarget::LocalDaemon { + endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path }, + } + ); + assert!(!target.uses_remote_workspace()); + assert_eq!(target.thread_params_mode(), ThreadParamsMode::Embedded); + Ok(()) + } + + #[test] + fn app_server_target_for_launch_prefers_explicit_remote_endpoint() -> color_eyre::Result<()> { + let explicit_endpoint = RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("explicit.sock")?, + }; + let target = app_server_target_for_launch( + Some(explicit_endpoint.clone()), + Some(AbsolutePathBuf::relative_to_current_dir("default.sock")?), + /*can_reuse_implicit_local_daemon*/ false, + ); + + assert_eq!( + target, + AppServerTarget::Remote { + endpoint: explicit_endpoint, + } + ); + assert!(target.uses_remote_workspace()); + assert_eq!(target.thread_params_mode(), ThreadParamsMode::Remote); + Ok(()) + } + + #[test] + fn app_server_target_for_launch_skips_local_daemon_when_launch_config_is_not_replayable() + -> color_eyre::Result<()> { + let socket_path = AbsolutePathBuf::relative_to_current_dir("codex.sock")?; + let target = app_server_target_for_launch( + /*explicit_remote_endpoint*/ None, + Some(socket_path), + /*can_reuse_implicit_local_daemon*/ false, + ); + + assert_eq!(target, AppServerTarget::Embedded); + Ok(()) + } + + #[test] + fn can_reuse_implicit_local_daemon_requires_default_launch_config() -> color_eyre::Result<()> { + let mut loader_overrides = LoaderOverrides::default(); + let cli_kv_overrides = vec![("web_search".to_string(), toml::Value::String("live".into()))]; + + assert!(can_reuse_implicit_local_daemon( + &[], + &LoaderOverrides::default(), + /*strict_config*/ false, + /*has_non_replayable_launch_overrides*/ false, + )); + assert!(!can_reuse_implicit_local_daemon( + &cli_kv_overrides, + &LoaderOverrides::default(), + /*strict_config*/ false, + /*has_non_replayable_launch_overrides*/ false, + )); + loader_overrides.ignore_user_config = true; + assert!(!can_reuse_implicit_local_daemon( + &[], + &loader_overrides, + /*strict_config*/ false, + /*has_non_replayable_launch_overrides*/ false, + )); + assert!(!can_reuse_implicit_local_daemon( + &[], + &LoaderOverrides::default(), + /*strict_config*/ true, + /*has_non_replayable_launch_overrides*/ false, + )); + assert!(!can_reuse_implicit_local_daemon( + &[], + &LoaderOverrides::default(), + /*strict_config*/ false, + /*has_non_replayable_launch_overrides*/ true, + )); + Ok(()) + } + + #[test] + fn should_load_configured_environments_for_local_daemon() -> color_eyre::Result<()> { + let target = AppServerTarget::LocalDaemon { + endpoint: RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, + }, + }; + + assert!(should_load_configured_environments( + &LoaderOverrides::default(), + &target, + )); + Ok(()) + } + #[tokio::test] async fn latest_session_lookup_params_keep_local_filters_for_embedded_sessions() -> std::io::Result<()> { @@ -1997,7 +2191,34 @@ mod tests { let cwd = temp_dir.path().join("project"); let params = latest_session_lookup_params( - /*is_remote*/ false, + /*uses_remote_workspace*/ false, + &config, + Some(cwd.as_path()), + /*include_non_interactive*/ false, + ); + + assert_eq!(params.model_providers, Some(vec![config.model_provider_id])); + assert_eq!( + params.cwd, + Some(ThreadListCwdFilter::One(cwd.to_string_lossy().to_string())) + ); + Ok(()) + } + + #[tokio::test] + async fn latest_session_lookup_params_keep_local_filters_for_local_daemon_sessions() + -> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + let config = build_config(&temp_dir).await?; + let cwd = temp_dir.path().join("project"); + let target = AppServerTarget::LocalDaemon { + endpoint: RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, + }, + }; + + let params = latest_session_lookup_params( + target.uses_remote_workspace(), &config, Some(cwd.as_path()), /*include_non_interactive*/ false, @@ -2018,7 +2239,7 @@ mod tests { let config = build_config(&temp_dir).await?; let params = latest_session_lookup_params( - /*is_remote*/ true, &config, /*cwd_filter*/ None, + /*uses_remote_workspace*/ true, &config, /*cwd_filter*/ None, /*include_non_interactive*/ false, ); @@ -2035,7 +2256,7 @@ mod tests { let cwd = Path::new("repo/on/server"); let params = latest_session_lookup_params( - /*is_remote*/ true, + /*uses_remote_workspace*/ true, &config, Some(cwd), /*include_non_interactive*/ false, @@ -2056,15 +2277,15 @@ mod tests { let remote_cwd = Path::new("repo/on/server"); let local_filter = latest_session_cwd_filter( - /*remote_mode*/ false, /*remote_cwd_override*/ None, &config, + /*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, &config, /*show_all*/ false, ); let show_all_filter = latest_session_cwd_filter( - /*remote_mode*/ false, /*remote_cwd_override*/ None, &config, + /*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, &config, /*show_all*/ true, ); let remote_filter = latest_session_cwd_filter( - /*remote_mode*/ true, + /*uses_remote_workspace*/ true, Some(remote_cwd), &config, /*show_all*/ false, @@ -2189,12 +2410,14 @@ mod tests { &other_cwd, )?; - let mut app_server = - AppServerSession::new(codex_app_server_client::AppServerClient::InProcess( + let mut app_server = AppServerSession::new( + codex_app_server_client::AppServerClient::InProcess( start_test_embedded_app_server(config.clone()).await?, - )); + ), + ThreadParamsMode::Embedded, + ); let filter_cwd = latest_session_cwd_filter( - /*remote_mode*/ false, /*remote_cwd_override*/ None, &config, + /*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, &config, /*show_all*/ false, ); let scoped_target = lookup_latest_session_target_with_app_server( @@ -2206,7 +2429,7 @@ mod tests { .await? .expect("expected project-scoped fork --last target"); let show_all_filter_cwd = latest_session_cwd_filter( - /*remote_mode*/ false, /*remote_cwd_override*/ None, &config, + /*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, &config, /*show_all*/ true, ); let show_all_target = lookup_latest_session_target_with_app_server( @@ -2265,6 +2488,29 @@ mod tests { Ok(()) } + #[tokio::test] + async fn config_cwd_for_app_server_target_canonicalizes_local_daemon_cli_cwd() + -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let target = AppServerTarget::LocalDaemon { + endpoint: RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, + }, + }; + let environment_manager = EnvironmentManager::default_for_tests(); + + let config_cwd = + config_cwd_for_app_server_target(Some(temp_dir.path()), &target, &environment_manager)?; + + assert_eq!( + config_cwd, + Some(AbsolutePathBuf::from_absolute_path(dunce::canonicalize( + temp_dir.path() + )?)?) + ); + Ok(()) + } + #[tokio::test] async fn config_cwd_for_app_server_target_errors_for_missing_embedded_cli_cwd() -> std::io::Result<()> { @@ -2388,10 +2634,12 @@ mod tests { .await .map_err(std::io::Error::other)?; - let mut app_server = - AppServerSession::new(codex_app_server_client::AppServerClient::InProcess( + let mut app_server = AppServerSession::new( + codex_app_server_client::AppServerClient::InProcess( start_test_embedded_app_server(config).await?, - )); + ), + ThreadParamsMode::Embedded, + ); let target = lookup_session_target_by_name_with_app_server(&mut app_server, "saved-session") .await?; diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index c7be652f85..3dbc2c3da6 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -349,15 +349,15 @@ async fn run_resume_picker_with_launch_context( launch_context: SessionPickerLaunchContext, ) -> Result { let (bg_tx, bg_rx) = mpsc::unbounded_channel(); - let is_remote = app_server.is_remote(); + let uses_remote_workspace = app_server.uses_remote_workspace(); let cwd_filter = picker_cwd_filter( config.cwd.as_path(), /*show_all*/ false, - is_remote, + uses_remote_workspace, app_server.remote_cwd_override(), ); - let local_filter_cwd = local_picker_cwd_filter(&cwd_filter, is_remote); - let provider_filter = picker_provider_filter(config, is_remote); + let local_filter_cwd = local_picker_cwd_filter(&cwd_filter, uses_remote_workspace); + let provider_filter = picker_provider_filter(config, uses_remote_workspace); let runtime_keymap = picker_runtime_keymap(config)?; let options = SessionPickerRunOptions { show_all, @@ -395,15 +395,15 @@ pub async fn run_fork_picker_with_app_server( app_server: AppServerSession, ) -> Result { let (bg_tx, bg_rx) = mpsc::unbounded_channel(); - let is_remote = app_server.is_remote(); + let uses_remote_workspace = app_server.uses_remote_workspace(); let cwd_filter = picker_cwd_filter( config.cwd.as_path(), /*show_all*/ false, - is_remote, + uses_remote_workspace, app_server.remote_cwd_override(), ); - let local_filter_cwd = local_picker_cwd_filter(&cwd_filter, is_remote); - let provider_filter = picker_provider_filter(config, is_remote); + let local_filter_cwd = local_picker_cwd_filter(&cwd_filter, uses_remote_workspace); + let provider_filter = picker_provider_filter(config, uses_remote_workspace); let runtime_keymap = picker_runtime_keymap(config)?; let options = SessionPickerRunOptions { show_all, @@ -513,12 +513,19 @@ fn raw_reasoning_visibility(config: &Config) -> RawReasoningVisibility { } } -fn local_picker_cwd_filter(cwd_filter: &Option, is_remote: bool) -> Option { - if is_remote { None } else { cwd_filter.clone() } +fn local_picker_cwd_filter( + cwd_filter: &Option, + uses_remote_workspace: bool, +) -> Option { + if uses_remote_workspace { + None + } else { + cwd_filter.clone() + } } -fn picker_provider_filter(config: &Config, is_remote: bool) -> ProviderFilter { - if is_remote { +fn picker_provider_filter(config: &Config, uses_remote_workspace: bool) -> ProviderFilter { + if uses_remote_workspace { ProviderFilter::Any } else { ProviderFilter::MatchDefault(config.model_provider_id.to_string()) @@ -533,12 +540,12 @@ fn picker_runtime_keymap(config: &Config) -> Result { fn picker_cwd_filter( config_cwd: &Path, show_all: bool, - is_remote: bool, + uses_remote_workspace: bool, remote_cwd_override: Option<&Path>, ) -> Option { if show_all { None - } else if is_remote { + } else if uses_remote_workspace { remote_cwd_override.map(Path::to_path_buf) } else { Some(config_cwd.to_path_buf()) @@ -3309,7 +3316,7 @@ mod tests { let cwd_filter = picker_cwd_filter( Path::new("/tmp/project"), /*show_all*/ false, - /*is_remote*/ false, + /*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, ); let params = thread_list_params( @@ -3588,7 +3595,8 @@ mod tests { remote_cwd.clone(), SessionPickerAction::Resume, ); - state.local_filter_cwd = local_picker_cwd_filter(&remote_cwd, /*is_remote*/ true); + state.local_filter_cwd = + local_picker_cwd_filter(&remote_cwd, /*uses_remote_workspace*/ true); state.start_initial_load(); From 59f262a2b4cde509bf3f36ee7213f46d3f4c8a0b Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 19 May 2026 15:52:17 -0700 Subject: [PATCH 10/63] build: fetch rg for Codex packages (#23526) ## Why The Codex package builder should produce a complete package without requiring callers to pre-populate `rg` under `codex-cli/vendor` or have `dotslash` installed on `PATH`. The repo already tracks the authoritative DotSlash manifest in `codex-cli/bin/rg`, so the builder can read that metadata directly and fetch the correct ripgrep archive for the target it is packaging. ## What changed - Added `scripts/codex_package/ripgrep.py` to parse `codex-cli/bin/rg` after stripping the shebang, select the target platform entry, download the configured artifact, and verify the recorded size and SHA-256 digest. - Added a cache under `$TMPDIR/codex-package/-rg` so verified archives can be reused without fetching again. - Extracted `rg`/`rg.exe` from `tar.gz` and `zip` artifacts into the package-builder cache, then copied that into `codex-path` through the existing package layout flow. - Kept `--rg-bin` as an explicit local override for offline tests and unusual local workflows. - Documented the default `rg` fetch/cache behavior in `scripts/codex_package/README.md`. ## Verification - Ran wrapper/module syntax compilation. - Ran `scripts/build_codex_package.py --help` from `/private/tmp`. - Ran a local manifest fetch test covering shebang-stripped manifest parsing, `tar.gz` extraction, `zip` extraction, size/SHA-256 verification, and cache reuse after deleting the original source archives. - Ran fake-cargo package/archive builds for macOS, Linux, and Windows target layouts with `--rg-bin`, including an assertion that generated tar archives contain no duplicate member names. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/23526). * #23541 * __->__ #23526 --- scripts/codex_package/README.md | 8 +- scripts/codex_package/cli.py | 7 +- scripts/codex_package/ripgrep.py | 195 +++++++++++++++++++++++++++++++ scripts/codex_package/targets.py | 32 ++--- 4 files changed, 213 insertions(+), 29 deletions(-) create mode 100644 scripts/codex_package/ripgrep.py diff --git a/scripts/codex_package/README.md b/scripts/codex_package/README.md index 8c3c7ac58b..6eb45eef47 100644 --- a/scripts/codex_package/README.md +++ b/scripts/codex_package/README.md @@ -34,6 +34,8 @@ one grouped `cargo build` command per package: The default cargo profile is `dev-small` because local iteration should favor fast, small builds. Release jobs should pass `--cargo-profile release`. -`rg` is not built from this repository, so it remains an input. If `--rg-bin` is -omitted, the builder looks in the existing `codex-cli/vendor//path/` -location. +`rg` is not built from this repository, so the builder fetches it from the +DotSlash manifest at `codex-cli/bin/rg`. Downloaded archives are cached under +`$TMPDIR/codex-package/-rg` and are reused only after the recorded size +and SHA-256 digest have been verified. Pass `--rg-bin` to use a local ripgrep +executable instead. diff --git a/scripts/codex_package/cli.py b/scripts/codex_package/cli.py index beddffa3b9..c80f50a1a0 100644 --- a/scripts/codex_package/cli.py +++ b/scripts/codex_package/cli.py @@ -8,9 +8,9 @@ from .cargo import build_source_binaries from .layout import build_package_dir from .layout import prepare_package_dir from .layout import validate_package_dir +from .ripgrep import resolve_rg_bin from .targets import TARGET_SPECS from .targets import PackageInputs -from .targets import resolve_rg_bin def parse_args() -> argparse.Namespace: @@ -69,7 +69,10 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--rg-bin", type=Path, - help="Path to the ripgrep executable to place in codex-path/.", + help=( + "Optional local ripgrep executable override instead of fetching from " + "codex-cli/bin/rg." + ), ) return parser.parse_args() diff --git a/scripts/codex_package/ripgrep.py b/scripts/codex_package/ripgrep.py new file mode 100644 index 0000000000..5411cb579a --- /dev/null +++ b/scripts/codex_package/ripgrep.py @@ -0,0 +1,195 @@ +"""Fetch ripgrep from the DotSlash manifest used by the npm package.""" + +import hashlib +import json +import shutil +import stat +import tarfile +import tempfile +import zipfile +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlparse +from urllib.request import urlopen + +from .targets import REPO_ROOT +from .targets import TargetSpec +from .targets import resolve_input_path + + +RG_MANIFEST = REPO_ROOT / "codex-cli" / "bin" / "rg" +DOWNLOAD_TIMEOUT_SECS = 60 + + +@dataclass(frozen=True) +class RgArtifact: + size: int + digest: str + archive_format: str + archive_member: str + url: str + + +def resolve_rg_bin(spec: TargetSpec, rg_bin: Path | None) -> Path: + if rg_bin is not None: + return resolve_input_path(rg_bin, "ripgrep executable", "--rg-bin") + + return fetch_rg(spec) + + +def fetch_rg( + spec: TargetSpec, + *, + manifest_path: Path = RG_MANIFEST, + cache_root: Path | None = None, +) -> Path: + artifact = artifact_for_target(spec, manifest_path) + cache_dir = (cache_root or default_cache_root()) / f"{spec.target}-rg" + archive_path = cache_dir / archive_filename(artifact.url) + + if not archive_is_valid(archive_path, artifact): + download_archive(artifact.url, archive_path) + try: + verify_archive(archive_path, artifact) + except RuntimeError: + archive_path.unlink(missing_ok=True) + raise + + dest = cache_dir / spec.rg_name + extract_rg(archive_path, artifact, dest) + if not spec.is_windows: + mode = dest.stat().st_mode + dest.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return dest + + +def artifact_for_target(spec: TargetSpec, manifest_path: Path) -> RgArtifact: + manifest = load_manifest(manifest_path) + try: + platform_info = manifest["platforms"][spec.dotslash_platform] + except KeyError as exc: + raise RuntimeError( + f"ripgrep manifest {manifest_path} is missing platform {spec.dotslash_platform!r}" + ) from exc + + providers = platform_info.get("providers") + if not providers: + raise RuntimeError( + f"ripgrep manifest {manifest_path} has no providers for {spec.dotslash_platform!r}" + ) + + hash_name = platform_info.get("hash") + if hash_name != "sha256": + raise RuntimeError( + f"Unsupported ripgrep hash {hash_name!r} for " + f"{spec.dotslash_platform!r}; expected sha256" + ) + + return RgArtifact( + size=int(platform_info["size"]), + digest=str(platform_info["digest"]), + archive_format=str(platform_info["format"]), + archive_member=str(platform_info["path"]), + url=str(providers[0]["url"]), + ) + + +def load_manifest(manifest_path: Path) -> dict: + text = manifest_path.read_text(encoding="utf-8") + if text.startswith("#!"): + text = "\n".join(text.splitlines()[1:]) + return json.loads(text) + + +def default_cache_root() -> Path: + return Path(tempfile.gettempdir()) / "codex-package" + + +def archive_filename(url: str) -> str: + filename = Path(urlparse(url).path).name + if not filename: + raise RuntimeError(f"Unable to determine archive filename from {url}") + return filename + + +def archive_is_valid(archive_path: Path, artifact: RgArtifact) -> bool: + if not archive_path.is_file(): + return False + try: + verify_archive(archive_path, artifact) + except RuntimeError: + archive_path.unlink(missing_ok=True) + return False + return True + + +def verify_archive(archive_path: Path, artifact: RgArtifact) -> None: + actual_size = archive_path.stat().st_size + if actual_size != artifact.size: + raise RuntimeError( + f"ripgrep archive {archive_path} has size {actual_size}, expected {artifact.size}" + ) + + digest = hashlib.sha256() + with open(archive_path, "rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + digest.update(chunk) + + actual_digest = digest.hexdigest() + if actual_digest != artifact.digest: + raise RuntimeError( + f"ripgrep archive {archive_path} has sha256 {actual_digest}, " + f"expected {artifact.digest}" + ) + + +def download_archive(url: str, archive_path: Path) -> None: + archive_path.parent.mkdir(parents=True, exist_ok=True) + temp_path = archive_path.with_suffix(f"{archive_path.suffix}.tmp") + temp_path.unlink(missing_ok=True) + try: + with urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECS) as response: + with open(temp_path, "wb") as out: + shutil.copyfileobj(response, out) + temp_path.replace(archive_path) + finally: + temp_path.unlink(missing_ok=True) + + +def extract_rg(archive_path: Path, artifact: RgArtifact, dest: Path) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + dest.unlink(missing_ok=True) + + if artifact.archive_format == "tar.gz": + with tarfile.open(archive_path, "r:gz") as archive: + try: + member = archive.getmember(artifact.archive_member) + except KeyError as exc: + raise RuntimeError( + f"ripgrep archive {archive_path} is missing {artifact.archive_member!r}" + ) from exc + + extracted = archive.extractfile(member) + if extracted is None: + raise RuntimeError( + f"ripgrep archive member {artifact.archive_member!r} is not a file" + ) + with extracted, open(dest, "wb") as out: + shutil.copyfileobj(extracted, out) + return + + if artifact.archive_format == "zip": + with zipfile.ZipFile(archive_path) as archive: + try: + with archive.open(artifact.archive_member) as extracted: + with open(dest, "wb") as out: + shutil.copyfileobj(extracted, out) + except KeyError as exc: + raise RuntimeError( + f"ripgrep archive {archive_path} is missing {artifact.archive_member!r}" + ) from exc + return + + raise RuntimeError( + f"Unsupported ripgrep archive format {artifact.archive_format!r}; expected tar.gz or zip" + ) diff --git a/scripts/codex_package/targets.py b/scripts/codex_package/targets.py index bdba8bd766..0cb964a4cc 100644 --- a/scripts/codex_package/targets.py +++ b/scripts/codex_package/targets.py @@ -14,6 +14,7 @@ class TargetSpec: target: str is_windows: bool is_linux: bool + dotslash_platform: str @property def exe_suffix(self) -> str: @@ -42,53 +43,43 @@ TARGET_SPECS: dict[str, TargetSpec] = { target="x86_64-unknown-linux-musl", is_windows=False, is_linux=True, + dotslash_platform="linux-x86_64", ), "aarch64-unknown-linux-musl": TargetSpec( target="aarch64-unknown-linux-musl", is_windows=False, is_linux=True, + dotslash_platform="linux-aarch64", ), "x86_64-apple-darwin": TargetSpec( target="x86_64-apple-darwin", is_windows=False, is_linux=False, + dotslash_platform="macos-x86_64", ), "aarch64-apple-darwin": TargetSpec( target="aarch64-apple-darwin", is_windows=False, is_linux=False, + dotslash_platform="macos-aarch64", ), "x86_64-pc-windows-msvc": TargetSpec( target="x86_64-pc-windows-msvc", is_windows=True, is_linux=False, + dotslash_platform="windows-x86_64", ), "aarch64-pc-windows-msvc": TargetSpec( target="aarch64-pc-windows-msvc", is_windows=True, is_linux=False, + dotslash_platform="windows-aarch64", ), } -def resolve_rg_bin(spec: TargetSpec, rg_bin: Path | None) -> Path: - return resolve_input_path( - rg_bin, - default_rg_candidates(spec), - "ripgrep executable", - "--rg-bin", - ) - - -def default_rg_candidates(spec: TargetSpec) -> list[Path]: - return [ - REPO_ROOT / "codex-cli" / "vendor" / spec.target / "path" / spec.rg_name, - ] - - def resolve_input_path( explicit_path: Path | None, - default_candidates: list[Path], description: str, flag_name: str, ) -> Path: @@ -100,14 +91,7 @@ def resolve_input_path( raise RuntimeError(f"{description} is not executable: {path}") return path - for candidate in default_candidates: - if candidate.is_file(): - return candidate.resolve() - - candidates = "\n".join(f" - {candidate}" for candidate in default_candidates) - raise RuntimeError( - f"Could not find {description}. Pass {flag_name}, or create one of:\n{candidates}" - ) + raise RuntimeError(f"Must specify {flag_name} for {description}.") def is_executable(path: Path) -> bool: From b019a678d8fae5ada157d4755c9720c067dcbb05 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Tue, 19 May 2026 16:23:25 -0700 Subject: [PATCH 11/63] Remove unused ARC monitor path (#23573) ## Summary - remove the unreachable ARC monitor path from MCP tool approval handling - delete the unused ARC monitor module/tests and trim the orphaned safety-monitor decision plumbing - keep `always allow` approvals on the existing auto-approval short-circuit without a dead monitor hop ## Testing - `cargo test -p codex-core mcp_tool_call` - `just fmt` - `just fix -p codex-core` - `git diff --check` ## Additional validation - Attempted `cargo test -p codex-core`; the library test target passed, then the integration target failed in this local environment. - The narrower MCP-focused rerun passed its unit coverage and only hit missing local `test_stdio_server` binaries in filtered integration cases. --- codex-rs/core/src/arc_monitor.rs | 416 --------------------- codex-rs/core/src/arc_monitor_tests.rs | 438 ----------------------- codex-rs/core/src/guardian/mod.rs | 1 + codex-rs/core/src/lib.rs | 1 - codex-rs/core/src/mcp_tool_call.rs | 134 +------ codex-rs/core/src/mcp_tool_call_tests.rs | 319 +---------------- 6 files changed, 15 insertions(+), 1294 deletions(-) delete mode 100644 codex-rs/core/src/arc_monitor.rs delete mode 100644 codex-rs/core/src/arc_monitor_tests.rs diff --git a/codex-rs/core/src/arc_monitor.rs b/codex-rs/core/src/arc_monitor.rs deleted file mode 100644 index a4f7e038e0..0000000000 --- a/codex-rs/core/src/arc_monitor.rs +++ /dev/null @@ -1,416 +0,0 @@ -use std::env; -use std::time::Duration; - -use serde::Deserialize; -use serde::Serialize; -use tracing::warn; - -use crate::compact::content_items_to_text; -use crate::event_mapping::is_contextual_user_message_content; -use crate::session::session::Session; -use crate::session::turn_context::TurnContext; -use codex_login::default_client::build_reqwest_client; -use codex_protocol::models::MessagePhase; -use codex_protocol::models::ResponseItem; - -const ARC_MONITOR_TIMEOUT: Duration = Duration::from_secs(30); -const CODEX_ARC_MONITOR_ENDPOINT_OVERRIDE: &str = "CODEX_ARC_MONITOR_ENDPOINT_OVERRIDE"; -const CODEX_ARC_MONITOR_TOKEN: &str = "CODEX_ARC_MONITOR_TOKEN"; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ArcMonitorOutcome { - Ok, - SteerModel(String), - AskUser(String), -} - -#[derive(Debug, Serialize, PartialEq)] -struct ArcMonitorRequest { - metadata: ArcMonitorMetadata, - #[serde(skip_serializing_if = "Option::is_none")] - messages: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - input: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - policies: Option, - action: serde_json::Map, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct ArcMonitorResult { - outcome: ArcMonitorResultOutcome, - short_reason: String, - rationale: String, - risk_score: u8, - risk_level: ArcMonitorRiskLevel, - evidence: Vec, -} - -#[derive(Debug, Serialize, PartialEq)] -struct ArcMonitorChatMessage { - role: String, - content: serde_json::Value, -} - -#[derive(Debug, Serialize, PartialEq)] -struct ArcMonitorPolicies { - user: Option, - developer: Option, -} - -#[derive(Debug, Serialize, PartialEq)] -#[serde(deny_unknown_fields)] -struct ArcMonitorMetadata { - codex_thread_id: String, - codex_turn_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - conversation_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - protection_client_callsite: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -#[allow(dead_code)] -struct ArcMonitorEvidence { - message: String, - why: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "kebab-case")] -enum ArcMonitorResultOutcome { - Ok, - SteerModel, - AskUser, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "lowercase")] -enum ArcMonitorRiskLevel { - Low, - Medium, - High, - Critical, -} - -pub(crate) async fn monitor_action( - sess: &Session, - turn_context: &TurnContext, - action: serde_json::Value, - protection_client_callsite: &'static str, -) -> ArcMonitorOutcome { - let auth = match turn_context.auth_manager.as_ref() { - Some(auth_manager) => match auth_manager.auth().await { - Some(auth) if auth.uses_codex_backend() => Some(auth), - _ => None, - }, - None => None, - }; - let env_token = read_non_empty_env_var(CODEX_ARC_MONITOR_TOKEN); - if env_token.is_none() && auth.is_none() { - return ArcMonitorOutcome::Ok; - } - - let url = read_non_empty_env_var(CODEX_ARC_MONITOR_ENDPOINT_OVERRIDE).unwrap_or_else(|| { - format!( - "{}/codex/safety/arc", - turn_context.config.chatgpt_base_url.trim_end_matches('/') - ) - }); - let action = match action { - serde_json::Value::Object(action) => action, - _ => { - warn!("skipping safety monitor because action payload is not an object"); - return ArcMonitorOutcome::Ok; - } - }; - let body = - build_arc_monitor_request(sess, turn_context, action, protection_client_callsite).await; - let client = build_reqwest_client(); - let mut request = client.post(&url).timeout(ARC_MONITOR_TIMEOUT).json(&body); - if let Some(token) = env_token { - request = request.bearer_auth(token); - } else if let Some(auth) = auth.as_ref() { - request = - request.headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()); - } - - let response = match request.send().await { - Ok(response) => response, - Err(err) => { - warn!(error = %err, %url, "safety monitor request failed"); - return ArcMonitorOutcome::Ok; - } - }; - let status = response.status(); - if !status.is_success() { - let response_text = response.text().await.unwrap_or_default(); - warn!( - %status, - %url, - response_text, - "safety monitor returned non-success status" - ); - return ArcMonitorOutcome::Ok; - } - - let response = match response.json::().await { - Ok(response) => response, - Err(err) => { - warn!(error = %err, %url, "failed to parse safety monitor response"); - return ArcMonitorOutcome::Ok; - } - }; - tracing::debug!( - risk_score = response.risk_score, - risk_level = ?response.risk_level, - evidence_count = response.evidence.len(), - "safety monitor completed" - ); - - let short_reason = response.short_reason.trim(); - let rationale = response.rationale.trim(); - match response.outcome { - ArcMonitorResultOutcome::Ok => ArcMonitorOutcome::Ok, - ArcMonitorResultOutcome::AskUser => { - if !short_reason.is_empty() { - ArcMonitorOutcome::AskUser(short_reason.to_string()) - } else if !rationale.is_empty() { - ArcMonitorOutcome::AskUser(rationale.to_string()) - } else { - ArcMonitorOutcome::AskUser( - "Additional confirmation is required before this tool call can continue." - .to_string(), - ) - } - } - ArcMonitorResultOutcome::SteerModel => { - if !rationale.is_empty() { - ArcMonitorOutcome::SteerModel(rationale.to_string()) - } else if !short_reason.is_empty() { - ArcMonitorOutcome::SteerModel(short_reason.to_string()) - } else { - ArcMonitorOutcome::SteerModel( - "Tool call was cancelled because of safety risks.".to_string(), - ) - } - } - } -} - -fn read_non_empty_env_var(key: &str) -> Option { - match env::var(key) { - Ok(value) => { - let value = value.trim(); - (!value.is_empty()).then(|| value.to_string()) - } - Err(env::VarError::NotPresent) => None, - Err(env::VarError::NotUnicode(_)) => { - warn!( - env_var = key, - "ignoring non-unicode safety monitor env override" - ); - None - } - } -} - -async fn build_arc_monitor_request( - sess: &Session, - turn_context: &TurnContext, - action: serde_json::Map, - protection_client_callsite: &'static str, -) -> ArcMonitorRequest { - let history = sess.clone_history().await; - let mut messages = build_arc_monitor_messages(history.raw_items()); - if messages.is_empty() { - messages.push(build_arc_monitor_message( - "user", - serde_json::Value::String( - "No prior conversation history is available for this ARC evaluation.".to_string(), - ), - )); - } - - let conversation_id = sess.conversation_id.to_string(); - ArcMonitorRequest { - metadata: ArcMonitorMetadata { - codex_thread_id: conversation_id.clone(), - codex_turn_id: turn_context.sub_id.clone(), - conversation_id: Some(conversation_id), - protection_client_callsite: Some(protection_client_callsite.to_string()), - }, - messages: Some(messages), - input: None, - policies: Some(ArcMonitorPolicies { - user: None, - developer: None, - }), - action, - } -} - -fn build_arc_monitor_messages(items: &[ResponseItem]) -> Vec { - let last_tool_call_index = items - .iter() - .enumerate() - .rev() - .find(|(_, item)| { - matches!( - item, - ResponseItem::LocalShellCall { .. } - | ResponseItem::FunctionCall { .. } - | ResponseItem::CustomToolCall { .. } - | ResponseItem::WebSearchCall { .. } - ) - }) - .map(|(index, _)| index); - let last_encrypted_reasoning_index = items - .iter() - .enumerate() - .rev() - .find(|(_, item)| { - matches!( - item, - ResponseItem::Reasoning { - encrypted_content: Some(encrypted_content), - .. - } if !encrypted_content.trim().is_empty() - ) - }) - .map(|(index, _)| index); - - items - .iter() - .enumerate() - .filter_map(|(index, item)| { - build_arc_monitor_message_item( - item, - index, - last_tool_call_index, - last_encrypted_reasoning_index, - ) - }) - .collect() -} - -fn build_arc_monitor_message_item( - item: &ResponseItem, - index: usize, - last_tool_call_index: Option, - last_encrypted_reasoning_index: Option, -) -> Option { - match item { - ResponseItem::Message { role, content, .. } if role == "user" => { - if is_contextual_user_message_content(content) { - None - } else { - content_items_to_text(content) - .map(|text| build_arc_monitor_text_message("user", "input_text", text)) - } - } - ResponseItem::Message { - role, - content, - phase: Some(MessagePhase::FinalAnswer), - .. - } if role == "assistant" => content_items_to_text(content) - .map(|text| build_arc_monitor_text_message("assistant", "output_text", text)), - ResponseItem::Message { .. } => None, - ResponseItem::Reasoning { - encrypted_content: Some(encrypted_content), - .. - } if Some(index) == last_encrypted_reasoning_index - && !encrypted_content.trim().is_empty() => - { - Some(build_arc_monitor_message( - "assistant", - serde_json::json!([{ - "type": "encrypted_reasoning", - "encrypted_content": encrypted_content, - }]), - )) - } - ResponseItem::Reasoning { .. } => None, - ResponseItem::LocalShellCall { action, .. } if Some(index) == last_tool_call_index => { - Some(build_arc_monitor_message( - "assistant", - serde_json::json!([{ - "type": "tool_call", - "tool_name": "shell", - "action": action, - }]), - )) - } - ResponseItem::FunctionCall { - name, arguments, .. - } if Some(index) == last_tool_call_index => Some(build_arc_monitor_message( - "assistant", - serde_json::json!([{ - "type": "tool_call", - "tool_name": name, - "arguments": arguments, - }]), - )), - ResponseItem::CustomToolCall { name, input, .. } if Some(index) == last_tool_call_index => { - Some(build_arc_monitor_message( - "assistant", - serde_json::json!([{ - "type": "tool_call", - "tool_name": name, - "input": input, - }]), - )) - } - ResponseItem::WebSearchCall { action, .. } if Some(index) == last_tool_call_index => { - Some(build_arc_monitor_message( - "assistant", - serde_json::json!([{ - "type": "tool_call", - "tool_name": "web_search", - "action": action, - }]), - )) - } - ResponseItem::LocalShellCall { .. } - | ResponseItem::FunctionCall { .. } - | ResponseItem::CustomToolCall { .. } - | ResponseItem::ToolSearchCall { .. } - | ResponseItem::WebSearchCall { .. } - | ResponseItem::FunctionCallOutput { .. } - | ResponseItem::CustomToolCallOutput { .. } - | ResponseItem::ToolSearchOutput { .. } - | ResponseItem::ImageGenerationCall { .. } - | ResponseItem::Compaction { .. } - | ResponseItem::CompactionTrigger - | ResponseItem::ContextCompaction { .. } - | ResponseItem::Other => None, - } -} - -fn build_arc_monitor_text_message( - role: &str, - part_type: &str, - text: String, -) -> ArcMonitorChatMessage { - build_arc_monitor_message( - role, - serde_json::json!([{ - "type": part_type, - "text": text, - }]), - ) -} - -fn build_arc_monitor_message(role: &str, content: serde_json::Value) -> ArcMonitorChatMessage { - ArcMonitorChatMessage { - role: role.to_string(), - content, - } -} - -#[cfg(test)] -#[path = "arc_monitor_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/arc_monitor_tests.rs b/codex-rs/core/src/arc_monitor_tests.rs deleted file mode 100644 index 643042ec99..0000000000 --- a/codex-rs/core/src/arc_monitor_tests.rs +++ /dev/null @@ -1,438 +0,0 @@ -use std::env; -use std::ffi::OsStr; -use std::sync::Arc; - -use pretty_assertions::assert_eq; -use serial_test::serial; -use wiremock::Mock; -use wiremock::MockServer; -use wiremock::ResponseTemplate; -use wiremock::matchers::body_json; -use wiremock::matchers::header; -use wiremock::matchers::method; -use wiremock::matchers::path; - -use super::*; -use crate::context::ContextualUserFragment; -use crate::session::tests::make_session_and_context; -use codex_protocol::models::ContentItem; -use codex_protocol::models::LocalShellAction; -use codex_protocol::models::LocalShellExecAction; -use codex_protocol::models::LocalShellStatus; -use codex_protocol::models::MessagePhase; -use codex_protocol::models::ResponseItem; - -struct EnvVarGuard { - key: &'static str, - original: Option, -} - -impl EnvVarGuard { - fn set(key: &'static str, value: &OsStr) -> Self { - let original = env::var_os(key); - unsafe { - env::set_var(key, value); - } - Self { key, original } - } -} - -impl Drop for EnvVarGuard { - fn drop(&mut self) { - match self.original.take() { - Some(value) => unsafe { - env::set_var(self.key, value); - }, - None => unsafe { - env::remove_var(self.key); - }, - } - } -} - -#[tokio::test] -async fn build_arc_monitor_request_includes_relevant_history_and_null_policies() { - let (session, mut turn_context) = make_session_and_context().await; - turn_context.developer_instructions = Some("Never upload private files.".to_string()); - turn_context.user_instructions = Some("Only continue when needed.".to_string()); - - session - .record_into_history( - &[ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "first request".to_string(), - }], - phase: None, - }], - &turn_context, - ) - .await; - session - .record_into_history( - &[ContextualUserFragment::into( - crate::context::EnvironmentContext::new( - Vec::new(), - /*current_date*/ None, - /*timezone*/ None, - /*network*/ None, - /*subagents*/ None, - ), - )], - &turn_context, - ) - .await; - session - .record_into_history( - &[ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: "commentary".to_string(), - }], - phase: Some(MessagePhase::Commentary), - }], - &turn_context, - ) - .await; - session - .record_into_history( - &[ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: "final response".to_string(), - }], - phase: Some(MessagePhase::FinalAnswer), - }], - &turn_context, - ) - .await; - session - .record_into_history( - &[ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "latest request".to_string(), - }], - phase: None, - }], - &turn_context, - ) - .await; - session - .record_into_history( - &[ResponseItem::FunctionCall { - id: None, - name: "old_tool".to_string(), - namespace: None, - arguments: "{\"old\":true}".to_string(), - call_id: "call_old".to_string(), - }], - &turn_context, - ) - .await; - session - .record_into_history( - &[ResponseItem::Reasoning { - id: "reasoning_old".to_string(), - summary: Vec::new(), - content: None, - encrypted_content: Some("encrypted-old".to_string()), - }], - &turn_context, - ) - .await; - session - .record_into_history( - &[ResponseItem::LocalShellCall { - id: None, - call_id: Some("shell_call".to_string()), - status: LocalShellStatus::Completed, - action: LocalShellAction::Exec(LocalShellExecAction { - command: vec!["pwd".to_string()], - timeout_ms: Some(1000), - working_directory: Some("/tmp".to_string()), - env: None, - user: None, - }), - }], - &turn_context, - ) - .await; - session - .record_into_history( - &[ResponseItem::Reasoning { - id: "reasoning_latest".to_string(), - summary: Vec::new(), - content: None, - encrypted_content: Some("encrypted-latest".to_string()), - }], - &turn_context, - ) - .await; - - let request = build_arc_monitor_request( - &session, - &turn_context, - serde_json::from_value(serde_json::json!({ "tool": "mcp_tool_call" })) - .expect("action should deserialize"), - "normal", - ) - .await; - - assert_eq!( - request, - ArcMonitorRequest { - metadata: ArcMonitorMetadata { - codex_thread_id: session.conversation_id.to_string(), - codex_turn_id: turn_context.sub_id.clone(), - conversation_id: Some(session.conversation_id.to_string()), - protection_client_callsite: Some("normal".to_string()), - }, - messages: Some(vec![ - ArcMonitorChatMessage { - role: "user".to_string(), - content: serde_json::json!([{ - "type": "input_text", - "text": "first request", - }]), - }, - ArcMonitorChatMessage { - role: "assistant".to_string(), - content: serde_json::json!([{ - "type": "output_text", - "text": "final response", - }]), - }, - ArcMonitorChatMessage { - role: "user".to_string(), - content: serde_json::json!([{ - "type": "input_text", - "text": "latest request", - }]), - }, - ArcMonitorChatMessage { - role: "assistant".to_string(), - content: serde_json::json!([{ - "type": "tool_call", - "tool_name": "shell", - "action": { - "type": "exec", - "command": ["pwd"], - "timeout_ms": 1000, - "working_directory": "/tmp", - "env": null, - "user": null, - }, - }]), - }, - ArcMonitorChatMessage { - role: "assistant".to_string(), - content: serde_json::json!([{ - "type": "encrypted_reasoning", - "encrypted_content": "encrypted-latest", - }]), - }, - ]), - input: None, - policies: Some(ArcMonitorPolicies { - user: None, - developer: None, - }), - action: serde_json::from_value(serde_json::json!({ "tool": "mcp_tool_call" })) - .expect("action should deserialize"), - } - ); -} - -#[tokio::test] -#[serial(arc_monitor_env)] -async fn monitor_action_posts_expected_arc_request() { - let server = MockServer::start().await; - let (session, mut turn_context) = make_session_and_context().await; - turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( - codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(), - )); - turn_context.developer_instructions = Some("Developer policy".to_string()); - turn_context.user_instructions = Some("User policy".to_string()); - - let mut config = (*turn_context.config).clone(); - config.chatgpt_base_url = server.uri(); - turn_context.config = Arc::new(config); - - session - .record_into_history( - &[ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "please run the tool".to_string(), - }], - phase: None, - }], - &turn_context, - ) - .await; - - Mock::given(method("POST")) - .and(path("/codex/safety/arc")) - .and(header("authorization", "Bearer Access Token")) - .and(header("chatgpt-account-id", "account_id")) - .and(body_json(serde_json::json!({ - "metadata": { - "codex_thread_id": session.conversation_id.to_string(), - "codex_turn_id": turn_context.sub_id.clone(), - "conversation_id": session.conversation_id.to_string(), - "protection_client_callsite": "normal", - }, - "messages": [{ - "role": "user", - "content": [{ - "type": "input_text", - "text": "please run the tool", - }], - }], - "policies": { - "developer": null, - "user": null, - }, - "action": { - "tool": "mcp_tool_call", - }, - }))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "outcome": "ask-user", - "short_reason": "needs confirmation", - "rationale": "tool call needs additional review", - "risk_score": 42, - "risk_level": "medium", - "evidence": [{ - "message": "browser_navigate", - "why": "tool call needs additional review", - }], - }))) - .expect(1) - .mount(&server) - .await; - - let outcome = monitor_action( - &session, - &turn_context, - serde_json::json!({ "tool": "mcp_tool_call" }), - "normal", - ) - .await; - - assert_eq!( - outcome, - ArcMonitorOutcome::AskUser("needs confirmation".to_string()) - ); -} - -#[tokio::test] -#[serial(arc_monitor_env)] -async fn monitor_action_uses_env_url_and_token_overrides() { - let server = MockServer::start().await; - let _url_guard = EnvVarGuard::set( - CODEX_ARC_MONITOR_ENDPOINT_OVERRIDE, - OsStr::new(&format!("{}/override/arc", server.uri())), - ); - let _token_guard = EnvVarGuard::set(CODEX_ARC_MONITOR_TOKEN, OsStr::new("override-token")); - - let (session, turn_context) = make_session_and_context().await; - session - .record_into_history( - &[ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "please run the tool".to_string(), - }], - phase: None, - }], - &turn_context, - ) - .await; - - Mock::given(method("POST")) - .and(path("/override/arc")) - .and(header("authorization", "Bearer override-token")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "outcome": "steer-model", - "short_reason": "needs approval", - "rationale": "high-risk action", - "risk_score": 96, - "risk_level": "critical", - "evidence": [{ - "message": "browser_navigate", - "why": "high-risk action", - }], - }))) - .expect(1) - .mount(&server) - .await; - - let outcome = monitor_action( - &session, - &turn_context, - serde_json::json!({ "tool": "mcp_tool_call" }), - "normal", - ) - .await; - - assert_eq!( - outcome, - ArcMonitorOutcome::SteerModel("high-risk action".to_string()) - ); -} - -#[tokio::test] -#[serial(arc_monitor_env)] -async fn monitor_action_rejects_legacy_response_fields() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/safety/arc")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "outcome": "steer-model", - "reason": "legacy high-risk action", - "monitorRequestId": "arc_456", - }))) - .expect(1) - .mount(&server) - .await; - - let (session, mut turn_context) = make_session_and_context().await; - turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( - codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(), - )); - let mut config = (*turn_context.config).clone(); - config.chatgpt_base_url = server.uri(); - turn_context.config = Arc::new(config); - - session - .record_into_history( - &[ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "please run the tool".to_string(), - }], - phase: None, - }], - &turn_context, - ) - .await; - - let outcome = monitor_action( - &session, - &turn_context, - serde_json::json!({ "tool": "mcp_tool_call" }), - "normal", - ) - .await; - - assert_eq!(outcome, ArcMonitorOutcome::Ok); -} diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index 131433208a..058c19d008 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -26,6 +26,7 @@ use serde::Serialize; pub(crate) use approval_request::GuardianApprovalRequest; pub(crate) use approval_request::GuardianMcpAnnotations; pub(crate) use approval_request::GuardianNetworkAccessTrigger; +#[cfg(test)] pub(crate) use approval_request::guardian_approval_request_to_json; pub(crate) use review::guardian_rejection_message; pub(crate) use review::guardian_timeout_message; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index aca6049698..d5c1237638 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -7,7 +7,6 @@ mod apply_patch; mod apps; -mod arc_monitor; mod client; mod client_common; mod realtime_context; diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 6dc9f82465..7b62b10b6f 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -3,22 +3,12 @@ use std::collections::HashMap; use std::time::Duration; use std::time::Instant; -use codex_app_server_protocol::ConfigLayerSource; -use codex_app_server_protocol::McpElicitationObjectType; -use codex_app_server_protocol::McpElicitationSchema; -use codex_app_server_protocol::McpServerElicitationRequest; -use codex_app_server_protocol::McpServerElicitationRequestParams; -use tracing::error; - -use crate::arc_monitor::ArcMonitorOutcome; -use crate::arc_monitor::monitor_action; use crate::config::Config; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::connectors; use crate::guardian::GuardianApprovalRequest; use crate::guardian::GuardianMcpAnnotations; -use crate::guardian::guardian_approval_request_to_json; use crate::guardian::guardian_rejection_message; use crate::guardian::guardian_timeout_message; use crate::guardian::new_guardian_review_id; @@ -36,6 +26,11 @@ use crate::turn_metadata::McpTurnMetadataContext; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::build_track_events_context; +use codex_app_server_protocol::ConfigLayerSource; +use codex_app_server_protocol::McpElicitationObjectType; +use codex_app_server_protocol::McpElicitationSchema; +use codex_app_server_protocol::McpServerElicitationRequest; +use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_config::types::AppToolApproval; use codex_features::Feature; use codex_hooks::PermissionRequestDecision; @@ -91,6 +86,7 @@ use std::sync::Arc; use toml_edit::value; use tracing::Instrument; use tracing::Span; +use tracing::error; use tracing::field::Empty; use url::Url; @@ -259,18 +255,6 @@ pub(crate) async fn handle_mcp_tool_call( ) .await } - McpToolApprovalDecision::BlockedBySafetyMonitor(message) => { - notify_mcp_tool_call_skip( - sess.as_ref(), - turn_context.as_ref(), - &call_id, - invocation, - mcp_app_resource_uri.clone(), - message, - /*already_started*/ true, - ) - .await - } }; let status = if result.is_ok() { "ok" } else { "error" }; @@ -967,7 +951,6 @@ enum McpToolApprovalDecision { AcceptAndRemember, Decline { message: Option }, Cancel, - BlockedBySafetyMonitor(String), } pub(crate) struct McpToolApprovalMetadata { @@ -1131,8 +1114,6 @@ pub(crate) const MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION: &str = "Allow for this se pub(crate) const MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC: &str = "__codex_mcp_decline__"; const MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER: &str = "Allow and don't ask me again"; const MCP_TOOL_APPROVAL_CANCEL: &str = "Cancel"; -const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT: &str = "mcp_tool_call__default"; -const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_ALWAYS_ALLOW: &str = "mcp_tool_call__always_allow"; pub(crate) fn is_mcp_tool_approval_question_id(question_id: &str) -> bool { question_id @@ -1185,31 +1166,6 @@ async fn maybe_request_mcp_tool_approval( return None; } - let mut monitor_reason = None; - let auto_approved_by_policy = approval_mode == AppToolApproval::Approve; - - if auto_approved_by_policy { - match maybe_monitor_auto_approved_mcp_tool_call( - sess, - turn_context, - invocation, - metadata, - approval_mode, - ) - .await - { - ArcMonitorOutcome::Ok => return None, - ArcMonitorOutcome::AskUser(reason) => { - monitor_reason = Some(reason); - } - ArcMonitorOutcome::SteerModel(reason) => { - return Some(McpToolApprovalDecision::BlockedBySafetyMonitor( - arc_monitor_interrupt_message(&reason), - )); - } - } - } - let session_approval_key = session_mcp_tool_approval_key(invocation, metadata, approval_mode); let persistent_approval_key = persistent_mcp_tool_approval_key(invocation, metadata, approval_mode); @@ -1256,7 +1212,7 @@ async fn maybe_request_mcp_tool_approval( turn_context, review_id.clone(), build_guardian_mcp_tool_review_request(call_id, invocation, metadata), - monitor_reason.clone(), + /*retry_reason*/ None, ) .await; let decision = mcp_tool_approval_decision_from_guardian(sess, &review_id, decision).await; @@ -1288,7 +1244,7 @@ async fn maybe_request_mcp_tool_approval( .as_ref() .map(|rendered_template| rendered_template.tool_params_display.clone()) .or_else(|| build_mcp_tool_approval_display_params(invocation.arguments.as_ref())); - let mut question = build_mcp_tool_approval_question( + let question = build_mcp_tool_approval_question( question_id.clone(), &invocation.server, &invocation.tool, @@ -1298,8 +1254,6 @@ async fn maybe_request_mcp_tool_approval( .as_ref() .map(|rendered_template| rendered_template.question.as_str()), ); - question.question = - mcp_tool_approval_question_text(question.question, monitor_reason.as_deref()); if tool_call_mcp_elicitation_enabled { let request_id = rmcp::model::RequestId::String( format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}").into(), @@ -1316,11 +1270,9 @@ async fn maybe_request_mcp_tool_approval( .or(invocation.arguments.as_ref()), tool_params_display: tool_params_display.as_deref(), question, - message_override: rendered_template.as_ref().and_then(|rendered_template| { - monitor_reason - .is_none() - .then_some(rendered_template.elicitation_message.as_str()) - }), + message_override: rendered_template + .as_ref() + .map(|rendered_template| rendered_template.elicitation_message.as_str()), prompt_options, }, ); @@ -1363,37 +1315,6 @@ async fn maybe_request_mcp_tool_approval( Some(decision) } -async fn maybe_monitor_auto_approved_mcp_tool_call( - sess: &Session, - turn_context: &TurnContext, - invocation: &McpInvocation, - metadata: Option<&McpToolApprovalMetadata>, - approval_mode: AppToolApproval, -) -> ArcMonitorOutcome { - let action = prepare_arc_request_action(invocation, metadata); - monitor_action( - sess, - turn_context, - action, - mcp_tool_approval_callsite_mode(approval_mode, turn_context), - ) - .await -} - -fn prepare_arc_request_action( - invocation: &McpInvocation, - metadata: Option<&McpToolApprovalMetadata>, -) -> serde_json::Value { - let request = build_guardian_mcp_tool_review_request("arc-monitor", invocation, metadata); - match guardian_approval_request_to_json(&request) { - Ok(action) => action, - Err(error) => { - error!(error = %error, "failed to serialize guardian MCP approval request for ARC"); - serde_json::Value::Null - } - } -} - fn session_mcp_tool_approval_key( invocation: &McpInvocation, metadata: Option<&McpToolApprovalMetadata>, @@ -1468,18 +1389,6 @@ async fn mcp_tool_approval_decision_from_guardian( } } -fn mcp_tool_approval_callsite_mode( - approval_mode: AppToolApproval, - _turn_context: &TurnContext, -) -> &'static str { - match approval_mode { - AppToolApproval::Approve => MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_ALWAYS_ALLOW, - AppToolApproval::Auto | AppToolApproval::Prompt => { - MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT - } - } -} - #[expect( clippy::await_holding_invalid_type, reason = "MCP approval metadata reads through the session-owned manager guard" @@ -1669,24 +1578,6 @@ fn build_mcp_tool_approval_fallback_message( format!("Allow {actor} to run tool \"{tool_name}\"?") } -fn mcp_tool_approval_question_text(question: String, monitor_reason: Option<&str>) -> String { - match monitor_reason.map(str::trim) { - Some(reason) if !reason.is_empty() => { - format!("Tool call needs your approval. Reason: {reason}") - } - _ => question, - } -} - -fn arc_monitor_interrupt_message(reason: &str) -> String { - let reason = reason.trim(); - if reason.is_empty() { - "Tool call was cancelled because of safety risks.".to_string() - } else { - format!("Tool call was cancelled because of safety risks: {reason}") - } -} - fn build_mcp_tool_approval_elicitation_request( sess: &Session, turn_context: &TurnContext, @@ -1988,8 +1879,7 @@ async fn apply_mcp_tool_approval_decision( } McpToolApprovalDecision::Accept | McpToolApprovalDecision::Decline { .. } - | McpToolApprovalDecision::Cancel - | McpToolApprovalDecision::BlockedBySafetyMonitor(_) => {} + | McpToolApprovalDecision::Cancel => {} } } diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 50a6bb4d45..cb83600350 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -405,17 +405,6 @@ fn prompt_mode_does_not_allow_persistent_remember() { ); } -#[test] -fn approval_question_text_prepends_safety_reason() { - assert_eq!( - mcp_tool_approval_question_text( - "Allow this action?".to_string(), - Some("This tool may contact an external system."), - ), - "Tool call needs your approval. Reason: This tool may contact an external system." - ); -} - #[tokio::test] async fn mcp_tool_call_span_records_expected_fields() { let buffer: &'static std::sync::Mutex> = @@ -1633,42 +1622,6 @@ fn guardian_mcp_review_request_includes_annotations_when_present() { ); } -#[test] -fn prepare_arc_request_action_serializes_mcp_tool_call_shape() { - let invocation = McpInvocation { - server: CODEX_APPS_MCP_SERVER_NAME.to_string(), - tool: "browser_navigate".to_string(), - arguments: Some(serde_json::json!({ - "url": "https://example.com", - })), - }; - - let action = prepare_arc_request_action( - &invocation, - Some(&approval_metadata( - /*connector_id*/ None, - Some("Playwright"), - /*connector_description*/ None, - Some("Navigate"), - /*tool_description*/ None, - )), - ); - - assert_eq!( - action, - serde_json::json!({ - "tool": "mcp_tool_call", - "server": CODEX_APPS_MCP_SERVER_NAME, - "tool_name": "browser_navigate", - "arguments": { - "url": "https://example.com", - }, - "connector_name": "Playwright", - "tool_title": "Navigate", - }) - ); -} - #[tokio::test(flavor = "current_thread")] async fn guardian_review_decision_maps_to_mcp_tool_decision() { let (session, _) = make_session_and_context().await; @@ -1803,24 +1756,6 @@ fn approval_elicitation_meta_merges_session_and_always_persist_with_connector_so ); } -#[tokio::test] -async fn approval_callsite_mode_distinguishes_default_and_always_allow() { - let (_session, turn_context) = make_session_and_context().await; - - assert_eq!( - mcp_tool_approval_callsite_mode(AppToolApproval::Auto, &turn_context), - "mcp_tool_call__default" - ); - assert_eq!( - mcp_tool_approval_callsite_mode(AppToolApproval::Prompt, &turn_context), - "mcp_tool_call__default" - ); - assert_eq!( - mcp_tool_approval_callsite_mode(AppToolApproval::Approve, &turn_context), - "mcp_tool_call__always_allow" - ); -} - #[test] fn declined_elicitation_response_stays_decline() { let response = parse_mcp_tool_approval_elicitation_response( @@ -2763,247 +2698,13 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval } #[tokio::test] -async fn approve_mode_skips_arc_interrupt_for_model() { - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::method; - use wiremock::matchers::path; - - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/safety/arc")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "outcome": "steer-model", - "short_reason": "needs approval", - "rationale": "high-risk action", - "risk_score": 96, - "risk_level": "critical", - "evidence": [{ - "message": "dangerous_tool", - "why": "high-risk action", - }], - }))) - .expect(0) - .mount(&server) - .await; - +async fn full_access_mode_skips_mcp_tool_approval_for_all_approval_modes() { let (session, mut turn_context) = make_session_and_context().await; - turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( - codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(), - )); - let mut config = (*turn_context.config).clone(); - config.chatgpt_base_url = server.uri(); - turn_context.config = Arc::new(config); - - let session = Arc::new(session); - let turn_context = Arc::new(turn_context); - let invocation = McpInvocation { - server: CODEX_APPS_MCP_SERVER_NAME.to_string(), - tool: "dangerous_tool".to_string(), - arguments: Some(serde_json::json!({ "id": 1 })), - }; - let metadata = McpToolApprovalMetadata { - annotations: Some(annotations(Some(false), Some(true), Some(true))), - connector_id: Some("calendar".to_string()), - connector_name: Some("Calendar".to_string()), - connector_description: Some("Manage events".to_string()), - plugin_id: None, - tool_title: Some("Dangerous Tool".to_string()), - tool_description: Some("Performs a risky action.".to_string()), - mcp_app_resource_uri: None, - codex_apps_meta: None, - openai_file_input_params: None, - }; - - let decision = maybe_request_mcp_tool_approval( - &session, - &turn_context, - "call-2", - &invocation, - "mcp__test__tool", - Some(&metadata), - AppToolApproval::Approve, - ) - .await; - - assert_eq!(decision, None); -} - -#[tokio::test] -async fn custom_approve_mode_skips_arc_interrupt_for_model() { - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::method; - use wiremock::matchers::path; - - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/safety/arc")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "outcome": "steer-model", - "short_reason": "needs approval", - "rationale": "high-risk action", - "risk_score": 96, - "risk_level": "critical", - "evidence": [{ - "message": "dangerous_tool", - "why": "high-risk action", - }], - }))) - .expect(0) - .mount(&server) - .await; - - let (session, mut turn_context) = make_session_and_context().await; - turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( - codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(), - )); - let mut config = (*turn_context.config).clone(); - config.chatgpt_base_url = server.uri(); - turn_context.config = Arc::new(config); - - let session = Arc::new(session); - let turn_context = Arc::new(turn_context); - let invocation = McpInvocation { - server: "docs".to_string(), - tool: "dangerous_tool".to_string(), - arguments: Some(serde_json::json!({ "id": 1 })), - }; - let metadata = McpToolApprovalMetadata { - annotations: Some(annotations(Some(false), Some(true), Some(true))), - connector_id: None, - connector_name: None, - connector_description: None, - plugin_id: None, - tool_title: Some("Dangerous Tool".to_string()), - tool_description: Some("Performs a risky action.".to_string()), - mcp_app_resource_uri: None, - codex_apps_meta: None, - openai_file_input_params: None, - }; - - let decision = maybe_request_mcp_tool_approval( - &session, - &turn_context, - "call-2-custom", - &invocation, - "mcp__test__tool", - Some(&metadata), - AppToolApproval::Approve, - ) - .await; - - assert_eq!(decision, None); -} - -#[tokio::test] -async fn approve_mode_skips_arc_interrupt_without_annotations() { - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::method; - use wiremock::matchers::path; - - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/safety/arc")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "outcome": "steer-model", - "short_reason": "needs approval", - "rationale": "high-risk action", - "risk_score": 96, - "risk_level": "critical", - "evidence": [{ - "message": "dangerous_tool", - "why": "high-risk action", - }], - }))) - .expect(0) - .mount(&server) - .await; - - let (session, mut turn_context) = make_session_and_context().await; - turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( - codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(), - )); - let mut config = (*turn_context.config).clone(); - config.chatgpt_base_url = server.uri(); - turn_context.config = Arc::new(config); - - let session = Arc::new(session); - let turn_context = Arc::new(turn_context); - let invocation = McpInvocation { - server: CODEX_APPS_MCP_SERVER_NAME.to_string(), - tool: "dangerous_tool".to_string(), - arguments: Some(serde_json::json!({ "id": 1 })), - }; - let metadata = McpToolApprovalMetadata { - annotations: None, - connector_id: Some("calendar".to_string()), - connector_name: Some("Calendar".to_string()), - connector_description: Some("Manage events".to_string()), - plugin_id: None, - tool_title: Some("Dangerous Tool".to_string()), - tool_description: Some("Performs a risky action.".to_string()), - mcp_app_resource_uri: None, - codex_apps_meta: None, - openai_file_input_params: None, - }; - - let decision = maybe_request_mcp_tool_approval( - &session, - &turn_context, - "call-3", - &invocation, - "mcp__test__tool", - Some(&metadata), - AppToolApproval::Approve, - ) - .await; - - assert_eq!(decision, None); -} - -#[tokio::test] -async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() { - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::method; - use wiremock::matchers::path; - - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/safety/arc")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "outcome": "steer-model", - "short_reason": "needs approval", - "rationale": "high-risk action", - "risk_score": 96, - "risk_level": "critical", - "evidence": [{ - "message": "dangerous_tool", - "why": "high-risk action", - }], - }))) - .expect(0) - .mount(&server) - .await; - - let (session, mut turn_context) = make_session_and_context().await; - turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( - codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(), - )); turn_context .approval_policy .set(AskForApproval::Never) .expect("test setup should allow updating approval policy"); turn_context.permission_profile = PermissionProfile::Disabled; - let mut config = (*turn_context.config).clone(); - config.chatgpt_base_url = server.uri(); - turn_context.config = Arc::new(config); let session = Arc::new(session); let turn_context = Arc::new(turn_context); @@ -3046,7 +2747,7 @@ async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() { } #[tokio::test] -async fn approve_mode_skips_arc_and_guardian_in_every_permission_mode() { +async fn approve_mode_skips_guardian_in_every_permission_mode() { use wiremock::Mock; use wiremock::ResponseTemplate; use wiremock::matchers::method; @@ -3059,22 +2760,6 @@ async fn approve_mode_skips_arc_and_guardian_in_every_permission_mode() { .expect(0) .mount(&server) .await; - Mock::given(method("POST")) - .and(path("/codex/safety/arc")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "outcome": "ask-user", - "short_reason": "needs confirmation", - "rationale": "ARC wants a second review", - "risk_score": 65, - "risk_level": "medium", - "evidence": [{ - "message": "dangerous_tool", - "why": "requires review", - }], - }))) - .expect(0) - .mount(&server) - .await; let invocation = McpInvocation { server: CODEX_APPS_MCP_SERVER_NAME.to_string(), From c58c84d6ee90634aea418de67fe9c0b58a3a9119 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 19 May 2026 16:47:20 -0700 Subject: [PATCH 12/63] test: fix multi-agent service tier assertion (#23576) ## Why `openai/codex#22169` added a regression test that expects an invalid child `service_tier` to be rejected, but the test used `Result::expect_err` on `SpawnAgentHandler::handle`. That requires the `Ok` type to implement `Debug`, and this handler returns `Box`, so Bazel failed while compiling `codex-core` tests before it could run them. ## What changed - Capture the handler result and assert on `result.err()` instead of calling `expect_err`. - Keep the same `FunctionCallError::RespondToModel` assertion for the rejected service tier. ## Verification - `cargo test -p codex-core spawn_agent_role_service_tier_does_not_hide_invalid_spawn_request` --- .../core/src/tools/handlers/multi_agents_tests.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 d1e2aff3fb..a582805ed2 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -804,7 +804,7 @@ service_tier = "priority" ); turn.config = Arc::new(config); - let err = SpawnAgentHandler::default() + let result = SpawnAgentHandler::default() .handle(invocation( Arc::new(session), Arc::new(turn), @@ -815,15 +815,14 @@ service_tier = "priority" "service_tier": "turbo" })), )) - .await - .expect_err("invalid spawn service tier should still be rejected"); + .await; assert_eq!( - err, - FunctionCallError::RespondToModel( + result.err(), + Some(FunctionCallError::RespondToModel( "Service tier `turbo` is not supported for model `gpt-5.4`. Supported service tiers: priority" .to_string() - ) + )) ); } From 79f044ed34c60af815feef12e8e665f35d54871e Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 19 May 2026 17:05:43 -0700 Subject: [PATCH 13/63] build: default Codex package target and output (#23541) ## Why The package builder should be easy to run during local iteration. Requiring callers to provide both a target triple and an output directory every time makes the common host-package case more awkward than necessary. This PR keeps explicit overrides available, but makes the default invocation useful: build for the current host platform and place the package in a fresh temporary directory. Because a temp output path is otherwise easy to lose, the builder continues to print the final package directory path when it completes. ## What changed - Makes `--target` optional and maps the host OS/architecture to supported Codex package target triples. - Uses GNU Linux target triples for Linux host defaults, while keeping the musl targets available for release jobs that pass `--target` explicitly. - Makes `--package-dir` optional and creates a new `codex-package-*` temp directory when omitted. - Documents the new defaults in `scripts/codex_package/README.md`. ## Verification - Compiled `scripts/build_codex_package.py` and `scripts/codex_package/*.py` with `PYTHONDONTWRITEBYTECODE=1`. - Ran `scripts/build_codex_package.py --help` from outside the repo. - Verified Linux host detection maps `x86_64` and `aarch64` to GNU target triples. - Ran a fake-Cargo package build while omitting both `--target` and `--package-dir`; verified the generated metadata target, expected package files, and printed temp package path. - Ran a fake-Cargo package build for `x86_64-unknown-linux-gnu` and verified `codex`, `bwrap`, and `rg` are assembled into the package. --- scripts/codex_package/README.md | 9 ++++++- scripts/codex_package/cli.py | 25 +++++++++++++----- scripts/codex_package/targets.py | 45 ++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/scripts/codex_package/README.md b/scripts/codex_package/README.md index 6eb45eef47..c4abb58afd 100644 --- a/scripts/codex_package/README.md +++ b/scripts/codex_package/README.md @@ -22,6 +22,12 @@ The builder creates a canonical Codex package directory: The package directory is the primary artifact. Archive formats such as `.tar.gz`, `.tar.zst`, and `.zip` are serializations of that directory. +If `--target` is omitted, the builder uses the release target for the current +host platform. On Linux, that default is a musl target to match Codex release +artifacts; pass a GNU Linux target explicitly for native glibc local builds. If +`--package-dir` is omitted, the builder creates a new temporary directory and +prints its path after the package is built. + ## Source-built artifacts Artifacts built from this repository are always built by the package builder in @@ -32,7 +38,8 @@ one grouped `cargo build` command per package: - Windows targets: `codex-command-runner` and `codex-windows-sandbox-setup` The default cargo profile is `dev-small` because local iteration should favor -fast, small builds. Release jobs should pass `--cargo-profile release`. +fast, small builds. Release jobs should pass `--cargo-profile release` and an +explicit target. `rg` is not built from this repository, so the builder fetches it from the DotSlash manifest at `codex-cli/bin/rg`. Downloaded archives are cached under diff --git a/scripts/codex_package/cli.py b/scripts/codex_package/cli.py index c80f50a1a0..6b2598445f 100644 --- a/scripts/codex_package/cli.py +++ b/scripts/codex_package/cli.py @@ -1,6 +1,7 @@ """Command-line interface for building Codex package directories.""" import argparse +import tempfile from pathlib import Path from .archive import write_archive @@ -11,6 +12,7 @@ from .layout import validate_package_dir from .ripgrep import resolve_rg_bin from .targets import TARGET_SPECS from .targets import PackageInputs +from .targets import default_target def parse_args() -> argparse.Namespace: @@ -20,9 +22,12 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument( "--target", - required=True, + default=argparse.SUPPRESS, choices=sorted(TARGET_SPECS), - help="Rust target triple for the package.", + help=( + "Rust target triple for the package. Defaults to the release target " + "for this host platform." + ), ) parser.add_argument( "--version", @@ -37,8 +42,11 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--package-dir", type=Path, - required=True, - help="Output directory to create as the package root.", + default=argparse.SUPPRESS, + help=( + "Output directory to create as the package root. Defaults to a new " + "temporary directory." + ), ) parser.add_argument( "--archive-output", @@ -79,8 +87,13 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() - spec = TARGET_SPECS[args.target] - package_dir = args.package_dir.resolve() + spec = TARGET_SPECS[getattr(args, "target", None) or default_target()] + package_dir_arg = getattr(args, "package_dir", None) + package_dir = ( + package_dir_arg.resolve() + if package_dir_arg is not None + else Path(tempfile.mkdtemp(prefix="codex-package-")).resolve() + ) source_outputs = build_source_binaries( spec, diff --git a/scripts/codex_package/targets.py b/scripts/codex_package/targets.py index 0cb964a4cc..d4f85953c2 100644 --- a/scripts/codex_package/targets.py +++ b/scripts/codex_package/targets.py @@ -1,5 +1,6 @@ """Supported package targets and default binary discovery.""" +import platform import stat from dataclasses import dataclass from pathlib import Path @@ -39,12 +40,24 @@ class PackageInputs: TARGET_SPECS: dict[str, TargetSpec] = { + "x86_64-unknown-linux-gnu": TargetSpec( + target="x86_64-unknown-linux-gnu", + is_windows=False, + is_linux=True, + dotslash_platform="linux-x86_64", + ), "x86_64-unknown-linux-musl": TargetSpec( target="x86_64-unknown-linux-musl", is_windows=False, is_linux=True, dotslash_platform="linux-x86_64", ), + "aarch64-unknown-linux-gnu": TargetSpec( + target="aarch64-unknown-linux-gnu", + is_windows=False, + is_linux=True, + dotslash_platform="linux-aarch64", + ), "aarch64-unknown-linux-musl": TargetSpec( target="aarch64-unknown-linux-musl", is_windows=False, @@ -78,6 +91,29 @@ TARGET_SPECS: dict[str, TargetSpec] = { } +HOST_RELEASE_TARGETS: dict[tuple[str, str], str] = { + ("darwin", "aarch64"): "aarch64-apple-darwin", + ("darwin", "x86_64"): "x86_64-apple-darwin", + ("linux", "aarch64"): "aarch64-unknown-linux-musl", + ("linux", "x86_64"): "x86_64-unknown-linux-musl", + ("windows", "aarch64"): "aarch64-pc-windows-msvc", + ("windows", "x86_64"): "x86_64-pc-windows-msvc", +} + + +def default_target() -> str: + system = platform.system().lower() + machine = normalize_machine(platform.machine()) + target = HOST_RELEASE_TARGETS.get((system, machine)) + if target is None: + supported = ", ".join(sorted(TARGET_SPECS)) + raise RuntimeError( + f"Unsupported host platform {platform.system()}/{platform.machine()}. " + f"Pass --target explicitly. Supported targets: {supported}" + ) + return target + + def resolve_input_path( explicit_path: Path | None, description: str, @@ -96,3 +132,12 @@ def resolve_input_path( def is_executable(path: Path) -> bool: return bool(path.stat().st_mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)) + + +def normalize_machine(machine: str) -> str: + machine = machine.lower() + if machine in ("amd64", "x86_64"): + return "x86_64" + if machine in ("aarch64", "arm64"): + return "aarch64" + return machine From 64ef6cd1e4d17c9c2aaa424e65d90011c64fd1c2 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Tue, 19 May 2026 17:54:41 -0700 Subject: [PATCH 14/63] Fan out rust-ci-full nextest by platform (#23358) ## Why `rust-ci-full` was paying the full Cargo nextest build-and-run cost once per platform, with Windows ARM64 as the long pole. This change moves the heavy work into one reusable per-platform flow: build a nextest archive once, then replay it across four shards so the platform lane spends less time running tests serially. For Windows ARM64, the archive is cross-compiled on Windows x64 and replayed on native Windows ARM64 shards so the slow ARM64 machine is used for execution rather than compilation. ## What changed - split the `rust-ci-full` nextest matrix into five explicit per-platform reusable-workflow calls - add `.github/workflows/rust-ci-full-nextest-platform.yml` to build one archive, upload timings/helpers, replay four nextest shards, upload per-shard JUnit, and roll the shard status back up per platform - add Windows CI helpers for Dev Drive setup and MSVC ARM64 linker environment export so the Windows ARM64 archive can be produced on Windows x64 - keep the existing Cargo git CLI fetch hardening inside the reusable workflow, since caller workflow-level `env` does not flow through `workflow_call` - document the archive-backed shard shape in `.github/workflows/README.md` - raise the default nextest slow timeout to 30s so the sharded full-CI path does not treat every >15s test as stuck ## Verification - validated the archive/shard flow with live GitHub Actions runs on this PR branch - Windows ARM64 cross-compile latency on completed runs: - https://github.com/openai/codex/actions/runs/26118759651: `34m30s` lane e2e, `17m16s` archive build, `9m55s` shard phase - https://github.com/openai/codex/actions/runs/26120777976: `30m36s` lane e2e, `17m21s` archive build, `6m50s` shard phase - comparable pre-cross-compile sharded Windows ARM64 runs were `55m01s`, `50m21s`, and `46m42s`, so the completed cross-compile runs improved the lane by roughly `12m` to `24m` versus the prior range - latest corrected cross-compile run: https://github.com/openai/codex/actions/runs/26120777976 - Windows ARM64 archive built successfully on Windows x64 - native Windows ARM64 shards started immediately after the archive upload - 3/4 Windows ARM64 shards passed; the failing shard hit the same existing `code_mode` test failure seen outside this lane - downloaded failed-shard JUnit XML from the validation runs and confirmed the remaining red is from known test failures, not archive/shard wiring - no local Codex tests run per repo guidance ## Notes - this PR does not change developers.openai.com documentation --- .github/actions/setup-msvc-env/action.yml | 17 + .../actions/setup-msvc-env/setup-msvc-env.ps1 | 257 ++++++++++ .github/scripts/setup-dev-drive.ps1 | 62 +++ .github/workflows/README.md | 3 +- .../rust-ci-full-nextest-platform.yml | 464 ++++++++++++++++++ .github/workflows/rust-ci-full.yml | 313 +++--------- codex-rs/.config/nextest.toml | 4 +- 7 files changed, 883 insertions(+), 237 deletions(-) create mode 100644 .github/actions/setup-msvc-env/action.yml create mode 100644 .github/actions/setup-msvc-env/setup-msvc-env.ps1 create mode 100644 .github/scripts/setup-dev-drive.ps1 create mode 100644 .github/workflows/rust-ci-full-nextest-platform.yml diff --git a/.github/actions/setup-msvc-env/action.yml b/.github/actions/setup-msvc-env/action.yml new file mode 100644 index 0000000000..287cb7f217 --- /dev/null +++ b/.github/actions/setup-msvc-env/action.yml @@ -0,0 +1,17 @@ +name: setup-msvc-env +description: Expose an MSVC developer environment for the requested Windows target. +inputs: + target: + description: Rust target triple that will be built on this Windows runner. + required: true + host-arch: + description: Optional Visual Studio host architecture override. + required: false + default: "" + +runs: + using: composite + steps: + - name: Expose MSVC SDK environment + shell: pwsh + run: '& "$env:GITHUB_ACTION_PATH/setup-msvc-env.ps1" -Target "${{ inputs.target }}" -HostArch "${{ inputs.host-arch }}"' diff --git a/.github/actions/setup-msvc-env/setup-msvc-env.ps1 b/.github/actions/setup-msvc-env/setup-msvc-env.ps1 new file mode 100644 index 0000000000..e2706d9117 --- /dev/null +++ b/.github/actions/setup-msvc-env/setup-msvc-env.ps1 @@ -0,0 +1,257 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Target, + + [string]$HostArch = "" +) + +# Cargo can cross-compile the Rust code for Windows ARM64 on a Windows x64 +# runner, but rustup alone does not expose the matching MSVC/UCRT include and +# library paths. Ask Visual Studio for the target-specific developer +# environment, then persist the relevant variables through GITHUB_ENV so the +# later Cargo step sees the same environment as a normal VsDevCmd shell. +switch ($Target) { + "x86_64-pc-windows-msvc" { + $TargetArch = "x64" + $RequiredComponent = "Microsoft.VisualStudio.Component.VC.Tools.x86.x64" + } + "aarch64-pc-windows-msvc" { + $TargetArch = "arm64" + $RequiredComponent = "Microsoft.VisualStudio.Component.VC.Tools.ARM64" + } + default { + throw "Unsupported Windows MSVC target: $Target" + } +} + +# VsDevCmd needs both sides of the cross compile: the architecture of the +# machine running the tools and the architecture of the binaries being linked. +# Infer the host from the runner unless a caller needs to override it. +if (-not $HostArch) { + $HostArch = if ($env:PROCESSOR_ARCHITEW6432 -eq "ARM64" -or $env:PROCESSOR_ARCHITECTURE -eq "ARM64") { + "arm64" + } else { + "x64" + } +} + +$VsWhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +if (-not (Test-Path $VsWhere)) { + throw "vswhere.exe not found" +} + +# Require the target VC tools component, not merely any Visual Studio install, +# so an x64 archive producer cannot silently link ARM64 tests with the wrong +# SDK/toolchain layout. +$InstallPath = & $VsWhere -latest -products * -requires $RequiredComponent -property installationPath 2>$null +if (-not $InstallPath) { + throw "Could not locate a Visual Studio installation with component $RequiredComponent" +} + +$VsDevCmd = Join-Path $InstallPath "Common7\Tools\VsDevCmd.bat" +if (-not (Test-Path $VsDevCmd)) { + throw "VsDevCmd.bat not found at $VsDevCmd" +} + +$VarsToExport = @( + "INCLUDE", + "LIB", + "LIBPATH", + "PATH", + "UCRTVersion", + "UniversalCRTSdkDir", + "VCINSTALLDIR", + "VCToolsInstallDir", + "WindowsLibPath", + "WindowsSdkBinPath", + "WindowsSdkDir", + "WindowsSDKLibVersion", + "WindowsSDKVersion" +) + +# Run VsDevCmd inside cmd.exe because it is a batch file, then copy just the +# variables Cargo/rustc need into the GitHub Actions environment file. PowerShell +# cannot mutate the parent composite-action environment directly. +$EnvLines = & cmd.exe /c ('"{0}" -no_logo -arch={1} -host_arch={2} >nul && set' -f $VsDevCmd, $TargetArch, $HostArch) +$VcToolsInstallDir = $null +foreach ($Line in $EnvLines) { + if ($Line -notmatch "^(.*?)=(.*)$") { + continue + } + + $Name = $Matches[1] + $Value = $Matches[2] + if ($VarsToExport -contains $Name) { + if ($Name -ieq "Path") { + $Name = "PATH" + } + if ($Name -eq "VCToolsInstallDir") { + $VcToolsInstallDir = $Value + } + "$Name=$Value" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + } +} + +if (-not $VcToolsInstallDir) { + throw "VCToolsInstallDir was not exported by VsDevCmd.bat" +} + +# Prefer Rust's bundled linker when rustup provides one, then Visual Studio's +# LLVM linker, and finally MSVC link.exe. This keeps the cross-compile path close +# to Rust's normal Windows MSVC behavior while still working on runner images +# where one of those linkers is absent. +$Linker = $null +$Rustc = Get-Command rustc -ErrorAction SilentlyContinue +if ($Rustc) { + $Sysroot = (& rustc --print sysroot 2>$null).Trim() + $RustHost = & rustc -vV 2>$null | Select-String "^host: " | ForEach-Object { $_.Line.Substring(6) } + if ($RustHost) { + $RustHost = $RustHost.Trim() + } + if ($Sysroot -and $RustHost) { + $RustLld = Join-Path $Sysroot "lib\rustlib\$RustHost\bin\rust-lld.exe" + if (Test-Path $RustLld) { + $Linker = $RustLld + } + } +} +if (-not $Linker) { + $Linker = Join-Path $InstallPath "VC\Tools\Llvm\x64\bin\lld-link.exe" +} +if (-not (Test-Path $Linker)) { + $Linker = Join-Path $VcToolsInstallDir "bin\Host${HostArch}\${TargetArch}\link.exe" +} +if (-not (Test-Path $Linker)) { + throw "Windows linker not found at $Linker" +} + +# rustc passes `/arm64hazardfree` for ARM64 MSVC links. The lld variants on our +# Windows x64 archive producers reject that flag, including when rustc places it +# inside a response file. Compile a tiny forwarding wrapper that strips only +# that unsupported flag, then delegate every other argument to the real linker. +if ($TargetArch -eq "arm64" -and (Split-Path -Leaf $Linker) -match "lld") { + $WrapperDir = Join-Path $env:RUNNER_TEMP "msvc-lld-wrapper" + New-Item -Path $WrapperDir -ItemType Directory -Force | Out-Null + $WrapperPath = Join-Path $WrapperDir "lld-link-wrapper.exe" + $WrapperSource = @' +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; + +internal static class Program +{ + private static int Main(string[] args) + { + var linker = Environment.GetEnvironmentVariable("MSVC_REAL_LINKER"); + if (string.IsNullOrEmpty(linker)) + { + Console.Error.WriteLine("MSVC_REAL_LINKER is not set"); + return 1; + } + + var startInfo = new ProcessStartInfo(linker) + { + UseShellExecute = false, + }; + var filteredArgs = new List { "-flavor", "link", "/defaultlib:ucrt", "/nodefaultlib:libucrt" }; + foreach (var arg in args) + { + if (!string.Equals(arg, "/arm64hazardfree", StringComparison.OrdinalIgnoreCase)) + { + filteredArgs.Add(QuoteArgument(FilterResponseFile(arg))); + } + } + startInfo.Arguments = string.Join(" ", filteredArgs); + + using var process = Process.Start(startInfo); + if (process is null) + { + Console.Error.WriteLine($"Failed to start linker: {linker}"); + return 1; + } + + process.WaitForExit(); + return process.ExitCode; + } + + private static string FilterResponseFile(string argument) + { + if (argument.Length < 2 || argument[0] != '@') + { + return argument; + } + + var responsePath = argument.Substring(1); + if (!File.Exists(responsePath)) + { + return argument; + } + + var filteredResponsePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".rsp"); + var responseContents = Regex.Replace( + File.ReadAllText(responsePath), + "/arm64hazardfree", + string.Empty, + RegexOptions.IgnoreCase); + File.WriteAllText(filteredResponsePath, responseContents); + return "@" + filteredResponsePath; + } + + private static string QuoteArgument(string argument) + { + if (argument.Length == 0) + { + return "\"\""; + } + if (argument.IndexOfAny(new[] { ' ', '\t', '"' }) < 0) + { + return argument; + } + + var quoted = new StringBuilder("\""); + var backslashes = 0; + foreach (var character in argument) + { + if (character == '\\') + { + backslashes++; + continue; + } + if (character == '"') + { + quoted.Append('\\', (backslashes * 2) + 1); + quoted.Append(character); + backslashes = 0; + continue; + } + + quoted.Append('\\', backslashes); + backslashes = 0; + quoted.Append(character); + } + quoted.Append('\\', backslashes * 2); + quoted.Append('"'); + return quoted.ToString(); + } +} +'@ + $WrapperSourcePath = Join-Path $WrapperDir "lld-link-wrapper.cs" + $WrapperSource | Out-File -FilePath $WrapperSourcePath -Encoding utf8 + $Csc = Join-Path $InstallPath "MSBuild\Current\Bin\Roslyn\csc.exe" + if (-not (Test-Path $Csc)) { + throw "csc.exe not found at $Csc" + } + & $Csc /nologo /target:exe /out:$WrapperPath $WrapperSourcePath + if ($LASTEXITCODE -ne 0) { + throw "Failed to compile lld-link wrapper" + } + "MSVC_REAL_LINKER=$Linker" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + $Linker = $WrapperPath +} + +Write-Output "Using Windows linker: $Linker" +$CargoTarget = $Target.ToUpperInvariant().Replace("-", "_") +"CARGO_TARGET_${CargoTarget}_LINKER=$Linker" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append diff --git a/.github/scripts/setup-dev-drive.ps1 b/.github/scripts/setup-dev-drive.ps1 new file mode 100644 index 0000000000..2b94e1b66f --- /dev/null +++ b/.github/scripts/setup-dev-drive.ps1 @@ -0,0 +1,62 @@ +# Configure a fast drive for Windows CI jobs. +# +# GitHub-hosted Windows runners do not always expose a secondary D: volume. When +# they do not, try to create a Dev Drive VHD and fall back to C: if the runner +# image does not allow that provisioning path. + +function Use-FallbackDrive { + param([string]$Reason) + + Write-Warning "$Reason Falling back to C:" + return "C:" +} + +function Invoke-BestEffort { + param([scriptblock]$Script, [string]$Description) + + try { + & $Script + } catch { + Write-Warning "$Description failed: $($_.Exception.Message)" + } +} + +if (Test-Path "D:\") { + Write-Output "Using existing drive at D:" + $Drive = "D:" +} else { + try { + $VhdPath = Join-Path $env:RUNNER_TEMP "codex-dev-drive.vhdx" + $SizeBytes = 64GB + + if (Test-Path $VhdPath) { + Remove-Item -Path $VhdPath -Force + } + + New-VHD -Path $VhdPath -SizeBytes $SizeBytes -Dynamic -ErrorAction Stop | Out-Null + $Mounted = Mount-VHD -Path $VhdPath -Passthru -ErrorAction Stop + $Disk = $Mounted | Get-Disk -ErrorAction Stop + $Disk | Initialize-Disk -PartitionStyle GPT -ErrorAction Stop + $Partition = $Disk | New-Partition -AssignDriveLetter -UseMaximumSize -ErrorAction Stop + $Volume = $Partition | Format-Volume -FileSystem ReFS -NewFileSystemLabel "CodexDevDrive" -DevDrive -Confirm:$false -Force -ErrorAction Stop + + $Drive = "$($Volume.DriveLetter):" + + Invoke-BestEffort { fsutil devdrv trust $Drive } "Trusting Dev Drive $Drive" + Invoke-BestEffort { fsutil devdrv enable /disallowAv } "Disabling AV filter attachment for Dev Drives" + Invoke-BestEffort { fsutil devdrv query $Drive } "Querying Dev Drive $Drive" + + Write-Output "Using Dev Drive at $Drive" + } catch { + $Drive = Use-FallbackDrive "Failed to create Dev Drive: $($_.Exception.Message)" + } +} + +$Tmp = "$Drive\codex-tmp" +New-Item -Path $Tmp -ItemType Directory -Force | Out-Null + +@( + "DEV_DRIVE=$Drive" + "TMP=$Tmp" + "TEMP=$Tmp" +) | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append diff --git a/.github/workflows/README.md b/.github/workflows/README.md index d14817f002..b2403b749c 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -21,7 +21,8 @@ The workflows in this directory are split so that pull requests get fast, review - `rust-ci-full.yml` is the full Cargo-native verification workflow. It keeps the heavier checks off the PR path while still validating them after merge: - the full Cargo `clippy` matrix - - the full Cargo `nextest` matrix + - the full Cargo `nextest` matrix via per-platform archive-backed shards + - Windows ARM64 nextest archives cross-compiled on Windows x64, then replayed on native Windows ARM64 shards - release-profile Cargo builds - cross-platform `argument-comment-lint` - Linux remote-env tests diff --git a/.github/workflows/rust-ci-full-nextest-platform.yml b/.github/workflows/rust-ci-full-nextest-platform.yml new file mode 100644 index 0000000000..7dc39d33ed --- /dev/null +++ b/.github/workflows/rust-ci-full-nextest-platform.yml @@ -0,0 +1,464 @@ +name: rust-ci-full nextest platform + +on: + workflow_call: + inputs: + runner: + required: true + type: string + runner_group: + required: false + default: "" + type: string + runner_labels: + required: false + default: "" + type: string + archive_runner: + required: false + default: "" + type: string + archive_runner_group: + required: false + default: "" + type: string + archive_runner_labels: + required: false + default: "" + type: string + target: + required: true + type: string + profile: + required: true + type: string + artifact_id: + required: true + type: string + remote_env: + required: false + default: false + type: boolean + test_threads: + required: false + default: 0 + type: number + use_sccache: + required: false + default: false + type: boolean + +# Caller workflow-level env does not flow through workflow_call, so keep the +# Cargo git transport hardening on the archive and shard jobs directly here. +env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + +jobs: + archive: + name: Build nextest archive + runs-on: ${{ inputs.archive_runner_group != '' && fromJSON(format('{{"group":"{0}","labels":"{1}"}}', inputs.archive_runner_group, inputs.archive_runner_labels)) || inputs.archive_runner != '' && inputs.archive_runner || inputs.runner_group != '' && fromJSON(format('{{"group":"{0}","labels":"{1}"}}', inputs.runner_group, inputs.runner_labels)) || inputs.runner }} + timeout-minutes: 60 + defaults: + run: + working-directory: codex-rs + env: + # Windows ARM64 archives are built on Windows x64, while their shards run + # on native Windows ARM64. Key producer-side caches by the archive runner + # so the cross-compile build reuses the Windows x64 cache lineage. + ARCHIVE_CACHE_RUNNER: ${{ inputs.archive_runner != '' && inputs.archive_runner || inputs.runner }} + USE_SCCACHE: ${{ inputs.use_sccache && 'true' || 'false' }} + CARGO_INCREMENTAL: "0" + SCCACHE_CACHE_SIZE: 10G + NEXTEST_ARCHIVE_FILE: nextest-${{ inputs.artifact_id }}.tar.zst + TEST_HELPERS_ARTIFACT: nextest-test-helpers-${{ inputs.artifact_id }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Configure Dev Drive (Windows) + if: ${{ runner.os == 'Windows' }} + shell: pwsh + run: ../.github/scripts/setup-dev-drive.ps1 + + - name: Install Linux build dependencies + if: ${{ runner.os == 'Linux' }} + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev bubblewrap + fi + + - name: Install DotSlash + uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 + + - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + with: + targets: ${{ inputs.target }} + + - name: Expose MSVC SDK environment (Windows) + if: ${{ runner.os == 'Windows' && inputs.target == 'aarch64-pc-windows-msvc' }} + uses: ./.github/actions/setup-msvc-env + with: + target: ${{ inputs.target }} + + - name: Compute lockfile hash + id: lockhash + shell: bash + run: | + set -euo pipefail + echo "hash=$(sha256sum Cargo.lock | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + echo "toolchain_hash=$(sha256sum rust-toolchain.toml | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + + - name: Restore cargo home cache + id: cache_cargo_home_restore + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: cargo-home-${{ env.ARCHIVE_CACHE_RUNNER }}-${{ inputs.target }}-${{ inputs.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }} + restore-keys: | + cargo-home-${{ env.ARCHIVE_CACHE_RUNNER }}-${{ inputs.target }}-${{ inputs.profile }}- + + - name: Install sccache + if: ${{ env.USE_SCCACHE == 'true' }} + uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 + with: + tool: sccache + version: 0.7.5 + + - name: Configure sccache backend + if: ${{ env.USE_SCCACHE == 'true' }} + shell: bash + run: | + set -euo pipefail + if [[ -n "${ACTIONS_CACHE_URL:-}" && -n "${ACTIONS_RUNTIME_TOKEN:-}" ]]; then + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "Using sccache GitHub backend" + else + echo "SCCACHE_GHA_ENABLED=false" >> "$GITHUB_ENV" + if [[ -n "${DEV_DRIVE:-}" ]]; then + echo "SCCACHE_DIR=${DEV_DRIVE}\\.sccache" >> "$GITHUB_ENV" + else + echo "SCCACHE_DIR=${{ github.workspace }}/.sccache" >> "$GITHUB_ENV" + fi + echo "Using sccache local disk + actions/cache fallback" + fi + + - name: Enable sccache wrapper + if: ${{ env.USE_SCCACHE == 'true' }} + shell: bash + run: | + set -euo pipefail + wrapper="$(command -v sccache)" + if [[ "${RUNNER_OS}" == "Windows" ]] && command -v cygpath >/dev/null 2>&1; then + wrapper="$(cygpath -w "${wrapper}")" + fi + echo "RUSTC_WRAPPER=${wrapper}" >> "$GITHUB_ENV" + echo "CARGO_BUILD_RUSTC_WRAPPER=${wrapper}" >> "$GITHUB_ENV" + + - name: Restore sccache cache (fallback) + if: ${{ env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' }} + id: cache_sccache_restore + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ${{ env.SCCACHE_DIR }} + key: sccache-${{ env.ARCHIVE_CACHE_RUNNER }}-${{ inputs.target }}-${{ inputs.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} + restore-keys: | + sccache-${{ env.ARCHIVE_CACHE_RUNNER }}-${{ inputs.target }}-${{ inputs.profile }}-${{ steps.lockhash.outputs.hash }}- + sccache-${{ env.ARCHIVE_CACHE_RUNNER }}-${{ inputs.target }}-${{ inputs.profile }}- + + - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 + with: + tool: nextest + version: 0.9.103 + + - name: Enable unprivileged user namespaces (Linux) + if: runner.os == 'Linux' + run: | + sudo sysctl -w kernel.unprivileged_userns_clone=1 + if sudo sysctl -a 2>/dev/null | grep -q '^kernel.apparmor_restrict_unprivileged_userns'; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + + - name: Build nextest archive + shell: bash + run: | + set -euo pipefail + archive_dir="${RUNNER_TEMP}/nextest-archive" + mkdir -p "${archive_dir}" + cargo nextest archive \ + --target ${{ inputs.target }} \ + --cargo-profile ${{ inputs.profile }} \ + --timings \ + --archive-file "${archive_dir}/${NEXTEST_ARCHIVE_FILE}" + + - name: Build runtime test helpers + if: ${{ runner.os == 'Linux' || runner.os == 'Windows' }} + shell: bash + run: | + set -euo pipefail + helper_dir="${RUNNER_TEMP}/${TEST_HELPERS_ARTIFACT}" + mkdir -p "${helper_dir}" + + if [[ "${RUNNER_OS}" == "Linux" ]]; then + cargo build \ + --target ${{ inputs.target }} \ + --profile ${{ inputs.profile }} \ + -p codex-linux-sandbox \ + --bin codex-linux-sandbox + cp "target/${{ inputs.target }}/${{ inputs.profile }}/codex-linux-sandbox" "${helper_dir}/" + else + cargo build \ + --target ${{ inputs.target }} \ + --profile ${{ inputs.profile }} \ + -p codex-windows-sandbox \ + --bin codex-windows-sandbox-setup \ + --bin codex-command-runner + cp "target/${{ inputs.target }}/${{ inputs.profile }}/codex-windows-sandbox-setup.exe" "${helper_dir}/" + cp "target/${{ inputs.target }}/${{ inputs.profile }}/codex-command-runner.exe" "${helper_dir}/" + fi + + - name: Upload Cargo timings (nextest) + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: cargo-timings-rust-ci-nextest-${{ inputs.target }}-${{ inputs.profile }} + path: codex-rs/target/**/cargo-timings/cargo-timing.html + if-no-files-found: warn + + - name: Upload nextest archive + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: nextest-archive-${{ inputs.artifact_id }} + path: ${{ runner.temp }}/nextest-archive/${{ env.NEXTEST_ARCHIVE_FILE }} + if-no-files-found: error + retention-days: 1 + + - name: Upload runtime test helpers + if: ${{ runner.os == 'Linux' || runner.os == 'Windows' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ env.TEST_HELPERS_ARTIFACT }} + path: ${{ runner.temp }}/${{ env.TEST_HELPERS_ARTIFACT }}/* + if-no-files-found: error + retention-days: 1 + + - name: Save cargo home cache + if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: cargo-home-${{ env.ARCHIVE_CACHE_RUNNER }}-${{ inputs.target }}-${{ inputs.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }} + + - name: Save sccache cache (fallback) + if: always() && !cancelled() && env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' + continue-on-error: true + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ${{ env.SCCACHE_DIR }} + key: sccache-${{ env.ARCHIVE_CACHE_RUNNER }}-${{ inputs.target }}-${{ inputs.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} + + - name: sccache stats + if: always() && env.USE_SCCACHE == 'true' + continue-on-error: true + run: sccache --show-stats || true + + - name: sccache summary + if: always() && env.USE_SCCACHE == 'true' + shell: bash + run: | + { + echo "### sccache stats — ${{ inputs.target }} (tests)"; + echo; + echo '```'; + sccache --show-stats || true; + echo '```'; + } >> "$GITHUB_STEP_SUMMARY" + + shard: + name: Tests shard ${{ matrix.shard }}/4 + needs: archive + runs-on: ${{ inputs.runner_group != '' && fromJSON(format('{{"group":"{0}","labels":"{1}"}}', inputs.runner_group, inputs.runner_labels)) || inputs.runner }} + timeout-minutes: 60 + defaults: + run: + working-directory: codex-rs + env: + NEXTEST_ARCHIVE_FILE: nextest-${{ inputs.artifact_id }}.tar.zst + TEST_HELPERS_ARTIFACT: nextest-test-helpers-${{ inputs.artifact_id }} + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install Linux build dependencies + if: ${{ runner.os == 'Linux' }} + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev bubblewrap + fi + + - name: Install DotSlash + uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 + + - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + with: + targets: ${{ inputs.target }} + + - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 + with: + tool: nextest + version: 0.9.103 + + - name: Enable unprivileged user namespaces (Linux) + if: runner.os == 'Linux' + run: | + sudo sysctl -w kernel.unprivileged_userns_clone=1 + if sudo sysctl -a 2>/dev/null | grep -q '^kernel.apparmor_restrict_unprivileged_userns'; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + + - name: Set up remote test env (Docker) + if: ${{ runner.os == 'Linux' && inputs.remote_env }} + shell: bash + run: | + set -euo pipefail + export CODEX_TEST_REMOTE_ENV_CONTAINER_NAME="codex-remote-test-env-${{ github.run_id }}-${{ matrix.shard }}" + source "${GITHUB_WORKSPACE}/scripts/test-remote-env.sh" + echo "CODEX_TEST_REMOTE_ENV=${CODEX_TEST_REMOTE_ENV}" >> "$GITHUB_ENV" + echo "CODEX_TEST_REMOTE_EXEC_SERVER_URL=${CODEX_TEST_REMOTE_EXEC_SERVER_URL}" >> "$GITHUB_ENV" + + - name: Download nextest archive + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: nextest-archive-${{ inputs.artifact_id }} + path: ${{ runner.temp }}/nextest-archive + + - name: Download runtime test helpers + if: ${{ runner.os == 'Linux' || runner.os == 'Windows' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ env.TEST_HELPERS_ARTIFACT }} + path: ${{ runner.temp }}/${{ env.TEST_HELPERS_ARTIFACT }} + + - name: tests + id: test + shell: bash + run: | + set -euo pipefail + archive_file="${RUNNER_TEMP}/nextest-archive/${NEXTEST_ARCHIVE_FILE}" + workspace_root="$(pwd)" + + if [[ "${RUNNER_OS}" == "Windows" ]]; then + archive_file="$(cygpath -w "${archive_file}")" + workspace_root="$(cygpath -w "${workspace_root}")" + fi + + if [[ "${RUNNER_OS}" == "Linux" ]]; then + helper_dir="${RUNNER_TEMP}/${TEST_HELPERS_ARTIFACT}" + helper_target_dir="$(pwd)/target/${{ inputs.target }}/${{ inputs.profile }}" + mkdir -p "${helper_target_dir}" + cp "${helper_dir}/codex-linux-sandbox" "${helper_target_dir}/" + chmod +x "${helper_target_dir}/codex-linux-sandbox" + elif [[ "${RUNNER_OS}" == "Windows" ]]; then + helper_dir="${RUNNER_TEMP}/${TEST_HELPERS_ARTIFACT}" + helper_target_dir="$(pwd)/target/${{ inputs.target }}/${{ inputs.profile }}" + mkdir -p "${helper_target_dir}" + cp "${helper_dir}/codex-windows-sandbox-setup.exe" "${helper_target_dir}/" + cp "${helper_dir}/codex-command-runner.exe" "${helper_target_dir}/" + fi + + nextest_args=( + run + --no-fail-fast + --archive-file "${archive_file}" + --workspace-remap "${workspace_root}" + --partition "hash:${{ matrix.shard }}/4" + ) + if [[ "${{ inputs.test_threads }}" != "0" ]]; then + nextest_args+=(--test-threads "${{ inputs.test_threads }}") + fi + + test_command=(cargo nextest "${nextest_args[@]}") + if [[ "${RUNNER_OS}" == "Linux" ]]; then + sandbox_helper="${helper_target_dir}/codex-linux-sandbox" + test_command=( + env + "CARGO_BIN_EXE_codex-linux-sandbox=${sandbox_helper}" + "CARGO_BIN_EXE_codex_linux_sandbox=${sandbox_helper}" + cargo nextest "${nextest_args[@]}" + ) + elif [[ "${RUNNER_OS}" == "Windows" ]]; then + setup_helper="$(cygpath -w "${helper_target_dir}/codex-windows-sandbox-setup.exe")" + command_runner="$(cygpath -w "${helper_target_dir}/codex-command-runner.exe")" + test_command=( + env + "CARGO_BIN_EXE_codex_windows_sandbox_setup=${setup_helper}" + "CARGO_BIN_EXE_codex_command_runner=${command_runner}" + cargo nextest "${nextest_args[@]}" + ) + fi + + "${test_command[@]}" + env: + RUST_BACKTRACE: 1 + RUST_MIN_STACK: "8388608" # 8 MiB + NEXTEST_STATUS_LEVEL: leak + + - name: Upload nextest JUnit report + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: nextest-junit-rust-ci-${{ inputs.artifact_id }}-shard-${{ matrix.shard }} + path: codex-rs/target/nextest/default/junit.xml + if-no-files-found: warn + + - name: Tear down remote test env + if: ${{ always() && runner.os == 'Linux' && inputs.remote_env }} + shell: bash + run: | + set +e + if [[ "${STEPS_TEST_OUTCOME}" != "success" ]]; then + docker logs "${CODEX_TEST_REMOTE_ENV}" || true + fi + docker rm -f "${CODEX_TEST_REMOTE_ENV}" >/dev/null 2>&1 || true + env: + STEPS_TEST_OUTCOME: ${{ steps.test.outcome }} + + - name: verify tests passed + if: steps.test.outcome == 'failure' + run: | + echo "Tests failed. See logs for details." + exit 1 + + result: + name: Platform result + needs: shard + if: always() + runs-on: ubuntu-24.04 + steps: + - name: Confirm test shards passed + shell: bash + run: | + if [[ "${{ needs.shard.result }}" != "success" ]]; then + echo "Nextest shards finished with result: ${{ needs.shard.result }}" >&2 + exit 1 + fi diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index b3c25b3f03..08e0709e17 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -521,242 +521,73 @@ jobs: /var/cache/apt key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1 - tests: - name: Tests — ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.remote_env == 'true' && ' (remote)' || '' }} - runs-on: ${{ matrix.runs_on || matrix.runner }} - # Windows ARM64 is the long pole here, and nextest retries plus targeted - # Windows timeout headroom need more than 45m to finish reliably. - timeout-minutes: 60 - defaults: - run: - working-directory: codex-rs - env: - # Speed up repeated builds across CI runs by caching compiled objects, except on - # arm64 macOS runners cross-targeting x86_64 where ring/cc-rs can produce - # mixed-architecture archives under sccache. - USE_SCCACHE: ${{ (startsWith(matrix.runner, 'windows') || (matrix.runner == 'macos-15-xlarge' && matrix.target == 'x86_64-apple-darwin')) && 'false' || 'true' }} - CARGO_INCREMENTAL: "0" - SCCACHE_CACHE_SIZE: 10G + tests_macos_aarch64: + name: Tests — macos-15-xlarge - aarch64-apple-darwin + uses: ./.github/workflows/rust-ci-full-nextest-platform.yml + with: + runner: macos-15-xlarge + target: aarch64-apple-darwin + profile: ci-test + artifact_id: macos-aarch64 + use_sccache: true + secrets: inherit - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - profile: dev - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-gnu - profile: dev - remote_env: "true" - runs_on: - group: codex-runners - labels: codex-linux-x64 - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-gnu - profile: dev - runs_on: - group: codex-runners - labels: codex-linux-arm64 - - runner: windows-x64 - target: x86_64-pc-windows-msvc - profile: dev - runs_on: - group: codex-runners - labels: codex-windows-x64 - - runner: windows-arm64 - target: aarch64-pc-windows-msvc - profile: dev - runs_on: - group: codex-runners - labels: codex-windows-arm64 + tests_linux_x64_remote: + name: Tests — ubuntu-24.04 - x86_64-unknown-linux-gnu (remote) + uses: ./.github/workflows/rust-ci-full-nextest-platform.yml + with: + runner: ubuntu-24.04 + runner_group: codex-runners + runner_labels: codex-linux-x64 + target: x86_64-unknown-linux-gnu + profile: ci-test + artifact_id: linux-x64-remote + remote_env: true + use_sccache: true + secrets: inherit - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Install Linux build dependencies - if: ${{ runner.os == 'Linux' }} - shell: bash - run: | - set -euo pipefail - if command -v apt-get >/dev/null 2>&1; then - sudo apt-get update -y - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev bubblewrap - fi + tests_linux_arm64: + name: Tests — ubuntu-24.04-arm - aarch64-unknown-linux-gnu + uses: ./.github/workflows/rust-ci-full-nextest-platform.yml + with: + runner: ubuntu-24.04-arm + runner_group: codex-runners + runner_labels: codex-linux-arm64 + target: aarch64-unknown-linux-gnu + profile: ci-test + artifact_id: linux-arm64 + use_sccache: true + secrets: inherit - # Some integration tests rely on DotSlash being installed. - # See https://github.com/openai/codex/pull/7617. - - name: Install DotSlash - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 + tests_windows_x64: + name: Tests — windows-x64 - x86_64-pc-windows-msvc + uses: ./.github/workflows/rust-ci-full-nextest-platform.yml + with: + runner: windows-x64 + runner_group: codex-runners + runner_labels: codex-windows-x64 + target: x86_64-pc-windows-msvc + profile: ci-test + artifact_id: windows-x64 + test_threads: 8 + secrets: inherit - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 - with: - targets: ${{ matrix.target }} - - - name: Compute lockfile hash - id: lockhash - working-directory: codex-rs - shell: bash - run: | - set -euo pipefail - echo "hash=$(sha256sum Cargo.lock | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - echo "toolchain_hash=$(sha256sum rust-toolchain.toml | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - - - name: Restore cargo home cache - id: cache_cargo_home_restore - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }} - restore-keys: | - cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- - - - name: Install sccache - if: ${{ env.USE_SCCACHE == 'true' }} - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 - with: - tool: sccache - version: 0.7.5 - - - name: Configure sccache backend - if: ${{ env.USE_SCCACHE == 'true' }} - shell: bash - run: | - set -euo pipefail - if [[ -n "${ACTIONS_CACHE_URL:-}" && -n "${ACTIONS_RUNTIME_TOKEN:-}" ]]; then - echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" - echo "Using sccache GitHub backend" - else - echo "SCCACHE_GHA_ENABLED=false" >> "$GITHUB_ENV" - echo "SCCACHE_DIR=${{ github.workspace }}/.sccache" >> "$GITHUB_ENV" - echo "Using sccache local disk + actions/cache fallback" - fi - - - name: Enable sccache wrapper - if: ${{ env.USE_SCCACHE == 'true' }} - shell: bash - run: echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" - - - name: Restore sccache cache (fallback) - if: ${{ env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' }} - id: cache_sccache_restore - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - with: - path: ${{ github.workspace }}/.sccache/ - key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} - restore-keys: | - sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}- - sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- - - - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 - with: - tool: nextest - version: 0.9.103 - - - name: Enable unprivileged user namespaces (Linux) - if: runner.os == 'Linux' - run: | - # Required for bubblewrap to work on Linux CI runners. - sudo sysctl -w kernel.unprivileged_userns_clone=1 - # Ubuntu 24.04+ can additionally gate unprivileged user namespaces - # behind AppArmor. - if sudo sysctl -a 2>/dev/null | grep -q '^kernel.apparmor_restrict_unprivileged_userns'; then - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - fi - - - name: Set up remote test env (Docker) - if: ${{ runner.os == 'Linux' && matrix.remote_env == 'true' }} - shell: bash - run: | - set -euo pipefail - export CODEX_TEST_REMOTE_ENV_CONTAINER_NAME=codex-remote-test-env - source "${GITHUB_WORKSPACE}/scripts/test-remote-env.sh" - echo "CODEX_TEST_REMOTE_ENV=${CODEX_TEST_REMOTE_ENV}" >> "$GITHUB_ENV" - echo "CODEX_TEST_REMOTE_EXEC_SERVER_URL=${CODEX_TEST_REMOTE_EXEC_SERVER_URL}" >> "$GITHUB_ENV" - - - name: tests - id: test - run: cargo nextest run --no-fail-fast --target ${{ matrix.target }} --cargo-profile ci-test --timings - env: - RUST_BACKTRACE: 1 - RUST_MIN_STACK: "8388608" # 8 MiB - NEXTEST_STATUS_LEVEL: leak - - - name: Upload nextest JUnit report - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: nextest-junit-rust-ci-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }} - path: codex-rs/target/nextest/default/junit.xml - if-no-files-found: warn - - - name: Upload Cargo timings (nextest) - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: cargo-timings-rust-ci-nextest-${{ matrix.target }}-${{ matrix.profile }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html - if-no-files-found: warn - - - name: Save cargo home cache - if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true' - continue-on-error: true - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }} - - - name: Save sccache cache (fallback) - if: always() && !cancelled() && env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' - continue-on-error: true - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - with: - path: ${{ github.workspace }}/.sccache/ - key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} - - - name: sccache stats - if: always() && env.USE_SCCACHE == 'true' - continue-on-error: true - run: sccache --show-stats || true - - - name: sccache summary - if: always() && env.USE_SCCACHE == 'true' - shell: bash - run: | - { - echo "### sccache stats — ${{ matrix.target }} (tests)"; - echo; - echo '```'; - sccache --show-stats || true; - echo '```'; - } >> "$GITHUB_STEP_SUMMARY" - - - name: Tear down remote test env - if: ${{ always() && runner.os == 'Linux' && matrix.remote_env == 'true' }} - shell: bash - run: | - set +e - if [[ "${STEPS_TEST_OUTCOME}" != "success" ]]; then - docker logs codex-remote-test-env || true - fi - docker rm -f codex-remote-test-env >/dev/null 2>&1 || true - env: - STEPS_TEST_OUTCOME: ${{ steps.test.outcome }} - - - name: verify tests passed - if: steps.test.outcome == 'failure' - run: | - echo "Tests failed. See logs for details." - exit 1 + tests_windows_arm64: + name: Tests — windows-arm64 - aarch64-pc-windows-msvc + uses: ./.github/workflows/rust-ci-full-nextest-platform.yml + with: + runner: windows-arm64 + runner_group: codex-runners + runner_labels: codex-windows-arm64 + archive_runner: windows-x64 + archive_runner_group: codex-runners + archive_runner_labels: codex-windows-x64 + target: aarch64-pc-windows-msvc + profile: ci-test + artifact_id: windows-arm64 + test_threads: 8 + use_sccache: true + secrets: inherit # --- Gatherer job for the full post-merge workflow -------------------------- results: @@ -768,7 +599,11 @@ jobs: argument_comment_lint_package, argument_comment_lint_prebuilt, lint_build, - tests, + tests_macos_aarch64, + tests_linux_x64_remote, + tests_linux_arm64, + tests_windows_x64, + tests_windows_arm64, ] if: always() runs-on: ubuntu-24.04 @@ -781,13 +616,21 @@ jobs: echo "general: ${{ needs.general.result }}" echo "shear : ${{ needs.cargo_shear.result }}" echo "lint : ${{ needs.lint_build.result }}" - echo "tests : ${{ needs.tests.result }}" + echo "test macos : ${{ needs.tests_macos_aarch64.result }}" + echo "test linux : ${{ needs.tests_linux_x64_remote.result }}" + echo "test arm64 : ${{ needs.tests_linux_arm64.result }}" + echo "test winx64: ${{ needs.tests_windows_x64.result }}" + echo "test winarm: ${{ needs.tests_windows_arm64.result }}" [[ '${{ needs.argument_comment_lint_package.result }}' == 'success' ]] || { echo 'argument_comment_lint_package failed'; exit 1; } [[ '${{ needs.argument_comment_lint_prebuilt.result }}' == 'success' ]] || { echo 'argument_comment_lint_prebuilt failed'; exit 1; } [[ '${{ needs.general.result }}' == 'success' ]] || { echo 'general failed'; exit 1; } [[ '${{ needs.cargo_shear.result }}' == 'success' ]] || { echo 'cargo_shear failed'; exit 1; } [[ '${{ needs.lint_build.result }}' == 'success' ]] || { echo 'lint_build failed'; exit 1; } - [[ '${{ needs.tests.result }}' == 'success' ]] || { echo 'tests failed'; exit 1; } + [[ '${{ needs.tests_macos_aarch64.result }}' == 'success' ]] || { echo 'tests_macos_aarch64 failed'; exit 1; } + [[ '${{ needs.tests_linux_x64_remote.result }}' == 'success' ]] || { echo 'tests_linux_x64_remote failed'; exit 1; } + [[ '${{ needs.tests_linux_arm64.result }}' == 'success' ]] || { echo 'tests_linux_arm64 failed'; exit 1; } + [[ '${{ needs.tests_windows_x64.result }}' == 'success' ]] || { echo 'tests_windows_x64 failed'; exit 1; } + [[ '${{ needs.tests_windows_arm64.result }}' == 'success' ]] || { echo 'tests_windows_arm64 failed'; exit 1; } - name: sccache summary note if: always() diff --git a/codex-rs/.config/nextest.toml b/codex-rs/.config/nextest.toml index 399caae16a..01f4a98dc8 100644 --- a/codex-rs/.config/nextest.toml +++ b/codex-rs/.config/nextest.toml @@ -1,6 +1,8 @@ [profile.default] # Retry once so one transient failure does not fail full-CI outright. -slow-timeout = { period = "15s", terminate-after = 2 } +# Fanout keeps the full-CI shards moving without treating every >30s test as +# stuck. Keep this aligned with the broader timeout budget we give sharded CI. +slow-timeout = { period = "30s", terminate-after = 2 } retries = 1 [profile.default.junit] From 14953023471159aaed89f360c0f3da2346cb4bc0 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 19 May 2026 19:01:05 -0700 Subject: [PATCH 15/63] feat: expose codex-app-server version flag (#23593) ## Why `codex-app-server` is published as a standalone release binary, so it should support the same basic version inspection behavior users expect from command-line tools. This is independent of package assembly: package metadata now comes from `codex-rs/Cargo.toml`, but the standalone app-server binary should still answer `--version` directly. ## What changed - Enables Clap's generated `--version` flag for the `codex-app-server` binary by adding `#[command(version)]` to its top-level parser. ## Verification - Ran `cargo run -p codex-app-server --bin codex-app-server -- --version` and verified it prints `codex-app-server 0.0.0`. --- codex-rs/app-server/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 6f5ecabf90..2435c532e8 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -17,6 +17,7 @@ const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH" const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; #[derive(Debug, Parser)] +#[command(version)] struct AppServerArgs { /// Transport endpoint URL. Supported values: `stdio://` (default), /// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`. From c3faea0b09cadbe74f62503fc7cb3832d82a3ff8 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Tue, 19 May 2026 19:42:56 -0700 Subject: [PATCH 16/63] feat: add permission profile list api (#23412) ## Why Clients need a typed permission-profile catalog instead of reconstructing that state from config internals. ## What changed - Added `permissionProfile/list` to the app-server v2 protocol with cursor pagination and optional `cwd`. - The list response includes built-in permission profiles plus config-defined `[permissions.]` profiles from the effective config for the request context. - Permission profiles keep optional `description` metadata for display purposes. - App-server docs and schema fixtures are updated for the new RPC. --- .../schema/json/ClientRequest.json | 52 ++++ .../codex_app_server_protocol.schemas.json | 96 ++++++++ .../codex_app_server_protocol.v2.schemas.json | 96 ++++++++ .../json/v2/PermissionProfileListParams.json | 30 +++ .../v2/PermissionProfileListResponse.json | 44 ++++ .../schema/typescript/ClientRequest.ts | 3 +- .../v2/PermissionProfileListParams.ts | 17 ++ .../v2/PermissionProfileListResponse.ts | 11 + .../typescript/v2/PermissionProfileSummary.ts | 13 + .../schema/typescript/v2/index.ts | 3 + .../src/protocol/common.rs | 5 + .../src/protocol/v2/permissions.rs | 35 +++ codex-rs/app-server/README.md | 1 + codex-rs/app-server/src/message_processor.rs | 3 + codex-rs/app-server/src/request_processors.rs | 6 + .../request_processors/catalog_processor.rs | 82 ++++++ .../app-server/tests/common/mcp_process.rs | 10 + codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../tests/suite/v2/permission_profile_list.rs | 233 ++++++++++++++++++ codex-rs/config/src/permissions_toml.rs | 1 + codex-rs/core/config.schema.json | 3 + codex-rs/core/src/config/config_tests.rs | 22 ++ codex-rs/core/src/config/permissions_tests.rs | 3 + 23 files changed, 769 insertions(+), 1 deletion(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts create mode 100644 codex-rs/app-server/tests/suite/v2/permission_profile_list.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 58aa1c4449..506358d25d 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1541,6 +1541,34 @@ ], "type": "string" }, + "PermissionProfileListParams": { + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "cwd": { + "description": "Optional working directory to resolve project config layers.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to the full result set.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, "Personality": { "enum": [ "none", @@ -5324,6 +5352,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "permissionProfile/list" + ], + "title": "PermissionProfile/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PermissionProfileListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "PermissionProfile/listRequest", + "type": "object" + }, { "properties": { "id": { 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 b50cacefa4..73e4335bf9 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 @@ -1429,6 +1429,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "permissionProfile/list" + ], + "title": "PermissionProfile/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/PermissionProfileListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "PermissionProfile/listRequest", + "type": "object" + }, { "properties": { "id": { @@ -11708,6 +11732,78 @@ } ] }, + "PermissionProfileListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "cwd": { + "description": "Optional working directory to resolve project config layers.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to the full result set.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "PermissionProfileListParams", + "type": "object" + }, + "PermissionProfileListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/v2/PermissionProfileSummary" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "PermissionProfileListResponse", + "type": "object" + }, + "PermissionProfileSummary": { + "properties": { + "description": { + "description": "Optional user-facing description for display in clients.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Available permission profile identifier.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "Personality": { "enum": [ "none", 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 9da530463a..ea8655d0fe 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 @@ -2155,6 +2155,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "permissionProfile/list" + ], + "title": "PermissionProfile/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PermissionProfileListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "PermissionProfile/listRequest", + "type": "object" + }, { "properties": { "id": { @@ -8257,6 +8281,78 @@ } ] }, + "PermissionProfileListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "cwd": { + "description": "Optional working directory to resolve project config layers.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to the full result set.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "PermissionProfileListParams", + "type": "object" + }, + "PermissionProfileListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/PermissionProfileSummary" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "PermissionProfileListResponse", + "type": "object" + }, + "PermissionProfileSummary": { + "properties": { + "description": { + "description": "Optional user-facing description for display in clients.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Available permission profile identifier.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "Personality": { "enum": [ "none", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListParams.json b/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListParams.json new file mode 100644 index 0000000000..402dab6221 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListParams.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "cwd": { + "description": "Optional working directory to resolve project config layers.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to the full result set.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "PermissionProfileListParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json new file mode 100644 index 0000000000..4d5a47f8d5 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "PermissionProfileSummary": { + "properties": { + "description": { + "description": "Optional user-facing description for display in clients.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Available permission profile identifier.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + } + }, + "properties": { + "data": { + "items": { + "$ref": "#/definitions/PermissionProfileSummary" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "PermissionProfileListResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 7f017ed700..14be7db47a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -42,6 +42,7 @@ import type { McpServerOauthLoginParams } from "./v2/McpServerOauthLoginParams"; import type { McpServerToolCallParams } from "./v2/McpServerToolCallParams"; import type { ModelListParams } from "./v2/ModelListParams"; import type { ModelProviderCapabilitiesReadParams } from "./v2/ModelProviderCapabilitiesReadParams"; +import type { PermissionProfileListParams } from "./v2/PermissionProfileListParams"; import type { PluginInstallParams } from "./v2/PluginInstallParams"; import type { PluginInstalledParams } from "./v2/PluginInstalledParams"; import type { PluginListParams } from "./v2/PluginListParams"; @@ -81,4 +82,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListParams.ts new file mode 100644 index 0000000000..24582c9507 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListParams.ts @@ -0,0 +1,17 @@ +// 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. + +export type PermissionProfileListParams = { +/** + * Opaque pagination cursor returned by a previous call. + */ +cursor?: string | null, +/** + * Optional page size; defaults to the full result set. + */ +limit?: number | null, +/** + * Optional working directory to resolve project config layers. + */ +cwd?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListResponse.ts new file mode 100644 index 0000000000..ba0ccbc0d6 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListResponse.ts @@ -0,0 +1,11 @@ +// 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 { PermissionProfileSummary } from "./PermissionProfileSummary"; + +export type PermissionProfileListResponse = { data: Array, +/** + * Opaque cursor to pass to the next call to continue after the last item. + * If None, there are no more items to return. + */ +nextCursor: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts new file mode 100644 index 0000000000..9d02fd776b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts @@ -0,0 +1,13 @@ +// 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. + +export type PermissionProfileSummary = { +/** + * Available permission profile identifier. + */ +id: string, +/** + * Optional user-facing description for display in clients. + */ +description: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 7c09be0def..d9815baec6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -256,6 +256,9 @@ export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; export type { PatchChangeKind } from "./PatchChangeKind"; export type { PermissionGrantScope } from "./PermissionGrantScope"; +export type { PermissionProfileListParams } from "./PermissionProfileListParams"; +export type { PermissionProfileListResponse } from "./PermissionProfileListResponse"; +export type { PermissionProfileSummary } from "./PermissionProfileSummary"; export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams"; export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 7a7fe5642c..4adcf5f592 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -800,6 +800,11 @@ client_request_definitions! { serialization: global("config"), response: v2::ExperimentalFeatureListResponse, }, + PermissionProfileList => "permissionProfile/list" { + params: v2::PermissionProfileListParams, + serialization: global_shared_read("config"), + response: v2::PermissionProfileListResponse, + }, ExperimentalFeatureEnablementSet => "experimentalFeature/enablement/set" { params: v2::ExperimentalFeatureEnablementSetParams, serialization: global("config"), 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 15eea54e14..99924ca1e5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -287,6 +287,41 @@ impl From for CoreFileSystemSandboxEntry { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionProfileListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to the full result set. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional working directory to resolve project config layers. + #[ts(optional = nullable)] + pub cwd: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionProfileSummary { + /// Available permission profile identifier. + pub id: String, + /// Optional user-facing description for display in clients. + pub description: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionProfileListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// If None, there are no more items to return. + pub next_cursor: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 1b7575141f..a3a7910884 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -189,6 +189,7 @@ Example with notification opt-out: - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. +- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. - `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index e37c9afd22..eb54307849 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1149,6 +1149,9 @@ impl MessageProcessor { .experimental_feature_list(params) .await } + ClientRequest::PermissionProfileList { params, .. } => { + self.catalog_processor.permission_profile_list(params).await + } ClientRequest::CollaborationModeList { params, .. } => { self.catalog_processor.collaboration_mode_list(params).await } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 3e84c397f8..168640c0eb 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -103,6 +103,9 @@ use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::MockExperimentalMethodResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; +use codex_app_server_protocol::PermissionProfileListParams; +use codex_app_server_protocol::PermissionProfileListResponse; +use codex_app_server_protocol::PermissionProfileSummary; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; @@ -357,6 +360,9 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; #[cfg(test)] use codex_protocol::items::TurnItem; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::ResponseItem; #[cfg(test)] use codex_protocol::permissions::FileSystemSandboxPolicy; 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 834f70654d..d2845d7d6a 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -1,4 +1,5 @@ use super::*; +use codex_config::config_toml::ConfigToml; use futures::StreamExt; #[derive(Clone)] @@ -155,6 +156,15 @@ impl CatalogRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn permission_profile_list( + &self, + params: PermissionProfileListParams, + ) -> Result, JSONRPCErrorError> { + self.permission_profile_list_response(params) + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn collaboration_mode_list( &self, params: CollaborationModeListParams, @@ -389,6 +399,78 @@ impl CatalogRequestProcessor { Ok(ExperimentalFeatureListResponse { data, next_cursor }) } + async fn permission_profile_list_response( + &self, + params: PermissionProfileListParams, + ) -> Result { + let PermissionProfileListParams { cursor, limit, cwd } = params; + let config_layer_stack = match cwd { + Some(cwd) => { + let cwd = PathBuf::from(cwd); + let (_, config_layer_stack) = self + .resolve_cwd_config(&cwd) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}")))?; + config_layer_stack + } + None => self + .config_manager + .load_config_layers(/*cwd*/ None) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}")))?, + }; + let effective_config: ConfigToml = config_layer_stack + .effective_config() + .try_into() + .map_err(|err| internal_error(format!("failed to read effective config: {err}")))?; + let mut profiles = vec![ + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), + description: None, + }, + ]; + let mut configured_profiles = effective_config + .permissions + .into_iter() + .flat_map(|permissions| permissions.entries) + .map(|(id, profile)| PermissionProfileSummary { + id, + description: profile.description, + }) + .collect::>(); + configured_profiles.sort_by(|left, right| left.id.cmp(&right.id)); + profiles.extend(configured_profiles); + let total = profiles.len(); + let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; + let effective_limit = effective_limit.min(total); + let start = match cursor { + Some(cursor) => cursor + .parse::() + .map_err(|_| invalid_request(format!("invalid cursor: {cursor}")))?, + None => 0, + }; + + if start > total { + return Err(invalid_request(format!( + "cursor {start} exceeds total permission profiles {total}" + ))); + } + + let end = start.saturating_add(effective_limit).min(total); + let data = profiles[start..end].to_vec(); + let next_cursor = (end < total).then_some(end.to_string()); + + Ok(PermissionProfileListResponse { data, next_cursor }) + } + async fn mock_experimental_method_inner( &self, params: MockExperimentalMethodParams, diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 3445010a7f..115764f822 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -56,6 +56,7 @@ use codex_app_server_protocol::McpServerToolCallParams; use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelProviderCapabilitiesReadParams; +use codex_app_server_protocol::PermissionProfileListParams; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstalledParams; use codex_app_server_protocol::PluginListParams; @@ -561,6 +562,15 @@ impl McpProcess { self.send_request("experimentalFeature/list", params).await } + /// Send a `permissionProfile/list` JSON-RPC request. + pub async fn send_permission_profile_list_request( + &mut self, + params: PermissionProfileListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("permissionProfile/list", params).await + } + /// Send an `experimentalFeature/enablement/set` JSON-RPC request. pub async fn send_experimental_feature_enablement_set_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index bdbe7b7ddd..1c81fe1ed6 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -29,6 +29,7 @@ mod memory_reset; mod model_list; mod model_provider_capabilities_read; mod output_schema; +mod permission_profile_list; mod plan_item; mod plugin_install; mod plugin_list; diff --git a/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs b/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs new file mode 100644 index 0000000000..5939065280 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs @@ -0,0 +1,233 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PermissionProfileListParams; +use codex_app_server_protocol::PermissionProfileListResponse; +use codex_app_server_protocol::PermissionProfileSummary; +use codex_app_server_protocol::RequestId; +use codex_core::config::set_project_trust_level; +use codex_protocol::config_types::TrustLevel; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +#[tokio::test] +async fn permission_profile_list_returns_builtin_and_configured_profiles() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +default_permissions = "dev" + +[permissions.dev] +description = "Day-to-day coding work." + +[permissions.dev.filesystem] +":workspace_roots" = "write" + +[permissions.audit] +description = "Inspect without writes." + +[permissions.audit.filesystem] +":workspace_roots" = "read" +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_permission_profile_list_request(PermissionProfileListParams { + cursor: None, + limit: None, + cwd: None, + }) + .await?; + let actual = read_response::(&mut mcp, request_id).await?; + + assert_eq!( + actual, + PermissionProfileListResponse { + data: vec![ + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), + description: None, + }, + PermissionProfileSummary { + id: "audit".to_string(), + description: Some("Inspect without writes.".to_string()), + }, + PermissionProfileSummary { + id: "dev".to_string(), + description: Some("Day-to-day coding work.".to_string()), + }, + ], + next_cursor: None, + } + ); + Ok(()) +} + +#[tokio::test] +async fn permission_profile_list_resolves_project_profiles_and_paginates() -> Result<()> { + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +default_permissions = ":workspace" +"#, + )?; + std::fs::write( + project_config_dir.join("config.toml"), + r#" +[permissions.project] +description = "Project-scoped profile." + +[permissions.project.filesystem] +":workspace_roots" = "write" +"#, + )?; + set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let first_request_id = mcp + .send_permission_profile_list_request(PermissionProfileListParams { + cursor: None, + limit: Some(3), + cwd: Some(workspace.path().to_string_lossy().into_owned()), + }) + .await?; + let first = read_response::(&mut mcp, first_request_id).await?; + assert_eq!( + first, + PermissionProfileListResponse { + data: vec![ + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), + description: None, + }, + ], + next_cursor: Some("3".to_string()), + } + ); + + let second_request_id = mcp + .send_permission_profile_list_request(PermissionProfileListParams { + cursor: first.next_cursor, + limit: Some(3), + cwd: Some(workspace.path().to_string_lossy().into_owned()), + }) + .await?; + let second = + read_response::(&mut mcp, second_request_id).await?; + assert_eq!( + second, + PermissionProfileListResponse { + data: vec![PermissionProfileSummary { + id: "project".to_string(), + description: Some("Project-scoped profile.".to_string()), + }], + next_cursor: None, + } + ); + Ok(()) +} + +#[tokio::test] +async fn permission_profile_list_discovers_project_profiles_without_default_selection() -> Result<()> +{ + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join("config.toml"), + r#" +[permissions.project] +description = "Project-scoped profile." + +[permissions.project.filesystem] +":workspace_roots" = "write" +"#, + )?; + set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_permission_profile_list_request(PermissionProfileListParams { + cursor: None, + limit: None, + cwd: Some(workspace.path().to_string_lossy().into_owned()), + }) + .await?; + let actual = read_response::(&mut mcp, request_id).await?; + + assert_eq!( + actual, + PermissionProfileListResponse { + data: vec![ + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), + description: None, + }, + PermissionProfileSummary { + id: "project".to_string(), + description: Some("Project-scoped profile.".to_string()), + }, + ], + next_cursor: None, + } + ); + Ok(()) +} + +async fn read_response( + mcp: &mut McpProcess, + request_id: i64, +) -> Result { + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} diff --git a/codex-rs/config/src/permissions_toml.rs b/codex-rs/config/src/permissions_toml.rs index fff8c67706..4d533776ad 100644 --- a/codex-rs/config/src/permissions_toml.rs +++ b/codex-rs/config/src/permissions_toml.rs @@ -25,6 +25,7 @@ impl PermissionsToml { #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct PermissionProfileToml { + pub description: Option, pub workspace_roots: Option, pub filesystem: Option, pub network: Option, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 00fc4931a4..0e811e9017 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2002,6 +2002,9 @@ "PermissionProfileToml": { "additionalProperties": false, "properties": { + "description": { + "type": "string" + }, "filesystem": { "$ref": "#/definitions/FilesystemPermissionsToml" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index dac1bf317e..f968677483 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -734,6 +734,9 @@ fn config_toml_deserializes_permission_profiles() { let toml = r#" default_permissions = "workspace" +[permissions.workspace] +description = "Day-to-day workspace access." + [permissions.workspace.workspace_roots] "~/code/openai" = true "~/code/ignored" = false @@ -765,6 +768,7 @@ allow_upstream_proxy = false entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: Some("Day-to-day workspace access.".to_string()), workspace_roots: Some(WorkspaceRootsToml { entries: BTreeMap::from([ ("~/code/ignored".to_string(), false), @@ -830,6 +834,7 @@ async fn permissions_profiles_proxy_policy_does_not_start_managed_network_proxy_ entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -984,6 +989,7 @@ async fn network_proxy_feature_matrix_preserves_sandbox_network_semantics() -> s entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1135,6 +1141,7 @@ async fn network_proxy_feature_uses_profile_network_proxy_settings() -> std::io: entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1238,6 +1245,7 @@ enabled = false entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1287,6 +1295,7 @@ async fn permissions_profiles_network_disabled_by_default_does_not_start_proxy() entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1334,6 +1343,7 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1638,6 +1648,7 @@ async fn permission_profile_override_preserves_configured_network_policy_without entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1698,6 +1709,7 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std: entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: Some(2), @@ -1777,6 +1789,7 @@ async fn permissions_profiles_require_default_permissions() -> std::io::Result<( entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1904,6 +1917,7 @@ async fn workspace_profile_applies_rules_to_runtime_and_profile_workspace_roots( entries: BTreeMap::from([( "dev".to_string(), PermissionProfileToml { + description: None, workspace_roots: Some(WorkspaceRootsToml { entries: BTreeMap::from([( profile_root.to_string_lossy().into_owned(), @@ -2362,6 +2376,7 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2423,6 +2438,7 @@ async fn permissions_profiles_reject_nested_entries_for_non_workspace_roots() -> entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2484,6 +2500,7 @@ async fn load_workspace_permission_profile( #[tokio::test] async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2528,6 +2545,7 @@ async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<( async fn permissions_profiles_allow_unknown_special_paths_with_nested_entries() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2565,6 +2583,7 @@ async fn permissions_profiles_allow_unknown_special_paths_with_nested_entries() #[tokio::test] async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + description: None, workspace_roots: None, filesystem: None, network: None, @@ -2594,6 +2613,7 @@ async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io #[tokio::test] async fn permissions_profiles_allow_empty_filesystem_with_warning() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2630,6 +2650,7 @@ async fn permissions_profiles_reject_workspace_root_parent_traversal() -> std::i entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2677,6 +2698,7 @@ async fn permissions_profiles_allow_network_enablement() -> std::io::Result<()> entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index d6f358017e..5e1fcf3525 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -67,6 +67,7 @@ async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Resu entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -285,6 +286,7 @@ fn compile_permission_profile_workspace_roots_resolves_enabled_entries() -> std: entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: Some(WorkspaceRootsToml { entries: BTreeMap::from([ ("backend".to_string(), true), @@ -394,6 +396,7 @@ fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()> entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, From 532b9c83ae83a3cdcf1e174c5239451314cd1508 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 19 May 2026 20:05:52 -0700 Subject: [PATCH 17/63] Move plugin and skill warmup into session startup (#23535) ## Why Plugin and skill loading is useful as warmup and early validation, but session startup does not need to wait for that work before it can continue building the session. Keeping it on the serial startup path adds avoidable latency to every fresh thread start. We still want invalid skill configurations to show up quickly, and we want the warmup to exercise the same plugin and skill manager caches that the normal turn path uses. ## What changed - moved plugin and skill warmup into the session startup async path instead of eagerly awaiting it on the serial setup path - kept the warmup using the session's resolved filesystem/environment context so skill loading still sees the right roots - preserved early skill-load error logging so broken skill configurations still surface during startup - left the per-turn plugin and skill loading path unchanged, so turns still use the normal cached managers ## Testing - Not run locally; relying on CI for validation. --- codex-rs/core/src/session/mod.rs | 14 ------- codex-rs/core/src/session/session.rs | 57 +++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 8f0d2f1c01..646528cf97 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -480,20 +480,6 @@ impl Codex { } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); - let fs = environment_selections.primary_filesystem(); - 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 loaded_skills = skills_manager.skills_for_config(&skills_input, fs).await; - - for err in &loaded_skills.errors { - error!( - "failed to load skill {}: {}", - err.path.display(), - err.message - ); - } if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = session_source && depth >= config.agent_max_depth diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 884c440f28..8945880c33 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1,6 +1,7 @@ use super::input_queue::InputQueue; use super::*; use crate::goals::GoalRuntimeState; +use crate::skills::SkillError; use crate::state::ActiveTurn; use codex_protocol::SessionId; use codex_protocol::config_types::ServiceTier; @@ -405,6 +406,29 @@ pub(crate) struct AppServerClientMetadata { pub(crate) client_version: Option, } +async fn warm_plugins_and_skills_for_session_init( + config: Arc, + environment_manager: Arc, + plugins_manager: Arc, + skills_manager: Arc, + environments: Vec, +) -> Vec { + let fs = crate::environment_selection::resolve_environment_selections( + environment_manager.as_ref(), + &environments, + ) + .ok() + .and_then(|resolved| resolved.primary_filesystem()); + 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.as_ref(), effective_skill_roots); + skills_manager + .skills_for_config(&skills_input, fs) + .await + .errors +} + impl Session { /// Returns the concrete identity for this thread. pub(crate) fn thread_id(&self) -> ThreadId { @@ -578,9 +602,38 @@ impl Session { otel.name = "session_init.auth_mcp", )); + let plugin_and_skill_warmup_fut = warm_plugins_and_skills_for_session_init( + Arc::clone(&config), + Arc::clone(&environment_manager), + Arc::clone(&plugins_manager), + Arc::clone(&skills_manager), + session_configuration.environments.clone(), + ) + .instrument(info_span!( + "session_init.plugin_skill_warmup", + otel.name = "session_init.plugin_skill_warmup", + )); + // Join all independent futures. - let (thread_persistence_result, state_db_ctx, (auth, mcp_servers, auth_statuses)) = - tokio::join!(thread_persistence_fut, state_db_fut, auth_and_mcp_fut); + let ( + thread_persistence_result, + state_db_ctx, + (auth, mcp_servers, auth_statuses), + plugin_skill_errors, + ) = tokio::join!( + thread_persistence_fut, + state_db_fut, + auth_and_mcp_fut, + plugin_and_skill_warmup_fut + ); + + for err in &plugin_skill_errors { + error!( + "failed to load skill {}: {}", + err.path.display(), + err.message + ); + } let mut live_thread_init = LiveThreadInitGuard::new(thread_persistence_result.map_err(|e| { From e43a2e297fe72285890e0f04f9f1aece843c2338 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 19 May 2026 20:48:37 -0700 Subject: [PATCH 18/63] Fix stale background terminal poll events (#23231) ## Why Issue #23214 reports `/ps` showing no background terminals while the status line still says it is waiting for a background terminal. The race is in core: `write_stdin` can poll a process that exits before the response returns. The process manager correctly returns `process_id: None`, but the handler still emitted a `TerminalInteraction` event using the requested session id, causing clients to believe a dead process was still being polled. Fixes #23214. ## What changed - Suppress `TerminalInteraction` events for empty `write_stdin` polls once `response.process_id` is `None`. - Continue emitting interactions for non-empty stdin, even if that input causes the process to exit before the response returns. - Extend the unified exec integration test to assert completed empty polls do not emit terminal interactions. ## Verification - `cargo test -p codex-core --test all unified_exec_emits_one_begin_and_one_end_event` - `cargo test -p codex-core --test all unified_exec_emits_terminal_interaction_for_write_stdin` `cargo test -p codex-core` currently aborts in unrelated `agent::control::tests::resume_agent_from_rollout_uses_edge_data_when_descendant_metadata_source_is_stale` with a reproducible stack overflow. --- .../handlers/unified_exec/write_stdin.rs | 23 ++++++++++++------- .../core/src/unified_exec/process_manager.rs | 4 ++-- codex-rs/core/tests/suite/unified_exec.rs | 8 +++++++ .../tui/src/chatwidget/command_lifecycle.rs | 6 ++++- .../tui/src/chatwidget/tests/exec_flow.rs | 16 +++++++++++++ 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs index 3dea7092f5..843f9e638f 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs @@ -78,14 +78,21 @@ impl ToolExecutor for WriteStdinHandler { FunctionCallError::RespondToModel(format!("write_stdin failed: {err}")) })?; - let interaction = TerminalInteractionEvent { - call_id: response.event_call_id.clone(), - process_id: args.session_id.to_string(), - stdin: args.chars.clone(), - }; - session - .send_event(turn.as_ref(), EventMsg::TerminalInteraction(interaction)) - .await; + // Empty stdin is a background poll, so emit it only while there is + // still a live process for the UI to wait on. Non-empty stdin is a real + // terminal interaction and should remain visible even if it completes + // the process before the response returns. + if !args.chars.is_empty() || response.process_id.is_some() { + let process_id = response.process_id.unwrap_or(args.session_id); + let interaction = TerminalInteractionEvent { + call_id: response.event_call_id.clone(), + process_id: process_id.to_string(), + stdin: args.chars.clone(), + }; + session + .send_event(turn.as_ref(), EventMsg::TerminalInteraction(interaction)) + .await; + } Ok(boxed_tool_output(response)) } diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 8c0cc89680..c6fc2ab3e5 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -691,8 +691,8 @@ impl UnifiedExecProcessManager { // After polling, refresh_process_state tells us whether the PTY is // still alive or has exited and been removed from the store; we thread - // that through so the handler can tag TerminalInteraction with an - // appropriate process_id and exit_code. + // that through so the handler can tag or suppress TerminalInteraction + // with an appropriate process_id and exit_code. let status = if let Some(status) = status_after_write { status } else { diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index d61e427386..9eb831b084 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -1322,6 +1322,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { let mut begin_events = Vec::new(); let mut end_events = Vec::new(); + let mut terminal_interactions = Vec::new(); let mut task_completed = false; loop { let event_msg = wait_for_event(&test.codex, |_| true).await; @@ -1332,6 +1333,9 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { EventMsg::ExecCommandEnd(event) if event.call_id == open_call_id => { end_events.push(event); } + EventMsg::TerminalInteraction(event) if event.call_id == open_call_id => { + terminal_interactions.push(event); + } EventMsg::TurnComplete(_) => { task_completed = true; } @@ -1353,6 +1357,10 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { 1, "expected end event for the write_stdin call" ); + assert!( + terminal_interactions.is_empty(), + "completed empty polls should not emit terminal interactions: {terminal_interactions:?}" + ); let open_event = &begin_events[0]; diff --git a/codex-rs/tui/src/chatwidget/command_lifecycle.rs b/codex-rs/tui/src/chatwidget/command_lifecycle.rs index efe7522963..6f13d37987 100644 --- a/codex-rs/tui/src/chatwidget/command_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/command_lifecycle.rs @@ -76,12 +76,16 @@ impl ChatWidget { if !self.bottom_pane.is_task_running() { return; } - self.flush_answer_stream_with_separator(); let command_display = self .unified_exec_processes .iter() .find(|process| process.key == process_id) .map(|process| process.command_display.clone()); + if stdin.is_empty() && command_display.is_none() { + return; + } + + self.flush_answer_stream_with_separator(); if stdin.is_empty() { // Empty stdin means we are polling for background output. // Surface this in the status indicator (single "waiting" surface) instead of diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index d54783615c..ffc8833777 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -719,6 +719,22 @@ async fn unified_exec_wait_status_header_updates_on_late_command_display() { assert_eq!(status.details(), Some("sleep 5")); } +#[tokio::test] +async fn unified_exec_empty_poll_for_finished_process_does_not_show_waiting_status() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.on_task_started(); + + terminal_interaction(&mut chat, "call-finished", "proc-finished", ""); + + assert_eq!(chat.status_state.current_status.header, "Working"); + let status = chat + .bottom_pane + .status_widget() + .expect("task status indicator should remain visible"); + assert_eq!(status.header(), "Working"); + assert!(chat.unified_exec_wait_streak.is_none()); +} + #[tokio::test] async fn unified_exec_waiting_multiple_empty_snapshots() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From 5a4202ad909c2918486bbdd911babf636f498990 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 19 May 2026 21:02:14 -0700 Subject: [PATCH 19/63] [codex] Preserve raw code-mode exec output by default (#23564) ## Why Code mode can use nested unified exec calls as data sources. When those calls omit `max_output_tokens`, code mode should receive raw command output so the script can parse or summarize it itself. When code mode does provide `max_output_tokens`, that explicit nested budget should be respected, including values above the default unified exec limit, rather than being capped before code mode sees the result. ## What - Preserve direct unified exec truncation behavior, while letting code-mode exec/write_stdin keep `max_output_tokens` as `None` unless explicitly supplied. - Make code-mode tool results use raw output when no explicit limit is present, and use the explicit nested limit directly when one is specified. - Refactor unified exec output formatting so `truncated_output` takes the caller-selected token budget. - Add e2e integration coverage for explicit nested exec limits, omitted nested exec limits, outer exec limit propagation, omitted-limit outputs that exceed both the default and a small truncation policy, explicit nested limits above those caps, and high explicit limits that still compact larger command output. - Reuse the code-mode turn setup helper while directly asserting the exact exec output item in each test. ## Testing - `just fmt` - `git diff --check` - Not run locally per repo guidance; CI should validate the e2e integration tests. --- codex-rs/core/src/tools/context.rs | 19 +- codex-rs/core/src/tools/context_tests.rs | 1 + .../core/src/tools/handlers/unified_exec.rs | 9 - .../handlers/unified_exec/exec_command.rs | 11 +- .../handlers/unified_exec/write_stdin.rs | 6 +- .../src/tools/handlers/unified_exec_tests.rs | 9 + codex-rs/core/src/unified_exec/mod.rs | 2 + codex-rs/core/src/unified_exec/mod_tests.rs | 39 ++- .../core/src/unified_exec/process_manager.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 237 ++++++++++++++++-- 10 files changed, 278 insertions(+), 57 deletions(-) diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 5fc19a2a2e..57cf0d80b3 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -311,6 +311,7 @@ pub struct ExecCommandToolOutput { pub wall_time: Duration, /// Raw bytes returned for this unified exec call before any truncation. pub raw_output: Vec, + pub truncation_policy: TruncationPolicy, pub max_output_tokens: Option, pub process_id: Option, pub exit_code: Option, @@ -357,7 +358,9 @@ impl ToolOutput for ExecCommandToolOutput { return None; } - Some(JsonValue::String(self.truncated_output())) + Some(JsonValue::String( + self.truncated_output(self.model_output_max_tokens()), + )) } fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { @@ -381,7 +384,10 @@ impl ToolOutput for ExecCommandToolOutput { exit_code: self.exit_code, session_id: self.process_id, original_token_count: self.original_token_count, - output: self.truncated_output(), + output: match self.max_output_tokens { + Some(max_tokens) => self.truncated_output(max_tokens), + None => String::from_utf8_lossy(&self.raw_output).to_string(), + }, }; serde_json::to_value(result).unwrap_or_else(|err| { @@ -391,9 +397,12 @@ impl ToolOutput for ExecCommandToolOutput { } impl ExecCommandToolOutput { - pub(crate) fn truncated_output(&self) -> String { + fn model_output_max_tokens(&self) -> usize { + resolve_max_tokens(self.max_output_tokens).min(self.truncation_policy.token_budget()) + } + + pub(crate) fn truncated_output(&self, max_tokens: usize) -> String { let text = String::from_utf8_lossy(&self.raw_output).to_string(); - let max_tokens = resolve_max_tokens(self.max_output_tokens); formatted_truncate_text(&text, TruncationPolicy::Tokens(max_tokens)) } @@ -420,7 +429,7 @@ impl ExecCommandToolOutput { } sections.push("Output:".to_string()); - sections.push(self.truncated_output()); + sections.push(self.truncated_output(self.model_output_max_tokens())); sections.join("\n") } diff --git a/codex-rs/core/src/tools/context_tests.rs b/codex-rs/core/src/tools/context_tests.rs index fe40341e95..6ac5b6978f 100644 --- a/codex-rs/core/src/tools/context_tests.rs +++ b/codex-rs/core/src/tools/context_tests.rs @@ -429,6 +429,7 @@ fn exec_command_tool_output_formats_truncated_response() { chunk_id: "abc123".to_string(), wall_time: std::time::Duration::from_millis(1250), raw_output: b"token one token two token three token four token five".to_vec(), + truncation_policy: TruncationPolicy::Tokens(10_000), max_output_tokens: Some(4), process_id: None, exit_code: Some(0), diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index b4e7bd6acf..170c1bd72f 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -7,10 +7,8 @@ use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::hook_names::HookToolName; use crate::tools::registry::PostToolUsePayload; -use crate::unified_exec::resolve_max_tokens; use codex_protocol::models::AdditionalPermissionProfile; use codex_tools::UnifiedExecShellMode; -use codex_utils_output_truncation::TruncationPolicy; use serde::Deserialize; use std::path::PathBuf; use std::sync::Arc; @@ -72,13 +70,6 @@ fn default_tty() -> bool { false } -fn effective_max_output_tokens( - max_output_tokens: Option, - truncation_policy: TruncationPolicy, -) -> usize { - resolve_max_tokens(max_output_tokens).min(truncation_policy.token_budget()) -} - #[derive(Debug)] pub(crate) struct ResolvedCommand { pub(crate) command: Vec, 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 af1c752528..62b1ed3d9f 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 @@ -36,7 +36,6 @@ use super::super::shell_spec::CommandToolOptions; use super::super::shell_spec::create_exec_command_tool_with_environment_id; use super::ExecCommandArgs; use super::ExecCommandEnvironmentArgs; -use super::effective_max_output_tokens; use super::get_command; use super::post_unified_exec_tool_use_payload; @@ -162,8 +161,6 @@ impl ToolExecutor for ExecCommandHandler { prefix_rule, .. } = args; - let max_output_tokens = - effective_max_output_tokens(max_output_tokens, turn.truncation_policy); let exec_permission_approvals_enabled = session.features().enabled(Feature::ExecPermissionApprovals); @@ -241,7 +238,8 @@ impl ToolExecutor for ExecCommandHandler { chunk_id: String::new(), wall_time: std::time::Duration::ZERO, raw_output: output.into_text().into_bytes(), - max_output_tokens: Some(max_output_tokens), + truncation_policy: turn.truncation_policy, + max_output_tokens, process_id: None, exit_code: None, original_token_count: None, @@ -258,7 +256,7 @@ impl ToolExecutor for ExecCommandHandler { hook_command: hook_command.clone(), process_id, yield_time_ms, - max_output_tokens: Some(max_output_tokens), + max_output_tokens, cwd, sandbox_cwd: turn_environment.cwd.clone(), environment, @@ -284,7 +282,8 @@ impl ToolExecutor for ExecCommandHandler { chunk_id: generate_chunk_id(), wall_time: output.duration, raw_output: output_text.into_bytes(), - max_output_tokens: Some(max_output_tokens), + truncation_policy: turn.truncation_policy, + max_output_tokens, // Sandbox denial is terminal, so there is no live // process for write_stdin to resume. process_id: None, diff --git a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs index 843f9e638f..77565eaac2 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs @@ -14,7 +14,6 @@ use codex_tools::ToolSpec; use serde::Deserialize; use super::super::shell_spec::create_write_stdin_tool; -use super::effective_max_output_tokens; use super::post_unified_exec_tool_use_payload; #[derive(Debug, Deserialize)] @@ -62,8 +61,6 @@ impl ToolExecutor for WriteStdinHandler { }; let args: WriteStdinArgs = parse_arguments(&arguments)?; - let max_output_tokens = - effective_max_output_tokens(args.max_output_tokens, turn.truncation_policy); let response = session .services .unified_exec_manager @@ -71,7 +68,8 @@ impl ToolExecutor for WriteStdinHandler { process_id: args.session_id, input: &args.chars, yield_time_ms: args.yield_time_ms, - max_output_tokens: Some(max_output_tokens), + max_output_tokens: args.max_output_tokens, + truncation_policy: turn.truncation_policy, }) .await .map_err(|err| { diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs index 50a8e44214..0eb9ebbedb 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -4,6 +4,7 @@ use crate::shell::default_user_shell; use codex_tools::UnifiedExecShellMode; use codex_tools::ZshForkConfig; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_output_truncation::TruncationPolicy; use pretty_assertions::assert_eq; use std::sync::Arc; @@ -17,6 +18,8 @@ use crate::tools::registry::CoreToolRuntime; use crate::turn_diff_tracker::TurnDiffTracker; use tokio::sync::Mutex; +const TEST_TRUNCATION_POLICY: TruncationPolicy = TruncationPolicy::Tokens(10_000); + async fn invocation_for_payload( tool_name: &str, call_id: &str, @@ -258,6 +261,7 @@ async fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_s chunk_id: "chunk-1".to_string(), wall_time: std::time::Duration::from_millis(498), raw_output: b"three".to_vec(), + truncation_policy: TEST_TRUNCATION_POLICY, max_output_tokens: None, process_id: None, exit_code: Some(0), @@ -287,6 +291,7 @@ async fn exec_command_post_tool_use_payload_uses_output_for_interactive_completi chunk_id: "chunk-1".to_string(), wall_time: std::time::Duration::from_millis(498), raw_output: b"three".to_vec(), + truncation_policy: TEST_TRUNCATION_POLICY, max_output_tokens: None, process_id: None, exit_code: Some(0), @@ -317,6 +322,7 @@ async fn exec_command_post_tool_use_payload_skips_running_sessions() { chunk_id: "chunk-1".to_string(), wall_time: std::time::Duration::from_millis(498), raw_output: b"three".to_vec(), + truncation_policy: TEST_TRUNCATION_POLICY, max_output_tokens: None, process_id: Some(45), exit_code: None, @@ -342,6 +348,7 @@ async fn write_stdin_post_tool_use_payload_uses_original_exec_call_id_and_comman chunk_id: "chunk-2".to_string(), wall_time: std::time::Duration::from_millis(498), raw_output: b"finished\n".to_vec(), + truncation_policy: TEST_TRUNCATION_POLICY, max_output_tokens: None, process_id: None, exit_code: Some(0), @@ -372,6 +379,7 @@ async fn write_stdin_post_tool_use_payload_keeps_parallel_session_metadata_separ chunk_id: "chunk-a".to_string(), wall_time: std::time::Duration::from_millis(498), raw_output: b"alpha\n".to_vec(), + truncation_policy: TEST_TRUNCATION_POLICY, max_output_tokens: None, process_id: None, exit_code: Some(0), @@ -383,6 +391,7 @@ async fn write_stdin_post_tool_use_payload_keeps_parallel_session_metadata_separ chunk_id: "chunk-b".to_string(), wall_time: std::time::Duration::from_millis(498), raw_output: b"beta\n".to_vec(), + truncation_policy: TEST_TRUNCATION_POLICY, max_output_tokens: None, process_id: None, exit_code: Some(0), diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 25abaf2835..6be32d26e7 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -31,6 +31,7 @@ use codex_exec_server::Environment; use codex_network_proxy::NetworkProxy; use codex_protocol::models::AdditionalPermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_output_truncation::TruncationPolicy; use rand::Rng; use rand::rng; use tokio::sync::Mutex; @@ -111,6 +112,7 @@ pub(crate) struct WriteStdinRequest<'a> { pub input: &'a str, pub yield_time_ms: u64, pub max_output_tokens: Option, + pub truncation_policy: TruncationPolicy, } #[derive(Default)] diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 6926f9cbf1..d416be2e33 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -10,6 +10,7 @@ use crate::tools::context::ExecCommandToolOutput; use crate::unified_exec::WriteStdinRequest; use crate::unified_exec::process::OutputHandles; use codex_sandboxing::SandboxType; +use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::approx_token_count; use core_test_support::get_remote_test_env; use core_test_support::skip_if_sandbox; @@ -162,6 +163,7 @@ async fn exec_command_with_tty( chunk_id: generate_chunk_id(), wall_time, raw_output: collected, + truncation_policy: turn.truncation_policy, max_output_tokens: None, process_id: response_process_id, exit_code, @@ -195,6 +197,7 @@ async fn write_stdin( input, yield_time_ms, max_output_tokens: None, + truncation_policy: TruncationPolicy::Tokens(10_000), }) .await } @@ -260,7 +263,9 @@ async fn unified_exec_persists_across_requests() -> anyhow::Result<()> { ) .await?; assert!( - out_2.truncated_output().contains("codex"), + out_2 + .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS) + .contains("codex"), "expected environment variable output" ); @@ -301,7 +306,9 @@ async fn multi_unified_exec_sessions() -> anyhow::Result<()> { "short command should not report a process id if it exits quickly" ); assert!( - !out_2.truncated_output().contains("codex"), + !out_2 + .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS) + .contains("codex"), "short command should run in a fresh shell" ); @@ -313,7 +320,9 @@ async fn multi_unified_exec_sessions() -> anyhow::Result<()> { ) .await?; assert!( - out_3.truncated_output().contains("codex"), + out_3 + .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS) + .contains("codex"), "session should preserve state" ); @@ -350,7 +359,9 @@ async fn unified_exec_timeouts() -> anyhow::Result<()> { ) .await?; assert!( - !out_2.truncated_output().contains(TEST_VAR_VALUE), + !out_2 + .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS) + .contains(TEST_VAR_VALUE), "timeout too short should yield incomplete output" ); @@ -359,7 +370,9 @@ async fn unified_exec_timeouts() -> anyhow::Result<()> { let out_3 = write_stdin(&session, process_id, "", /*yield_time_ms*/ 100).await?; assert!( - out_3.truncated_output().contains(TEST_VAR_VALUE), + out_3 + .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS) + .contains(TEST_VAR_VALUE), "subsequent poll should retrieve output" ); @@ -394,7 +407,9 @@ async fn unified_exec_pause_blocks_yield_timeout() -> anyhow::Result<()> { "pause should block the unified exec yield timeout" ); assert!( - response.truncated_output().contains("unified-exec-done"), + response + .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS) + .contains("unified-exec-done"), "exec_command should wait for output after the pause lifts" ); assert!( @@ -420,7 +435,11 @@ async fn requests_with_large_timeout_are_capped() -> anyhow::Result<()> { .await?; assert!(result.process_id.is_some()); - assert!(result.truncated_output().contains("codex")); + assert!( + result + .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS) + .contains("codex") + ); Ok(()) } @@ -442,7 +461,11 @@ async fn completed_commands_do_not_persist_sessions() -> anyhow::Result<()> { result.process_id.is_some(), "completed command should report a process id" ); - assert!(result.truncated_output().contains("codex")); + assert!( + result + .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS) + .contains("codex") + ); assert!( session diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index c6fc2ab3e5..2f5de77319 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -581,6 +581,7 @@ impl UnifiedExecProcessManager { chunk_id, wall_time, raw_output: collected, + truncation_policy: context.turn.truncation_policy, max_output_tokens: request.max_output_tokens, process_id: response_process_id, exit_code, @@ -725,6 +726,7 @@ impl UnifiedExecProcessManager { chunk_id, wall_time, raw_output: collected, + truncation_policy: request.truncation_policy, max_output_tokens: request.max_output_tokens, process_id, exit_code, diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 19373b52e4..b733d7bded 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -5,6 +5,7 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; +use codex_core::config::Config; use codex_features::Feature; use codex_login::CodexAuth; use codex_models_manager::bundled_models_response; @@ -144,11 +145,21 @@ async fn run_code_mode_turn( server: &MockServer, prompt: &str, code: &str, +) -> Result<(TestCodex, ResponseMock)> { + run_code_mode_turn_with_config(server, prompt, code, |_| {}).await +} + +async fn run_code_mode_turn_with_config( + server: &MockServer, + prompt: &str, + code: &str, + configure: impl FnOnce(&mut Config) + Send + 'static, ) -> Result<(TestCodex, ResponseMock)> { let mut builder = test_codex() .with_model("test-gpt-5.1-codex") .with_config(move |config| { let _ = config.features.enable(Feature::CodeMode); + configure(config); }); let test = builder.build(server).await?; @@ -292,8 +303,7 @@ text(JSON.stringify(await tools.exec_command({ cmd: "printf code_mode_exec_marke ) .await?; - let req = second_mock.single_request(); - let items = custom_tool_output_items(&req, "call-1"); + let items = custom_tool_output_items(&second_mock.single_request(), "call-1"); assert_eq!(items.len(), 2); assert_regex_match( concat!( @@ -645,40 +655,217 @@ text(JSON.stringify(results)); #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_can_truncate_final_result_with_configured_budget() -> Result<()> { +async fn code_mode_exec_command_explicit_max_output_tokens_truncates() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; let (_test, second_mock) = run_code_mode_turn( &server, - "use exec to truncate the final result", - r#"// @exec: {"max_output_tokens": 6} -text(JSON.stringify(await tools.exec_command({ - cmd: "printf 'token one token two token three token four token five token six token seven'", - max_output_tokens: 100 -}))); + "use exec_command from code mode", + r#" +const result = await tools.exec_command({ + cmd: "printf '0123456789012345678901234567890123456789'", + max_output_tokens: 5 +}); +text(result.output); "#, ) .await?; - let req = second_mock.single_request(); - let items = custom_tool_output_items(&req, "call-1"); - assert_eq!(items.len(), 2); - assert_regex_match( - concat!( - r"(?s)\A", - r"Script completed\nWall time \d+\.\d seconds\nOutput:\n\z" + assert_eq!( + text_item( + &custom_tool_output_items(&second_mock.single_request(), "call-1"), + /*index*/ 1 ), - text_item(&items, /*index*/ 0), + "Total output lines: 1\n\n0123456789…5 tokens truncated…0123456789" + ); + + Ok(()) +} + +#[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<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let (_test, second_mock) = run_code_mode_turn( + &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)\"", + max_output_tokens: 20000 +}); +text(result.output); +"#, + ) + .await?; + + assert_eq!( + text_item( + &custom_tool_output_items(&second_mock.single_request(), "call-1"), + /*index*/ 1 + ), + "x".repeat(50_000) + ); + + Ok(()) +} + +#[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<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let (_test, second_mock) = run_code_mode_turn( + &server, + "use exec_command from code mode", + r#"// @exec: {"max_output_tokens": 25000} +const result = await tools.exec_command({ + cmd: "python3 -c \"import sys; sys.stdout.write('A' * 90000)\"", + max_output_tokens: 20000 +}); +text(result.output); +"#, + ) + .await?; + + assert_eq!( + text_item( + &custom_tool_output_items(&second_mock.single_request(), "call-1"), + /*index*/ 1 + ), + format!( + "Total output lines: 1\n\n{}…2500 tokens truncated…{}", + "A".repeat(40_000), + "A".repeat(40_000) + ) + ); + + Ok(()) +} + +#[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<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let (_test, second_mock) = run_code_mode_turn_with_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)\"", + max_output_tokens: 20000 +}); +text(result.output); +"#, + |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) + ); + + Ok(()) +} + +#[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<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let (_test, second_mock) = run_code_mode_turn( + &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); +"#, + ) + .await?; + + assert_eq!( + text_item( + &custom_tool_output_items(&second_mock.single_request(), "call-1"), + /*index*/ 1 + ), + "x".repeat(50_000) + ); + + Ok(()) +} + +#[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<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let (_test, second_mock) = run_code_mode_turn_with_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); +"#, + |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) + ); + + Ok(()) +} + +#[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<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let (_test, second_mock) = run_code_mode_turn( + &server, + "use exec_command from code mode", + r#"// @exec: {"max_output_tokens": 5} +const result = await tools.exec_command({ + cmd: "printf '0123456789012345678901234567890123456789'" +}); +text(result.output); +"#, + ) + .await?; + + assert_eq!( + text_item( + &custom_tool_output_items(&second_mock.single_request(), "call-1"), + /*index*/ 1 + ), + "Total output lines: 1\n\n0123456789…5 tokens truncated…0123456789" ); - let expected_pattern = r#"(?sx) -\A -Total\ output\ lines:\ 1\n -\n -.*…\d+\ tokens\ truncated….* -\z -"#; - assert_regex_match(expected_pattern, text_item(&items, /*index*/ 1)); Ok(()) } From 9dda71dbae98976bc6d0c1c769810bcf24bb6092 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 19 May 2026 21:56:46 -0700 Subject: [PATCH 20/63] Warn on invalid UTF-8 in AGENTS.md files (#23232) Fixes #23223. ## Why Malformed AGENTS instructions should not fail silently. The reported issue had invalid UTF-8 in a global `AGENTS.md`; before this change, Codex treated that decode failure like a missing file, so the personal instructions disappeared without a user-visible explanation and the rollout had no `# AGENTS.md instructions` block. Project-level AGENTS files already used lossy decoding, so their instructions still appeared, but invalid bytes were replaced without telling the user. Global and project AGENTS files should behave consistently: keep usable instruction text when possible, and surface a diagnostic when bytes had to be replaced. ## What changed Global `AGENTS.override.md` and `AGENTS.md` loading now reads bytes and decodes with replacement characters on invalid UTF-8, matching project-level AGENTS behavior. Both global and project AGENTS loading now emit a startup warning when invalid UTF-8 is found, and both keep the instruction text with invalid byte sequences replaced. Missing files, non-file candidates, empty files, and the existing `AGENTS.override.md` before `AGENTS.md` precedence keep their current behavior. ## How users see it The warnings flow through the existing startup warning surface. App-server clients receive config-time startup warnings as `configWarning` notifications during initialization, and thread startup emits startup warnings as thread-scoped `warning` notifications. Global AGENTS invalid UTF-8 warnings can appear on both surfaces. Project-level AGENTS invalid UTF-8 warnings are discovered while building thread instructions, so they appear as thread-scoped `warning` notifications. Clients that render warning notifications in the conversation surface show the message as a visible diagnostic instead of silently hiding or altering instructions. --- codex-rs/core/src/agents_md.rs | 72 +++++++++++++++++++++------- codex-rs/core/src/agents_md_tests.rs | 59 ++++++++++++++++++++++- codex-rs/core/src/config/mod.rs | 11 +++-- codex-rs/core/src/session/mod.rs | 7 ++- 4 files changed, 126 insertions(+), 23 deletions(-) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 2c635e7002..6daaff4a40 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -62,18 +62,31 @@ impl<'a> AgentsMdManager<'a> { pub(crate) async fn load_global_instructions( fs: &dyn ExecutorFileSystem, codex_dir: Option<&AbsolutePathBuf>, + startup_warnings: &mut Vec, ) -> Option { let base = codex_dir?; for candidate in [LOCAL_AGENTS_MD_FILENAME, DEFAULT_AGENTS_MD_FILENAME] { let path = base.join(candidate); - if let Ok(contents) = fs.read_file_text(&path, /*sandbox*/ None).await { - let trimmed = contents.trim(); - if !trimmed.is_empty() { - return Some(LoadedAgentsMd { - contents: trimmed.to_string(), - path, - }); + let data = match fs.read_file(&path, /*sandbox*/ None).await { + Ok(data) => data, + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Err(err) if err.kind() == io::ErrorKind::IsADirectory => continue, + Err(err) => { + startup_warnings.push(format!( + "Failed to read global AGENTS.md instructions from `{}`: {err}", + path.display() + )); + continue; } + }; + warn_invalid_utf8(&path, &data, "Global", startup_warnings); + let contents = String::from_utf8_lossy(&data); + let trimmed = contents.trim(); + if !trimmed.is_empty() { + return Some(LoadedAgentsMd { + contents: trimmed.to_string(), + path, + }); } } None @@ -84,16 +97,19 @@ impl<'a> AgentsMdManager<'a> { pub(crate) async fn user_instructions( &self, environment: Option<&Environment>, + startup_warnings: &mut Vec, ) -> Option { let fs = environment?.get_filesystem(); - self.user_instructions_with_fs(fs.as_ref()).await + self.user_instructions_with_fs(fs.as_ref(), startup_warnings) + .await } - pub(crate) async fn user_instructions_with_fs( + async fn user_instructions_with_fs( &self, fs: &dyn ExecutorFileSystem, + startup_warnings: &mut Vec, ) -> Option { - let agents_md_docs = self.read_agents_md(fs).await; + let agents_md_docs = self.read_agents_md(fs, startup_warnings).await; let mut output = String::new(); @@ -130,11 +146,15 @@ impl<'a> AgentsMdManager<'a> { /// Returns all instruction source files included in the current config. pub async fn instruction_sources(&self, fs: &dyn ExecutorFileSystem) -> Vec { - let mut paths = - Self::load_global_instructions(LOCAL_FS.as_ref(), Some(&self.config.codex_home)) - .await - .map(|loaded| vec![loaded.path]) - .unwrap_or_default(); + let mut global_instruction_warnings = Vec::new(); + let mut paths = Self::load_global_instructions( + LOCAL_FS.as_ref(), + Some(&self.config.codex_home), + &mut global_instruction_warnings, + ) + .await + .map(|loaded| vec![loaded.path]) + .unwrap_or_default(); match self.agents_md_paths(fs).await { Ok(agents_md_paths) => paths.extend(agents_md_paths), Err(err) => { @@ -150,7 +170,11 @@ impl<'a> AgentsMdManager<'a> { /// concatenation of all discovered docs. If no documentation file is found /// the function returns `Ok(None)`. Unexpected I/O failures bubble up as /// `Err` so callers can decide how to handle them. - async fn read_agents_md(&self, fs: &dyn ExecutorFileSystem) -> io::Result> { + async fn read_agents_md( + &self, + fs: &dyn ExecutorFileSystem, + startup_warnings: &mut Vec, + ) -> io::Result> { let max_total = self.config.project_doc_max_bytes; if max_total == 0 { @@ -182,6 +206,8 @@ impl<'a> AgentsMdManager<'a> { Err(err) if err.kind() == io::ErrorKind::NotFound => continue, Err(err) => return Err(err), }; + warn_invalid_utf8(&p, &data, "Project", startup_warnings); + let size = data.len() as u64; if size > remaining { data.truncate(remaining as usize); @@ -324,6 +350,20 @@ impl<'a> AgentsMdManager<'a> { } } +fn warn_invalid_utf8( + path: &AbsolutePathBuf, + data: &[u8], + source: &str, + startup_warnings: &mut Vec, +) { + if let Err(err) = std::str::from_utf8(data) { + startup_warnings.push(format!( + "{source} AGENTS.md instructions from `{}` contain invalid UTF-8: {err}. Invalid byte sequences were replaced.", + path.display() + )); + } +} + #[cfg(test)] #[path = "agents_md_tests.rs"] mod tests; diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index a3a7544823..2d6a5fa30b 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -7,12 +7,14 @@ use core_test_support::PathBufExt; use core_test_support::TempDirExt; use pretty_assertions::assert_eq; use std::fs; +use std::path::Path; use std::path::PathBuf; use tempfile::TempDir; async fn get_user_instructions(config: &Config) -> Option { + let mut warnings = Vec::new(); AgentsMdManager::new(config) - .user_instructions_with_fs(LOCAL_FS.as_ref()) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await } @@ -22,6 +24,19 @@ async fn agents_md_paths(config: &Config) -> std::io::Result Date: Tue, 19 May 2026 22:03:08 -0700 Subject: [PATCH 21/63] feat: Add vertical remote plugin collection support (#23584) - Adds an explicit vertical marketplace kind for plugin/list that fail-open fetches collection=vertical only when full remote plugins are disabled. - Renames the global remote marketplace/cache identity to openai-curated-remote and materializes remote installs with backend release versions and app manifests. --- .../schema/json/ClientRequest.json | 1 + .../codex_app_server_protocol.schemas.json | 1 + .../codex_app_server_protocol.v2.schemas.json | 1 + .../schema/json/v2/PluginListParams.json | 1 + .../v2/PluginListMarketplaceKind.ts | 2 +- .../src/protocol/v2/plugin.rs | 3 + .../src/protocol/v2/tests.rs | 22 +- .../src/request_processors/plugins.rs | 40 +- .../tests/suite/v2/plugin_install.rs | 109 +++- .../app-server/tests/suite/v2/plugin_list.rs | 544 +++++++++++++----- .../app-server/tests/suite/v2/plugin_read.rs | 22 +- .../tests/suite/v2/plugin_uninstall.rs | 16 +- .../app-server/tests/suite/v2/skills_list.rs | 2 +- codex-rs/core-plugins/src/manager_tests.rs | 8 +- codex-rs/core-plugins/src/remote.rs | 69 ++- .../remote/remote_installed_plugin_sync.rs | 3 +- .../core-plugins/src/remote/share/checkout.rs | 1 + codex-rs/core-plugins/src/remote_bundle.rs | 182 +++++- 18 files changed, 806 insertions(+), 221 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 506358d25d..ee68faf001 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1632,6 +1632,7 @@ "PluginListMarketplaceKind": { "enum": [ "local", + "vertical", "workspace-directory", "shared-with-me" ], 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 73e4335bf9..3db4a87ef2 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 @@ -12193,6 +12193,7 @@ "PluginListMarketplaceKind": { "enum": [ "local", + "vertical", "workspace-directory", "shared-with-me" ], 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 ea8655d0fe..7b62d6d4bf 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 @@ -8742,6 +8742,7 @@ "PluginListMarketplaceKind": { "enum": [ "local", + "vertical", "workspace-directory", "shared-with-me" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json index 65b1b4e88d..9c15ed5a7c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json @@ -8,6 +8,7 @@ "PluginListMarketplaceKind": { "enum": [ "local", + "vertical", "workspace-directory", "shared-with-me" ], diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts index 6ff6161f34..1be75e6f02 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.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 PluginListMarketplaceKind = "local" | "workspace-directory" | "shared-with-me"; +export type PluginListMarketplaceKind = "local" | "vertical" | "workspace-directory" | "shared-with-me"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 33c2670077..70d283994e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -144,6 +144,9 @@ pub enum PluginListMarketplaceKind { #[serde(rename = "local")] #[ts(rename = "local")] Local, + #[serde(rename = "vertical")] + #[ts(rename = "vertical")] + Vertical, #[serde(rename = "workspace-directory")] #[ts(rename = "workspace-directory")] WorkspaceDirectory, 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 2ee583f991..09a8b609be 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2708,14 +2708,14 @@ fn marketplace_upgrade_params_serialization_uses_optional_marketplace_name() { fn plugin_marketplace_entry_serializes_remote_only_path_as_null() { assert_eq!( serde_json::to_value(PluginMarketplaceEntry { - name: "openai-curated".to_string(), + name: "openai-curated-remote".to_string(), path: None, interface: None, plugins: Vec::new(), }) .unwrap(), json!({ - "name": "openai-curated", + "name": "openai-curated-remote", "path": null, "interface": null, "plugins": [], @@ -2799,6 +2799,7 @@ fn plugin_list_params_serializes_marketplace_kind_filter() { cwds: None, marketplace_kinds: Some(vec![ PluginListMarketplaceKind::Local, + PluginListMarketplaceKind::Vertical, PluginListMarketplaceKind::WorkspaceDirectory, PluginListMarketplaceKind::SharedWithMe, ]), @@ -2808,6 +2809,7 @@ fn plugin_list_params_serializes_marketplace_kind_filter() { "cwds": null, "marketplaceKinds": [ "local", + "vertical", "workspace-directory", "shared-with-me", ], @@ -2875,13 +2877,13 @@ fn plugin_read_params_serialization_uses_install_source_fields() { assert_eq!( serde_json::from_value::(json!({ - "remoteMarketplaceName": "openai-curated", + "remoteMarketplaceName": "openai-curated-remote", "pluginName": "gmail", })) .unwrap(), PluginReadParams { marketplace_path: None, - remote_marketplace_name: Some("openai-curated".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "gmail".to_string(), }, ); @@ -2926,14 +2928,14 @@ fn plugin_install_params_serialization_omits_force_remote_sync() { assert_eq!( serde_json::from_value::(json!({ - "remoteMarketplaceName": "openai-curated", + "remoteMarketplaceName": "openai-curated-remote", "pluginName": "gmail", "forceRemoteSync": true, })) .unwrap(), PluginInstallParams { marketplace_path: None, - remote_marketplace_name: Some("openai-curated".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "gmail".to_string(), }, ); @@ -2943,13 +2945,13 @@ fn plugin_install_params_serialization_omits_force_remote_sync() { fn plugin_skill_read_params_serialization_uses_remote_plugin_id() { assert_eq!( serde_json::to_value(PluginSkillReadParams { - remote_marketplace_name: "chatgpt-global".to_string(), + remote_marketplace_name: "openai-curated-remote".to_string(), remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), skill_name: "plan-work".to_string(), }) .unwrap(), json!({ - "remoteMarketplaceName": "chatgpt-global", + "remoteMarketplaceName": "openai-curated-remote", "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", "skillName": "plan-work", }), @@ -3144,7 +3146,7 @@ fn plugin_share_list_response_serializes_share_items() { serde_json::to_value(PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "gmail@chatgpt-global".to_string(), + id: "gmail@openai-curated-remote".to_string(), remote_plugin_id: Some( "plugins~Plugin_00000000000000000000000000000000".to_string(), ), @@ -3167,7 +3169,7 @@ fn plugin_share_list_response_serializes_share_items() { json!({ "data": [{ "plugin": { - "id": "gmail@chatgpt-global", + "id": "gmail@openai-curated-remote", "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", "localVersion": null, "name": "gmail", diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 199ddf1191..61fd363b2e 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -475,6 +475,7 @@ impl PluginRequestProcessor { let marketplace_kinds = marketplace_kinds.unwrap_or_else(|| vec![PluginListMarketplaceKind::Local]); let include_local = marketplace_kinds.contains(&PluginListMarketplaceKind::Local); + let include_vertical = marketplace_kinds.contains(&PluginListMarketplaceKind::Vertical); let config = self.load_latest_config(/*fallback_cwd*/ None).await?; let empty_response = || PluginListResponse { @@ -564,6 +565,35 @@ impl PluginRequestProcessor { (Vec::new(), Vec::new()) }; + // TODO(remote plugins): Remove this once remote plugins are ready and vertical plugins are + // served directly from the normal remote catalog. + if include_vertical && !config.features.enabled(Feature::RemotePlugin) { + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + match codex_core_plugins::remote::fetch_openai_curated_remote_collection_marketplace( + &remote_plugin_service_config, + auth.as_ref(), + ) + .await + { + Ok(Some(remote_marketplace)) => { + data.push(remote_marketplace_to_info(remote_marketplace)); + } + Ok(None) => {} + Err( + RemotePluginCatalogError::AuthRequired + | RemotePluginCatalogError::UnsupportedAuthMode, + ) => {} + Err(err) => { + warn!( + error = %err, + "plugin/list openai-curated-remote collection fetch failed; returning local marketplaces only" + ); + } + } + } + let mut remote_sources = Vec::new(); if !explicit_marketplace_kinds && config.features.enabled(Feature::RemotePlugin) { remote_sources.push(RemoteMarketplaceSource::Global); @@ -592,14 +622,7 @@ impl PluginRequestProcessor { .into_iter() .map(remote_marketplace_to_info) { - if let Some(existing) = data - .iter_mut() - .find(|marketplace| marketplace.name == remote_marketplace.name) - { - *existing = remote_marketplace; - } else { - data.push(remote_marketplace); - } + data.push(remote_marketplace); } } Err( @@ -1407,6 +1430,7 @@ impl PluginRequestProcessor { &remote_detail.summary.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)?; 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 6adcd92195..9d17e8747f 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -132,7 +132,7 @@ async fn plugin_install_rejects_multiple_install_sources() -> Result<()> { marketplace_path: Some(AbsolutePathBuf::try_from( codex_home.path().join("marketplace.json"), )?), - remote_marketplace_name: Some("openai-curated".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "sample-plugin".to_string(), }) .await?; @@ -167,7 +167,7 @@ plugins = false let request_id = mcp .send_plugin_install_request(PluginInstallParams { marketplace_path: None, - remote_marketplace_name: Some("chatgpt-global".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "plugins~Plugin_22222222222222222222222222222222".to_string(), }) .await?; @@ -193,15 +193,32 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()> let server = MockServer::start().await; let installed_path = codex_home .path() - .join("plugins/cache/chatgpt-global/linear/1.2.3"); + .join("plugins/cache/openai-curated-remote/linear/1.2.3"); + let remote_app_manifest = json!({ + "apps": { + "linear-remote": { + "id": "remote-linear-app" + } + } + }); let bundle_url = mount_remote_plugin_bundle( &server, /*status_code*/ 200, - remote_plugin_bundle_tar_gz_bytes("linear")?, + remote_plugin_bundle_tar_gz_bytes_with_contents( + r#"{"name":"linear","version":"0.0.1"}"#, + Some(r#"{"apps":{"linear-bundled":{"id":"bundled-linear-app"}}}"#), + )?, ) .await; configure_remote_plugin_test(codex_home.path(), &server)?; - mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; + mount_remote_plugin_detail_with_app_manifest( + &server, + REMOTE_PLUGIN_ID, + "1.2.3", + Some(&bundle_url), + remote_app_manifest.clone(), + ) + .await; mount_empty_remote_installed_plugins(&server).await; mount_remote_plugin_install_after_cache_write( &server, @@ -247,12 +264,20 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()> ) .await?; assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); + let installed_plugin_manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(installed_path.join(".codex-plugin/plugin.json"))?, + )?; + assert_eq!(installed_plugin_manifest["name"], json!("linear")); + assert_eq!(installed_plugin_manifest["version"], json!("1.2.3")); + let installed_app_manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(installed_path.join(".app.json"))?)?; + assert_eq!(installed_app_manifest, remote_app_manifest); assert!(installed_path.join("skills/plan-work/SKILL.md").is_file()); assert!( !codex_home .path() .join(format!( - "plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}/1.2.3" + "plugins/cache/openai-curated-remote/{REMOTE_PLUGIN_ID}/1.2.3" )) .exists() ); @@ -299,7 +324,7 @@ async fn plugin_install_rejects_missing_remote_bundle_url() -> Result<()> { assert!( !codex_home .path() - .join("plugins/cache/chatgpt-global/linear") + .join("plugins/cache/openai-curated-remote/linear") .exists() ); Ok(()) @@ -340,7 +365,7 @@ async fn plugin_install_rejects_plain_http_remote_bundle_url() -> Result<()> { assert!( !codex_home .path() - .join("plugins/cache/chatgpt-global/linear") + .join("plugins/cache/openai-curated-remote/linear") .exists() ); Ok(()) @@ -382,7 +407,7 @@ async fn plugin_install_rejects_invalid_remote_release_version() -> Result<()> { assert!( !codex_home .path() - .join("plugins/cache/chatgpt-global/linear") + .join("plugins/cache/openai-curated-remote/linear") .exists() ); Ok(()) @@ -398,7 +423,7 @@ async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> { let request_id = mcp .send_plugin_install_request(PluginInstallParams { marketplace_path: None, - remote_marketplace_name: Some("chatgpt-global".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "linear/../../oops".to_string(), }) .await?; @@ -468,7 +493,7 @@ async fn plugin_install_rejects_remote_plugin_disabled_by_admin_before_download( assert!( !codex_home .path() - .join("plugins/cache/chatgpt-global/linear") + .join("plugins/cache/openai-curated-remote/linear") .exists() ); Ok(()) @@ -764,7 +789,7 @@ async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> { "event_params": { "plugin_id": REMOTE_PLUGIN_ID, "plugin_name": "linear", - "marketplace_name": "chatgpt-global", + "marketplace_name": "openai-curated-remote", "has_skills": true, "mcp_server_count": 0, "connector_ids": [], @@ -824,7 +849,7 @@ async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> assert!( !codex_home .path() - .join("plugins/cache/chatgpt-global/linear") + .join("plugins/cache/openai-curated-remote/linear") .exists() ); Ok(()) @@ -1349,12 +1374,49 @@ async fn mount_remote_plugin_detail( .await; } +async fn mount_remote_plugin_detail_with_app_manifest( + server: &MockServer, + remote_plugin_id: &str, + release_version: &str, + bundle_download_url: Option<&str>, + app_manifest: serde_json::Value, +) { + mount_remote_plugin_detail_with_status_and_app_manifest( + server, + remote_plugin_id, + release_version, + bundle_download_url, + PluginAvailability::Available, + Some(app_manifest), + ) + .await; +} + async fn mount_remote_plugin_detail_with_status( server: &MockServer, remote_plugin_id: &str, release_version: &str, bundle_download_url: Option<&str>, status: PluginAvailability, +) { + mount_remote_plugin_detail_with_status_and_app_manifest( + server, + remote_plugin_id, + release_version, + bundle_download_url, + status, + /*app_manifest*/ None, + ) + .await; +} + +async fn mount_remote_plugin_detail_with_status_and_app_manifest( + server: &MockServer, + remote_plugin_id: &str, + release_version: &str, + bundle_download_url: Option<&str>, + status: PluginAvailability, + app_manifest: Option, ) { let status = match status { PluginAvailability::Available => "ENABLED", @@ -1363,6 +1425,9 @@ async fn mount_remote_plugin_detail_with_status( let bundle_download_url_field = bundle_download_url .map(|url| format!(r#" "bundle_download_url": "{url}","#)) .unwrap_or_default(); + let app_manifest_field = app_manifest + .map(|manifest| format!(r#" "app_manifest": {manifest},"#)) + .unwrap_or_default(); let detail_body = format!( r#"{{ "id": "{remote_plugin_id}", @@ -1377,6 +1442,7 @@ async fn mount_remote_plugin_detail_with_status( "display_name": "Linear", "description": "Track work in Linear", "app_ids": [], +{app_manifest_field} "interface": {{ "short_description": "Plan and track work" }}, @@ -1576,13 +1642,20 @@ fn write_plugin_source( fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result> { let manifest = format!(r#"{{"name":"{plugin_name}"}}"#); + remote_plugin_bundle_tar_gz_bytes_with_contents(&manifest, /*app_manifest*/ None) +} + +fn remote_plugin_bundle_tar_gz_bytes_with_contents( + plugin_manifest: &str, + app_manifest: Option<&str>, +) -> Result> { let skill = "# Plan Work\n\nTrack work in Linear.\n"; let encoder = GzEncoder::new(Vec::new(), Compression::default()); let mut tar = tar::Builder::new(encoder); - for (path, contents, mode) in [ + let mut entries = vec![ ( ".codex-plugin/plugin.json", - manifest.as_bytes(), + plugin_manifest.as_bytes(), /*mode*/ 0o644, ), ( @@ -1590,7 +1663,11 @@ fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result> { skill.as_bytes(), /*mode*/ 0o644, ), - ] { + ]; + if let Some(app_manifest) = app_manifest { + entries.push((".app.json", app_manifest.as_bytes(), /*mode*/ 0o644)); + } + for (path, contents, mode) in entries { let mut header = tar::Header::new_gnu(); header.set_size(contents.len() as u64); header.set_mode(mode); diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index d439a4585b..93dbfb4773 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -1470,15 +1470,26 @@ async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> remote_plugin_bundle_tar_gz_bytes("linear")?, ) .await; - let global_installed_body = - remote_installed_plugin_body(&bundle_url, "1.2.3", /*enabled*/ true); + let remote_app_manifest = serde_json::json!({ + "apps": { + "linear-remote": { + "id": "remote-linear-app" + } + } + }); + let global_installed_body = remote_installed_plugin_body_with_app_manifest( + &bundle_url, + "1.2.3", + /*enabled*/ true, + remote_app_manifest.clone(), + ); mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await; mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) .await; let installed_path = codex_home .path() - .join("plugins/cache/chatgpt-global/linear/1.2.3"); + .join("plugins/cache/openai-curated-remote/linear/1.2.3"); let mut mcp = McpProcess::new_with_env_and_plugin_startup_tasks( codex_home.path(), &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], @@ -1487,9 +1498,19 @@ async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; wait_for_path_exists(&installed_path.join(".codex-plugin/plugin.json")).await?; + let installed_plugin_manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(installed_path.join(".codex-plugin/plugin.json"))?, + )?; + assert_eq!( + installed_plugin_manifest["version"], + serde_json::json!("1.2.3") + ); + let installed_app_manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(installed_path.join(".app.json"))?)?; + assert_eq!(installed_app_manifest, remote_app_manifest); assert!(installed_path.join("skills/plan-work/SKILL.md").is_file()); let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(!config.contains("linear@chatgpt-global")); + assert!(!config.contains("linear@openai-curated-remote")); Ok(()) } @@ -1509,8 +1530,8 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() .chatgpt_account_id("account-123"), AuthCredentialsStoreMode::File, )?; - write_installed_plugin_with_version(&codex_home, "chatgpt-global", "linear", "1.0.0")?; - write_installed_plugin_with_version(&codex_home, "chatgpt-global", "stale", "1.0.0")?; + write_installed_plugin_with_version(&codex_home, "openai-curated-remote", "linear", "1.0.0")?; + write_installed_plugin_with_version(&codex_home, "openai-curated-remote", "stale", "1.0.0")?; let bundle_url = mount_remote_plugin_bundle( &server, @@ -1518,8 +1539,19 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() remote_plugin_bundle_tar_gz_bytes("linear")?, ) .await; - let global_installed_body = - remote_installed_plugin_body(&bundle_url, "1.2.3", /*enabled*/ true); + let remote_app_manifest = serde_json::json!({ + "apps": { + "linear-remote": { + "id": "remote-linear-app" + } + } + }); + let global_installed_body = remote_installed_plugin_body_with_app_manifest( + &bundle_url, + "1.2.3", + /*enabled*/ true, + remote_app_manifest.clone(), + ); mount_remote_plugin_list(&server, "GLOBAL", &global_installed_body).await; mount_remote_plugin_list(&server, "WORKSPACE", empty_remote_installed_plugins_body()).await; mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await; @@ -1528,11 +1560,13 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() let old_path = codex_home .path() - .join("plugins/cache/chatgpt-global/linear/1.0.0"); + .join("plugins/cache/openai-curated-remote/linear/1.0.0"); let new_path = codex_home .path() - .join("plugins/cache/chatgpt-global/linear/1.2.3"); - let stale_path = codex_home.path().join("plugins/cache/chatgpt-global/stale"); + .join("plugins/cache/openai-curated-remote/linear/1.2.3"); + let stale_path = codex_home + .path() + .join("plugins/cache/openai-curated-remote/stale"); let mut mcp = McpProcess::new_with_env( codex_home.path(), @@ -1556,22 +1590,32 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() let remote_marketplace = response .marketplaces .into_iter() - .find(|marketplace| marketplace.name == "chatgpt-global") - .expect("expected chatgpt-global marketplace entry"); + .find(|marketplace| marketplace.name == "openai-curated-remote") + .expect("expected openai-curated-remote marketplace entry"); assert_eq!( remote_marketplace .plugins .into_iter() .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) .collect::>(), - vec![("linear@chatgpt-global".to_string(), true, true)] + vec![("linear@openai-curated-remote".to_string(), true, true)] ); wait_for_path_exists(&new_path.join(".codex-plugin/plugin.json")).await?; + let installed_plugin_manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(new_path.join(".codex-plugin/plugin.json"))?, + )?; + assert_eq!( + installed_plugin_manifest["version"], + serde_json::json!("1.2.3") + ); + let installed_app_manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(new_path.join(".app.json"))?)?; + assert_eq!(installed_app_manifest, remote_app_manifest); wait_for_path_missing(&old_path).await?; wait_for_path_missing(&stale_path).await?; let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(!config.contains("linear@chatgpt-global")); + assert!(!config.contains("linear@openai-curated-remote")); Ok(()) } @@ -1714,18 +1758,21 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - let remote_marketplace = response .marketplaces .into_iter() - .find(|marketplace| marketplace.name == "chatgpt-global") - .expect("expected ChatGPT remote marketplace"); + .find(|marketplace| marketplace.name == "openai-curated-remote") + .expect("expected openai-curated remote marketplace"); assert_eq!(remote_marketplace.path, None); assert_eq!( remote_marketplace .interface .as_ref() .and_then(|interface| interface.display_name.as_deref()), - Some("ChatGPT Plugins") + Some("OpenAI Curated Remote") ); assert_eq!(remote_marketplace.plugins.len(), 1); - assert_eq!(remote_marketplace.plugins[0].id, "linear@chatgpt-global"); + assert_eq!( + remote_marketplace.plugins[0].id, + "linear@openai-curated-remote" + ); assert_eq!( remote_marketplace.plugins[0].remote_plugin_id.as_deref(), Some("plugins~Plugin_00000000000000000000000000000000") @@ -1753,6 +1800,283 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - ] ); assert_eq!(response.featured_plugin_ids, Vec::::new()); + assert!( + !server + .received_requests() + .await + .expect("wiremock should record requests") + .iter() + .any(|request| request + .url + .query_pairs() + .any(|(name, value)| name == "collection" && value == "vertical")) + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_includes_openai_curated_remote_collection_when_requested() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", 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 collection_body = r#"{ + "plugins": [ + { + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "ENABLED", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": { + "short_description": "Plan and track work", + "capabilities": ["Read", "Write"] + }, + "skills": [] + } + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + mount_openai_curated_remote_collection_plugin_list(&server, collection_body).await; + mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await; + mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + let remote_marketplace = response + .marketplaces + .into_iter() + .find(|marketplace| marketplace.name == "openai-curated-remote") + .expect("expected openai-curated remote marketplace"); + assert_eq!(remote_marketplace.path, None); + assert_eq!( + remote_marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("OpenAI Curated Remote") + ); + assert_eq!(remote_marketplace.plugins.len(), 1); + let plugin = &remote_marketplace.plugins[0]; + assert_eq!(plugin.id, "linear@openai-curated-remote"); + assert_eq!( + plugin.remote_plugin_id.as_deref(), + Some("plugins~Plugin_00000000000000000000000000000000") + ); + assert_eq!(plugin.name, "linear"); + assert_eq!(plugin.source, PluginSource::Remote); + assert_eq!(plugin.installed, false); + assert_eq!(plugin.enabled, false); + + let requests = server + .received_requests() + .await + .expect("wiremock should record requests"); + assert!(requests.iter().any(|request| { + request.method == "GET" + && request.url.path().ends_with("/ps/plugins/list") + && request + .url + .query_pairs() + .any(|(name, value)| name == "collection" && value == "vertical") + })); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_fail_opens_openai_curated_remote_collection_errors() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", 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, + )?; + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/list")) + .and(query_param("scope", "GLOBAL")) + .and(query_param("limit", "200")) + .and(query_param("collection", "vertical")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(500).set_body_string("temporary failure")) + .mount(&server) + .await; + mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await; + mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert!( + response + .marketplaces + .iter() + .all(|marketplace| marketplace.name != "openai-curated-remote") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_does_not_query_openai_curated_remote_collection_by_default() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", 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 mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert!( + response + .marketplaces + .iter() + .all(|marketplace| marketplace.name != "openai-curated-remote") + ); + assert!( + server + .received_requests() + .await + .expect("wiremock should record requests") + .iter() + .all(|request| !request + .url + .query_pairs() + .any(|(name, value)| name == "collection" && value == "vertical")) + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_vertical_kind_noops_when_remote_plugin_enabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", 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 mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert!( + response + .marketplaces + .iter() + .all(|marketplace| marketplace.name != "openai-curated-remote") + ); + assert!( + server + .received_requests() + .await + .expect("wiremock should record requests") + .iter() + .all(|request| !request + .url + .query_pairs() + .any(|(name, value)| name == "collection" && value == "vertical")) + ); Ok(()) } @@ -1795,7 +2119,7 @@ async fn plugin_list_does_not_append_global_remote_when_marketplace_kinds_are_ex response .marketplaces .iter() - .all(|marketplace| marketplace.name != "chatgpt-global") + .all(|marketplace| marketplace.name != "openai-curated-remote") ); wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; Ok(()) @@ -1962,18 +2286,18 @@ plugin_sharing = false )?; assert_eq!(response.marketplaces.len(), 1); - assert_eq!(response.marketplaces[0].name, "chatgpt-global"); + assert_eq!(response.marketplaces[0].name, "openai-curated-remote"); assert_eq!( response.marketplaces[0] .plugins .iter() .map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled)) .collect::>(), - vec![("linear@chatgpt-global".to_string(), true, true)] + vec![("linear@openai-curated-remote".to_string(), true, true)] ); let installed_path = codex_home .path() - .join("plugins/cache/chatgpt-global/linear/1.2.3/.codex-plugin/plugin.json"); + .join("plugins/cache/openai-curated-remote/linear/1.2.3/.codex-plugin/plugin.json"); wait_for_path_exists(&installed_path).await?; wait_for_remote_installed_scope_request(&server, "GLOBAL").await?; wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?; @@ -2442,7 +2766,7 @@ async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> { let remote_marketplace = response .marketplaces .into_iter() - .find(|marketplace| marketplace.name == "chatgpt-global") + .find(|marketplace| marketplace.name == "openai-curated-remote") .expect("expected ChatGPT remote marketplace"); let plugin = remote_marketplace .plugins @@ -2457,133 +2781,6 @@ async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> { Ok(()) } -#[tokio::test] -async fn plugin_list_remote_marketplace_replaces_local_marketplace_with_same_name() -> Result<()> { - let codex_home = TempDir::new()?; - let server = MockServer::start().await; - write_remote_plugin_catalog_config( - codex_home.path(), - &format!("{}/backend-api/", 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 local_plugin_root = codex_home - .path() - .join(".agents/plugins/plugins/local-linear/.codex-plugin"); - std::fs::create_dir_all(&local_plugin_root)?; - std::fs::write( - codex_home.path().join(".agents/plugins/marketplace.json"), - r#"{ - "name": "chatgpt-global", - "plugins": [ - { - "name": "local-linear", - "source": { - "source": "local", - "path": "./plugins/local-linear" - } - } - ] -}"#, - )?; - std::fs::write( - local_plugin_root.join("plugin.json"), - r#"{"name":"local-linear"}"#, - )?; - - let global_directory_body = r#"{ - "plugins": [ - { - "id": "plugins~Plugin_00000000000000000000000000000000", - "name": "linear", - "scope": "GLOBAL", - "installation_policy": "AVAILABLE", - "authentication_policy": "ON_USE", - "release": { - "display_name": "Linear", - "description": "Track work in Linear", - "app_ids": [], - "interface": {}, - "skills": [] - } - } - ], - "pagination": { - "limit": 50, - "next_page_token": null - } -}"#; - let empty_page_body = r#"{ - "plugins": [], - "pagination": { - "limit": 50, - "next_page_token": null - } -}"#; - for (scope, body) in [ - ("GLOBAL", global_directory_body), - ("WORKSPACE", empty_page_body), - ] { - Mock::given(method("GET")) - .and(path("/backend-api/ps/plugins/list")) - .and(query_param("scope", scope)) - .and(query_param("limit", "200")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string(body)) - .mount(&server) - .await; - } - for scope in ["GLOBAL", "WORKSPACE"] { - Mock::given(method("GET")) - .and(path("/backend-api/ps/plugins/installed")) - .and(query_param("scope", scope)) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body)) - .mount(&server) - .await; - } - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - - let request_id = mcp - .send_plugin_list_request(PluginListParams { - cwds: None, - marketplace_kinds: None, - }) - .await?; - - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let response: PluginListResponse = to_response(response)?; - let matching_marketplaces = response - .marketplaces - .iter() - .filter(|marketplace| marketplace.name == "chatgpt-global") - .collect::>(); - - assert_eq!(matching_marketplaces.len(), 1); - assert_eq!(matching_marketplaces[0].path, None); - assert_eq!(matching_marketplaces[0].plugins.len(), 1); - assert_eq!( - matching_marketplaces[0].plugins[0].source, - PluginSource::Remote - ); - assert_eq!(matching_marketplaces[0].plugins[0].name, "linear"); - Ok(()) -} - #[tokio::test] async fn plugin_list_does_not_fetch_remote_marketplaces_when_plugins_disabled() -> Result<()> { let codex_home = TempDir::new()?; @@ -2817,6 +3014,19 @@ async fn mount_remote_plugin_list(server: &MockServer, scope: &str, body: &str) .await; } +async fn mount_openai_curated_remote_collection_plugin_list(server: &MockServer, body: &str) { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/list")) + .and(query_param("scope", "GLOBAL")) + .and(query_param("limit", "200")) + .and(query_param("collection", "vertical")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(server) + .await; +} + async fn mount_shared_workspace_plugins(server: &MockServer, body: &str) { Mock::given(method("GET")) .and(path("/backend-api/ps/plugins/workspace/shared")) @@ -2910,6 +3120,37 @@ fn remote_installed_plugin_body( release_version: &str, enabled: bool, ) -> String { + remote_installed_plugin_body_with_optional_app_manifest( + bundle_download_url, + release_version, + enabled, + /*app_manifest*/ None, + ) +} + +fn remote_installed_plugin_body_with_app_manifest( + bundle_download_url: &str, + release_version: &str, + enabled: bool, + app_manifest: serde_json::Value, +) -> String { + remote_installed_plugin_body_with_optional_app_manifest( + bundle_download_url, + release_version, + enabled, + Some(app_manifest), + ) +} + +fn remote_installed_plugin_body_with_optional_app_manifest( + bundle_download_url: &str, + release_version: &str, + enabled: bool, + app_manifest: Option, +) -> String { + let app_manifest_field = app_manifest + .map(|manifest| format!(r#" "app_manifest": {manifest},"#)) + .unwrap_or_default(); format!( r#"{{ "plugins": [ @@ -2925,6 +3166,7 @@ fn remote_installed_plugin_body( "description": "Track work in Linear", "bundle_download_url": "{bundle_download_url}", "app_ids": [], +{app_manifest_field} "interface": {{}}, "skills": [] }}, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 812f884a55..3ec2366f54 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -101,7 +101,7 @@ async fn plugin_read_rejects_multiple_read_sources() -> Result<()> { marketplace_path: Some(AbsolutePathBuf::try_from( codex_home.path().join("marketplace.json"), )?), - remote_marketplace_name: Some("openai-curated".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "sample-plugin".to_string(), }) .await?; @@ -196,7 +196,7 @@ plugins = true let request_id = mcp .send_plugin_read_request(PluginReadParams { marketplace_path: None, - remote_marketplace_name: Some("chatgpt-global".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(), }) .await?; @@ -208,8 +208,8 @@ plugins = true .await??; let response: PluginReadResponse = to_response(response)?; - assert_eq!(response.plugin.marketplace_name, "chatgpt-global"); - assert_eq!(response.plugin.summary.id, "linear@chatgpt-global"); + assert_eq!(response.plugin.marketplace_name, "openai-curated-remote"); + assert_eq!(response.plugin.summary.id, "linear@openai-curated-remote"); assert_eq!( response.plugin.summary.remote_plugin_id.as_deref(), Some("plugins~Plugin_00000000000000000000000000000000") @@ -484,7 +484,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> let request_id = mcp .send_plugin_read_request(PluginReadParams { marketplace_path: None, - remote_marketplace_name: Some("chatgpt-global".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(), }) .await?; @@ -496,10 +496,10 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> .await??; let response: PluginReadResponse = to_response(response)?; - assert_eq!(response.plugin.marketplace_name, "chatgpt-global"); + assert_eq!(response.plugin.marketplace_name, "openai-curated-remote"); assert_eq!(response.plugin.marketplace_path, None); assert_eq!(response.plugin.summary.source, PluginSource::Remote); - assert_eq!(response.plugin.summary.id, "linear@chatgpt-global"); + assert_eq!(response.plugin.summary.id, "linear@openai-curated-remote"); assert_eq!( response.plugin.summary.remote_plugin_id.as_deref(), Some("plugins~Plugin_00000000000000000000000000000000") @@ -568,7 +568,7 @@ async fn plugin_skill_read_reads_remote_skill_contents_when_remote_plugin_enable let request_id = mcp .send_plugin_skill_read_request(PluginSkillReadParams { - remote_marketplace_name: "chatgpt-global".to_string(), + remote_marketplace_name: "openai-curated-remote".to_string(), remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), skill_name: "plan-work".to_string(), }) @@ -621,7 +621,7 @@ async fn plugin_read_maps_missing_remote_plugin_to_invalid_request() -> Result<( let request_id = mcp .send_plugin_read_request(PluginReadParams { marketplace_path: None, - remote_marketplace_name: Some("chatgpt-global".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "plugins~Plugin_missing".to_string(), }) .await?; @@ -673,7 +673,7 @@ remote_plugin = true let request_id = mcp .send_plugin_read_request(PluginReadParams { marketplace_path: None, - remote_marketplace_name: Some("chatgpt-global".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "linear".to_string(), }) .await?; @@ -703,7 +703,7 @@ async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> { let request_id = mcp .send_plugin_read_request(PluginReadParams { marketplace_path: None, - remote_marketplace_name: Some("chatgpt-global".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "linear/../../oops".to_string(), }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs index 8fdf149a01..4feede934d 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -219,15 +219,15 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl let remote_plugin_cache_root = codex_home .path() - .join("plugins/cache/chatgpt-global/linear"); + .join("plugins/cache/openai-curated-remote/linear"); std::fs::create_dir_all(remote_plugin_cache_root.join("1.0.0/.codex-plugin"))?; std::fs::write( remote_plugin_cache_root.join("1.0.0/.codex-plugin/plugin.json"), r#"{"name":"linear","version":"1.0.0"}"#, )?; - let legacy_remote_plugin_cache_root = codex_home - .path() - .join(format!("plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}")); + let legacy_remote_plugin_cache_root = codex_home.path().join(format!( + "plugins/cache/openai-curated-remote/{REMOTE_PLUGIN_ID}" + )); std::fs::create_dir_all(legacy_remote_plugin_cache_root.join("local/.codex-plugin"))?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -299,7 +299,7 @@ async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()> )?; let global_cache_root = codex_home .path() - .join("plugins/cache/chatgpt-global/linear"); + .join("plugins/cache/openai-curated-remote/linear"); std::fs::create_dir_all(global_cache_root.join("1.0.0/.codex-plugin"))?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -420,9 +420,9 @@ async fn plugin_uninstall_rejects_before_post_when_remote_detail_fetch_fails() - AuthCredentialsStoreMode::File, )?; - let legacy_remote_plugin_cache_root = codex_home - .path() - .join(format!("plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}")); + let legacy_remote_plugin_cache_root = codex_home.path().join(format!( + "plugins/cache/openai-curated-remote/{REMOTE_PLUGIN_ID}" + )); std::fs::create_dir_all(legacy_remote_plugin_cache_root.join("local/.codex-plugin"))?; let mut mcp = McpProcess::new(codex_home.path()).await?; diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index db982bc5d0..74100bc19f 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -117,7 +117,7 @@ fn write_plugin_with_skill( fn write_cached_remote_plugin_with_skill( codex_home: &std::path::Path, ) -> Result { - let plugin_root = codex_home.join("plugins/cache/chatgpt-global/linear/local"); + let plugin_root = codex_home.join("plugins/cache/openai-curated-remote/linear/local"); std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; std::fs::write( plugin_root.join(".codex-plugin/plugin.json"), diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 791a143c4f..53f72ed508 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -150,7 +150,7 @@ async fn load_config(codex_home: &Path, cwd: &Path) -> PluginsConfigInput { fn remote_installed_linear_plugin() -> RemoteInstalledPlugin { RemoteInstalledPlugin { - marketplace_name: "chatgpt-global".to_string(), + marketplace_name: "openai-curated-remote".to_string(), id: "plugins~Plugin_linear".to_string(), name: "linear".to_string(), enabled: true, @@ -401,11 +401,11 @@ async fn build_remote_installed_plugin_marketplaces_from_cache_uses_remote_metad .build_remote_installed_plugin_marketplaces_from_cache(&[RemotePluginScope::Global]) .expect("remote installed cache should be present"); assert_eq!(marketplaces.len(), 1); - assert_eq!(marketplaces[0].name, "chatgpt-global"); - assert_eq!(marketplaces[0].display_name, "ChatGPT Plugins"); + assert_eq!(marketplaces[0].name, "openai-curated-remote"); + assert_eq!(marketplaces[0].display_name, "OpenAI Curated Remote"); assert_eq!(marketplaces[0].plugins.len(), 1); let plugin = &marketplaces[0].plugins[0]; - assert_eq!(plugin.id, "linear@chatgpt-global"); + assert_eq!(plugin.id, "linear@openai-curated-remote"); assert_eq!(plugin.remote_plugin_id, "plugins~Plugin_linear"); assert_eq!(plugin.name, "linear"); assert_eq!(plugin.installed, true); diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 56427473a1..16ae512931 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -12,6 +12,7 @@ use codex_plugin::PluginId; use codex_utils_absolute_path::AbsolutePathBuf; use reqwest::RequestBuilder; use serde::Deserialize; +use serde_json::Value as JsonValue; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::collections::HashSet; @@ -46,19 +47,20 @@ pub use share::load_plugin_share_remote_ids_by_local_path; pub use share::save_remote_plugin_share; pub use share::update_remote_plugin_share_targets; -pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global"; +pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "openai-curated-remote"; pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "workspace-directory"; pub const REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME: &str = "workspace-shared-with-me"; pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME: &str = "workspace-shared-with-me-private"; pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME: &str = "workspace-shared-with-me-unlisted"; -pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Plugins"; +pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "OpenAI Curated Remote"; pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "Workspace Directory"; pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME: &str = "Shared with me"; pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME: &str = "Shared with me (unlisted)"; +const OPENAI_CURATED_REMOTE_COLLECTION_KEY: &str = "vertical"; const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30); const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200; const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128; @@ -158,6 +160,7 @@ pub struct RemotePluginDetail { pub description: Option, pub release_version: Option, pub bundle_download_url: Option, + pub app_manifest: Option, pub skills: Vec, pub app_ids: Vec, } @@ -391,6 +394,8 @@ struct RemotePluginReleaseResponse { #[serde(default)] app_ids: Vec, #[serde(default)] + app_manifest: Option, + #[serde(default)] keywords: Vec, interface: RemotePluginReleaseInterfaceResponse, #[serde(default)] @@ -595,6 +600,31 @@ pub async fn fetch_remote_marketplaces( Ok(marketplaces) } +pub async fn fetch_openai_curated_remote_collection_marketplace( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, +) -> Result, RemotePluginCatalogError> { + let auth = ensure_chatgpt_auth(auth)?; + let scope = RemotePluginScope::Global; + let (directory_plugins, installed_plugins) = tokio::try_join!( + fetch_directory_plugins_for_scope_with_collection( + config, + auth, + scope, + OPENAI_CURATED_REMOTE_COLLECTION_KEY, + ), + fetch_installed_plugins_for_scope(config, auth, scope), + )?; + + build_remote_marketplace( + REMOTE_GLOBAL_MARKETPLACE_NAME, + REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME, + directory_plugins, + installed_plugins, + /*include_installed_only*/ false, + ) +} + fn build_remote_marketplace( name: &str, display_name: &str, @@ -866,6 +896,7 @@ async fn build_remote_plugin_detail( description: non_empty_string(Some(&plugin.release.description)), release_version: plugin.release.version, bundle_download_url: plugin.release.bundle_download_url, + app_manifest: plugin.release.app_manifest, skills, app_ids: plugin.release.app_ids, }) @@ -1174,12 +1205,40 @@ async fn fetch_directory_plugins_for_scope( config: &RemotePluginServiceConfig, auth: &CodexAuth, scope: RemotePluginScope, +) -> Result, RemotePluginCatalogError> { + fetch_directory_plugins_for_scope_with_optional_collection( + config, auth, scope, /*collection*/ None, + ) + .await +} + +async fn fetch_directory_plugins_for_scope_with_collection( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + scope: RemotePluginScope, + collection: &str, +) -> Result, RemotePluginCatalogError> { + fetch_directory_plugins_for_scope_with_optional_collection( + config, + auth, + scope, + Some(collection), + ) + .await +} + +async fn fetch_directory_plugins_for_scope_with_optional_collection( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + scope: RemotePluginScope, + collection: Option<&str>, ) -> Result, RemotePluginCatalogError> { let mut plugins = Vec::new(); let mut page_token = None; loop { let response = - get_remote_plugin_list_page(config, auth, scope, page_token.as_deref()).await?; + get_remote_plugin_list_page(config, auth, scope, page_token.as_deref(), collection) + .await?; plugins.extend(response.plugins); let Some(next_page_token) = response.pagination.next_page_token else { break; @@ -1249,6 +1308,7 @@ async fn get_remote_plugin_list_page( auth: &CodexAuth, scope: RemotePluginScope, page_token: Option<&str>, + collection: Option<&str>, ) -> Result { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/list"); @@ -1256,6 +1316,9 @@ async fn get_remote_plugin_list_page( let mut request = authenticated_request(client.get(&url), auth)?; request = request.query(&[("scope", scope.api_value())]); request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]); + if let Some(collection) = collection { + request = request.query(&[("collection", collection)]); + } if let Some(page_token) = page_token { request = request.query(&[("pageToken", page_token)]); } diff --git a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs index 69cbfe2569..fe3a597b0f 100644 --- a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs +++ b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs @@ -208,6 +208,7 @@ pub async fn sync_remote_installed_plugin_bundles_once( &plugin.name, release_version, plugin.release.bundle_download_url.as_deref(), + plugin.release.app_manifest.clone(), ) { Ok(bundle) => bundle, Err(err) => { @@ -511,7 +512,7 @@ mod tests { &installed_plugin_names_by_marketplace, ) .expect("cleanup after install guard is dropped"); - assert_eq!(removed, vec!["linear@chatgpt-global".to_string()]); + assert_eq!(removed, vec!["linear@openai-curated-remote".to_string()]); assert!(!cached_manifest.exists()); } diff --git a/codex-rs/core-plugins/src/remote/share/checkout.rs b/codex-rs/core-plugins/src/remote/share/checkout.rs index 8aabccff8b..6b12d2588d 100644 --- a/codex-rs/core-plugins/src/remote/share/checkout.rs +++ b/codex-rs/core-plugins/src/remote/share/checkout.rs @@ -85,6 +85,7 @@ pub async fn checkout_remote_plugin_share( &plugin_name, detail.release_version.as_deref(), detail.bundle_download_url.as_deref(), + /*app_manifest*/ None, ) .map_err(|err| { RemotePluginCatalogError::UnexpectedResponse(format!( diff --git a/codex-rs/core-plugins/src/remote_bundle.rs b/codex-rs/core-plugins/src/remote_bundle.rs index ed7cd56a86..7862573e44 100644 --- a/codex-rs/core-plugins/src/remote_bundle.rs +++ b/codex-rs/core-plugins/src/remote_bundle.rs @@ -1,3 +1,4 @@ +use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use crate::store::PluginInstallResult; use crate::store::PluginStore; use crate::store::PluginStoreError; @@ -10,6 +11,7 @@ use codex_utils_plugins::find_plugin_manifest_path; use flate2::read::GzDecoder; use reqwest::Response; use reqwest::StatusCode; +use serde_json::Value as JsonValue; use std::fs; use std::io; use std::io::Read; @@ -33,6 +35,7 @@ const TEST_ALLOW_LOOPBACK_HTTP_REMOTE_PLUGIN_BUNDLES_ENV: &str = pub struct ValidatedRemotePluginBundle { pub plugin_id: PluginId, pub plugin_version: String, + app_manifest: Option, bundle_download_url: String, } @@ -137,6 +140,7 @@ pub fn validate_remote_plugin_bundle( plugin_name: &str, release_version: Option<&str>, bundle_download_url: Option<&str>, + app_manifest: Option, ) -> Result { let plugin_id = PluginId::new(plugin_name.to_string(), remote_marketplace_name.to_string()) .map_err(|source| RemotePluginBundleInstallError::InvalidPluginId { @@ -187,6 +191,7 @@ pub fn validate_remote_plugin_bundle( Ok(ValidatedRemotePluginBundle { plugin_id, plugin_version, + app_manifest, bundle_download_url, }) } @@ -367,6 +372,7 @@ fn install_remote_plugin_bundle( extract_plugin_bundle_tar_gz(&bundle_bytes, extract_dir.path())?; let plugin_root = find_extracted_plugin_root(extract_dir.path())?; + prepare_extracted_remote_plugin_root(&plugin_root, &bundle)?; let plugin_root = AbsolutePathBuf::try_from(plugin_root).map_err(|err| { RemotePluginBundleInstallError::InvalidBundle(format!( "failed to resolve extracted remote plugin bundle root: {err}" @@ -436,6 +442,90 @@ fn extract_remote_plugin_bundle_to_path( Ok(destination) } +fn prepare_extracted_remote_plugin_root( + plugin_root: &Path, + bundle: &ValidatedRemotePluginBundle, +) -> Result<(), RemotePluginBundleInstallError> { + if bundle.plugin_id.marketplace_name != REMOTE_GLOBAL_MARKETPLACE_NAME { + return Ok(()); + } + + overwrite_plugin_manifest_version(plugin_root, &bundle.plugin_version)?; + if let Some(app_manifest) = &bundle.app_manifest { + overwrite_plugin_app_manifest(plugin_root, app_manifest)?; + } + Ok(()) +} + +fn overwrite_plugin_manifest_version( + plugin_root: &Path, + plugin_version: &str, +) -> Result<(), RemotePluginBundleInstallError> { + let manifest_path = find_plugin_manifest_path(plugin_root).ok_or_else(|| { + RemotePluginBundleInstallError::InvalidBundle( + "remote plugin bundle did not contain a valid plugin.json".to_string(), + ) + })?; + let contents = fs::read_to_string(&manifest_path).map_err(|source| { + RemotePluginBundleInstallError::io("failed to read remote plugin manifest", source) + })?; + let mut manifest: JsonValue = serde_json::from_str(&contents).map_err(|err| { + RemotePluginBundleInstallError::InvalidBundle(format!( + "failed to parse remote plugin manifest: {err}" + )) + })?; + let Some(manifest_object) = manifest.as_object_mut() else { + return Err(RemotePluginBundleInstallError::InvalidBundle( + "remote plugin manifest must be a JSON object".to_string(), + )); + }; + manifest_object.insert( + "version".to_string(), + JsonValue::String(plugin_version.to_string()), + ); + write_json_file( + &manifest_path, + &manifest, + "failed to write remote plugin manifest", + ) +} + +fn overwrite_plugin_app_manifest( + plugin_root: &Path, + app_manifest: &JsonValue, +) -> Result<(), RemotePluginBundleInstallError> { + let app_manifest_path = crate::manifest::load_plugin_manifest(plugin_root) + .and_then(|manifest| manifest.paths.apps.map(|path| path.to_path_buf())) + .unwrap_or_else(|| plugin_root.join(".app.json")); + write_json_file( + &app_manifest_path, + app_manifest, + "failed to write remote plugin app manifest", + ) +} + +fn write_json_file( + path: &Path, + value: &JsonValue, + context: &'static str, +) -> Result<(), RemotePluginBundleInstallError> { + let parent = path.parent().ok_or_else(|| { + RemotePluginBundleInstallError::InvalidBundle(format!( + "remote plugin output path has no parent: {}", + path.display() + )) + })?; + fs::create_dir_all(parent) + .map_err(|source| RemotePluginBundleInstallError::io(context, source))?; + let mut contents = serde_json::to_vec_pretty(value).map_err(|err| { + RemotePluginBundleInstallError::InvalidBundle(format!( + "failed to serialize remote plugin JSON override: {err}" + )) + })?; + contents.push(b'\n'); + fs::write(path, contents).map_err(|source| RemotePluginBundleInstallError::io(context, source)) +} + fn extract_plugin_bundle_tar_gz( bytes: &[u8], destination: &Path, @@ -624,15 +714,16 @@ mod tests { fn validate_remote_plugin_bundle_uses_detail_name_for_local_plugin_id() { let bundle = validate_remote_plugin_bundle( REMOTE_PLUGIN_ID, - "chatgpt-global", + "openai-curated-remote", "linear", Some("1.2.3"), Some("https://example.com/linear.tar.gz"), + /*app_manifest*/ None, ) .expect("valid install plan"); assert_eq!(bundle.plugin_id.plugin_name, "linear"); - assert_eq!(bundle.plugin_id.marketplace_name, "chatgpt-global"); + assert_eq!(bundle.plugin_id.marketplace_name, "openai-curated-remote"); assert_eq!(bundle.plugin_version, "1.2.3"); assert_eq!( bundle.bundle_download_url.as_str(), @@ -644,10 +735,11 @@ mod tests { fn validate_remote_plugin_bundle_rejects_missing_release_version() { let err = validate_remote_plugin_bundle( REMOTE_PLUGIN_ID, - "chatgpt-global", + "openai-curated-remote", "linear", /*release_version*/ None, Some("https://example.com/linear.tar.gz"), + /*app_manifest*/ None, ) .expect_err("missing release version should be rejected"); @@ -661,10 +753,11 @@ mod tests { fn validate_remote_plugin_bundle_rejects_invalid_release_version() { let err = validate_remote_plugin_bundle( REMOTE_PLUGIN_ID, - "chatgpt-global", + "openai-curated-remote", "linear", Some("../1.2.3"), Some("https://example.com/linear.tar.gz"), + /*app_manifest*/ None, ) .expect_err("invalid release version should be rejected"); @@ -678,10 +771,11 @@ mod tests { fn validate_remote_plugin_bundle_rejects_missing_download_url() { let err = validate_remote_plugin_bundle( REMOTE_PLUGIN_ID, - "chatgpt-global", + "openai-curated-remote", "linear", Some("1.2.3"), /*bundle_download_url*/ None, + /*app_manifest*/ None, ) .expect_err("missing bundle download URL should be rejected"); @@ -695,10 +789,11 @@ mod tests { fn validate_remote_plugin_bundle_rejects_unsupported_download_url_scheme() { let err = validate_remote_plugin_bundle( REMOTE_PLUGIN_ID, - "chatgpt-global", + "openai-curated-remote", "linear", Some("1.2.3"), Some("http://example.com/linear.tar.gz"), + /*app_manifest*/ None, ) .expect_err("plain HTTP URLs should be rejected before cloud install"); @@ -755,6 +850,78 @@ mod tests { ); } + #[test] + fn install_preserves_non_global_bundle_manifest_metadata() { + let codex_home = tempdir().expect("tempdir"); + let bundle = validate_remote_plugin_bundle( + REMOTE_PLUGIN_ID, + "workspace-shared-with-me", + "linear", + Some("backend-version"), + Some("https://example.com/linear.tar.gz"), + Some(serde_json::json!({ + "apps": { + "remote": { + "id": "remote-app" + } + } + })), + ) + .expect("valid install plan"); + + let result = install_remote_plugin_bundle( + codex_home.path().to_path_buf(), + bundle, + tar_gz_bytes(&[ + ( + ".codex-plugin/plugin.json", + br#"{"name":"linear","version":"bundle-version"}"#, + /*mode*/ 0o644, + ), + ( + ".app.json", + br#"{"apps":{"bundled":{"id":"bundled-app"}}}"#, + /*mode*/ 0o644, + ), + ]), + ) + .expect("install bundle"); + + assert_eq!(result.plugin_version, "backend-version"); + let installed_manifest: JsonValue = serde_json::from_str( + &std::fs::read_to_string( + result + .installed_path + .join(".codex-plugin/plugin.json") + .as_path(), + ) + .expect("read installed plugin manifest"), + ) + .expect("parse installed plugin manifest"); + assert_eq!( + installed_manifest, + serde_json::json!({ + "name": "linear", + "version": "bundle-version", + }) + ); + let installed_app_manifest: JsonValue = serde_json::from_str( + &std::fs::read_to_string(result.installed_path.join(".app.json").as_path()) + .expect("read installed app manifest"), + ) + .expect("parse installed app manifest"); + assert_eq!( + installed_app_manifest, + serde_json::json!({ + "apps": { + "bundled": { + "id": "bundled-app", + }, + }, + }) + ); + } + #[test] fn find_extracted_plugin_root_uses_local_manifest_discovery() { let extraction_root = tempdir().expect("tempdir"); @@ -866,10 +1033,11 @@ mod tests { fn valid_remote_plugin_bundle() -> ValidatedRemotePluginBundle { validate_remote_plugin_bundle( REMOTE_PLUGIN_ID, - "chatgpt-global", + "openai-curated-remote", "linear", Some("1.2.3"), Some("https://example.com/linear.tar.gz"), + /*app_manifest*/ None, ) .expect("valid install plan") } From 343a74076ffcd810d57a54e18ded0b3f93b2dc1b Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 19 May 2026 22:10:03 -0700 Subject: [PATCH 22/63] build: package prebuilt Codex entrypoints (#23586) ## Why The package builder should describe the binaries it is actually packaging, not require callers to restate release metadata out of band. A caller-provided `--version` flag can drift from the workspace version, but running the target entrypoint to discover its version breaks cross-target packages when the produced binary cannot execute on the build host. This PR keeps package metadata tied to the repository source of truth by reading `[workspace.package].version` from `codex-rs/Cargo.toml`. It also prepares the package layout for `codex-app-server` packages: the same package structure can now represent either the CLI entrypoint or the app-server entrypoint while keeping shared sidecars such as `rg`, `bwrap`, and Windows sandbox helpers in the existing package directories. ## What changed - Removes the `--version` CLI flag from `scripts/build_codex_package.py`. - Adds Cargo.toml version discovery for `codex-package.json.version` via `codex-rs/Cargo.toml`. - Adds `--entrypoint-bin` so callers can package a prebuilt entrypoint instead of rebuilding it with Cargo. - Makes `--variant` an explicit choice between `codex` and `codex-app-server`, and uses it to select the cargo binary and packaged `bin/` entrypoint name. - Updates `scripts/codex_package/README.md` to document variants, prebuilt entrypoints, and Cargo.toml version detection. ## Verification - Compiled `scripts/build_codex_package.py` and `scripts/codex_package/*.py` with `PYTHONDONTWRITEBYTECODE=1`. - Ran `scripts/build_codex_package.py --help` and verified `--version` is gone while `--variant` and `--entrypoint-bin` are present. - Verified the package builder reads version `0.0.0` from `codex-rs/Cargo.toml`. - Built a fake cross-target `codex-app-server` package using a non-executable `--entrypoint-bin`; verified metadata records version `0.0.0`, variant `codex-app-server`, and `bin/codex-app-server` as the entrypoint. --- scripts/codex_package/README.md | 14 +++++--- scripts/codex_package/cargo.py | 55 +++++++++++++++++++++----------- scripts/codex_package/cli.py | 37 +++++++++++++++------ scripts/codex_package/layout.py | 25 +++++++++++---- scripts/codex_package/targets.py | 30 ++++++++++++++--- scripts/codex_package/version.py | 29 +++++++++++++++++ 6 files changed, 147 insertions(+), 43 deletions(-) create mode 100644 scripts/codex_package/version.py diff --git a/scripts/codex_package/README.md b/scripts/codex_package/README.md index c4abb58afd..c1674a8b77 100644 --- a/scripts/codex_package/README.md +++ b/scripts/codex_package/README.md @@ -10,7 +10,7 @@ The builder creates a canonical Codex package directory: . ├── codex-package.json ├── bin -│ └── codex[.exe] +│ └── [.exe] ├── codex-resources │ ├── bwrap # Linux only │ ├── codex-command-runner.exe # Windows only @@ -28,18 +28,24 @@ artifacts; pass a GNU Linux target explicitly for native glibc local builds. If `--package-dir` is omitted, the builder creates a new temporary directory and prints its path after the package is built. +The `--variant` flag selects the package entrypoint. Supported variants are +`codex` and `codex-app-server`. The `version` field in `codex-package.json` is +read from `[workspace.package].version` in `codex-rs/Cargo.toml`. + ## Source-built artifacts Artifacts built from this repository are always built by the package builder in -one grouped `cargo build` command per package: +one grouped `cargo build` command per package when they are needed: -- all targets: `codex` +- all targets: the selected entrypoint, unless `--entrypoint-bin` is provided - Linux targets: `bwrap` - Windows targets: `codex-command-runner` and `codex-windows-sandbox-setup` The default cargo profile is `dev-small` because local iteration should favor fast, small builds. Release jobs should pass `--cargo-profile release` and an -explicit target. +explicit target. Release jobs that already built and signed/notarized the +entrypoint should pass `--entrypoint-bin` so the package contains that exact +binary instead of rebuilding it. `rg` is not built from this repository, so the builder fetches it from the DotSlash manifest at `codex-cli/bin/rg`. Downloaded archives are cached under diff --git a/scripts/codex_package/cargo.py b/scripts/codex_package/cargo.py index 9ee2d2f832..c265808b16 100644 --- a/scripts/codex_package/cargo.py +++ b/scripts/codex_package/cargo.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from pathlib import Path from .targets import REPO_ROOT +from .targets import PackageVariant from .targets import TargetSpec @@ -14,7 +15,7 @@ CODEX_RS_ROOT = REPO_ROOT / "codex-rs" @dataclass(frozen=True) class SourceBuildOutputs: - codex_bin: Path + entrypoint_bin: Path bwrap_bin: Path | None codex_command_runner_bin: Path | None codex_windows_sandbox_setup_bin: Path | None @@ -22,28 +23,39 @@ class SourceBuildOutputs: def build_source_binaries( spec: TargetSpec, + variant: PackageVariant, *, cargo: str, profile: str, + entrypoint_bin: Path | None, ) -> SourceBuildOutputs: - binaries = source_binaries_for_target(spec) - cmd = [ - cargo, - "build", - "--target", - spec.target, - "--profile", - profile, - ] - for binary in binaries: - cmd.extend(["--bin", binary]) + binaries = source_binaries_for_target( + spec, + variant, + build_entrypoint=entrypoint_bin is None, + ) + if binaries: + cmd = [ + cargo, + "build", + "--target", + spec.target, + "--profile", + profile, + ] + for binary in binaries: + cmd.extend(["--bin", binary]) - print("+", " ".join(cmd)) - subprocess.run(cmd, cwd=CODEX_RS_ROOT, check=True) + print("+", " ".join(cmd)) + subprocess.run(cmd, cwd=CODEX_RS_ROOT, check=True) output_dir = cargo_profile_output_dir(spec, profile) outputs = SourceBuildOutputs( - codex_bin=output_dir / spec.codex_name, + entrypoint_bin=( + entrypoint_bin.resolve() + if entrypoint_bin is not None + else output_dir / variant.entrypoint_name(spec) + ), bwrap_bin=output_dir / "bwrap" if spec.is_linux else None, codex_command_runner_bin=( output_dir / "codex-command-runner.exe" if spec.is_windows else None @@ -56,8 +68,15 @@ def build_source_binaries( return outputs -def source_binaries_for_target(spec: TargetSpec) -> list[str]: - binaries = ["codex"] +def source_binaries_for_target( + spec: TargetSpec, + variant: PackageVariant, + *, + build_entrypoint: bool, +) -> list[str]: + binaries = [] + if build_entrypoint: + binaries.append(variant.cargo_bin) if spec.is_linux: binaries.append("bwrap") if spec.is_windows: @@ -97,7 +116,7 @@ def cargo_profile_dirname(profile: str) -> str: def validate_source_outputs(outputs: SourceBuildOutputs) -> None: for path in [ - outputs.codex_bin, + outputs.entrypoint_bin, outputs.bwrap_bin, outputs.codex_command_runner_bin, outputs.codex_windows_sandbox_setup_bin, diff --git a/scripts/codex_package/cli.py b/scripts/codex_package/cli.py index 6b2598445f..16324cf44d 100644 --- a/scripts/codex_package/cli.py +++ b/scripts/codex_package/cli.py @@ -10,9 +10,12 @@ from .layout import build_package_dir from .layout import prepare_package_dir from .layout import validate_package_dir from .ripgrep import resolve_rg_bin +from .targets import PACKAGE_VARIANTS from .targets import TARGET_SPECS from .targets import PackageInputs from .targets import default_target +from .targets import resolve_input_path +from .version import read_workspace_version def parse_args() -> argparse.Namespace: @@ -29,15 +32,11 @@ def parse_args() -> argparse.Namespace: "for this host platform." ), ) - parser.add_argument( - "--version", - default="0.0.0-dev", - help="Codex version to record in codex-package.json.", - ) parser.add_argument( "--variant", + choices=sorted(PACKAGE_VARIANTS), default="codex", - help="Package variant to record in codex-package.json.", + help="Package variant to build.", ) parser.add_argument( "--package-dir", @@ -74,6 +73,14 @@ def parse_args() -> argparse.Namespace: "release packages." ), ) + parser.add_argument( + "--entrypoint-bin", + type=Path, + help=( + "Optional prebuilt entrypoint executable for the selected package " + "variant. If omitted, the entrypoint is built with Cargo." + ), + ) parser.add_argument( "--rg-bin", type=Path, @@ -88,6 +95,7 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() spec = TARGET_SPECS[getattr(args, "target", None) or default_target()] + variant = PACKAGE_VARIANTS[args.variant] package_dir_arg = getattr(args, "package_dir", None) package_dir = ( package_dir_arg.resolve() @@ -97,19 +105,30 @@ def main() -> int: source_outputs = build_source_binaries( spec, + variant, cargo=args.cargo, profile=args.cargo_profile, + entrypoint_bin=( + resolve_input_path( + args.entrypoint_bin, + "prebuilt entrypoint executable", + "--entrypoint-bin", + ) + if args.entrypoint_bin is not None + else None + ), ) + version = read_workspace_version() inputs = PackageInputs( - codex_bin=source_outputs.codex_bin, + entrypoint_bin=source_outputs.entrypoint_bin, rg_bin=resolve_rg_bin(spec, args.rg_bin), bwrap_bin=source_outputs.bwrap_bin, codex_command_runner_bin=source_outputs.codex_command_runner_bin, codex_windows_sandbox_setup_bin=source_outputs.codex_windows_sandbox_setup_bin, ) prepare_package_dir(package_dir, force=args.force) - build_package_dir(package_dir, args.version, args.variant, spec, inputs) - validate_package_dir(package_dir, spec) + build_package_dir(package_dir, version, variant, spec, inputs) + validate_package_dir(package_dir, variant, spec) archive_output = args.archive_output if archive_output is not None: diff --git a/scripts/codex_package/layout.py b/scripts/codex_package/layout.py index faf24ef810..6d8982a631 100644 --- a/scripts/codex_package/layout.py +++ b/scripts/codex_package/layout.py @@ -6,6 +6,7 @@ import stat from pathlib import Path from .targets import PackageInputs +from .targets import PackageVariant from .targets import TargetSpec @@ -30,7 +31,7 @@ def prepare_package_dir(package_dir: Path, *, force: bool) -> None: def build_package_dir( package_dir: Path, version: str, - variant: str, + variant: PackageVariant, spec: TargetSpec, inputs: PackageInputs, ) -> None: @@ -41,7 +42,12 @@ def build_package_dir( resources_dir.mkdir() path_dir.mkdir() - copy_executable(inputs.codex_bin, bin_dir / spec.codex_name, is_windows=spec.is_windows) + entrypoint_name = variant.entrypoint_name(spec) + copy_executable( + inputs.entrypoint_bin, + bin_dir / entrypoint_name, + is_windows=spec.is_windows, + ) copy_executable(inputs.rg_bin, path_dir / spec.rg_name, is_windows=spec.is_windows) if inputs.bwrap_bin is not None: @@ -65,15 +71,19 @@ def build_package_dir( "layoutVersion": LAYOUT_VERSION, "version": version, "target": spec.target, - "variant": variant, - "entrypoint": f"bin/{spec.codex_name}", + "variant": variant.name, + "entrypoint": f"bin/{entrypoint_name}", "resourcesDir": "codex-resources", "pathDir": "codex-path", } write_json(package_dir / "codex-package.json", metadata) -def validate_package_dir(package_dir: Path, spec: TargetSpec) -> None: +def validate_package_dir( + package_dir: Path, + variant: PackageVariant, + spec: TargetSpec, +) -> None: required_dirs = [ Path("bin"), Path("codex-resources"), @@ -94,7 +104,8 @@ def validate_package_dir(package_dir: Path, spec: TargetSpec) -> None: expected_metadata = { "layoutVersion": LAYOUT_VERSION, "target": spec.target, - "entrypoint": f"bin/{spec.codex_name}", + "variant": variant.name, + "entrypoint": f"bin/{variant.entrypoint_name(spec)}", "resourcesDir": "codex-resources", "pathDir": "codex-path", } @@ -106,7 +117,7 @@ def validate_package_dir(package_dir: Path, spec: TargetSpec) -> None: ) required_files = [ - Path("bin") / spec.codex_name, + Path("bin") / variant.entrypoint_name(spec), Path("codex-path") / spec.rg_name, ] executable_files = list(required_files) diff --git a/scripts/codex_package/targets.py b/scripts/codex_package/targets.py index d4f85953c2..4af0d4a00d 100644 --- a/scripts/codex_package/targets.py +++ b/scripts/codex_package/targets.py @@ -21,24 +21,44 @@ class TargetSpec: def exe_suffix(self) -> str: return ".exe" if self.is_windows else "" - @property - def codex_name(self) -> str: - return f"codex{self.exe_suffix}" - @property def rg_name(self) -> str: return f"rg{self.exe_suffix}" +@dataclass(frozen=True) +class PackageVariant: + name: str + cargo_bin: str + executable_stem: str + + def entrypoint_name(self, spec: TargetSpec) -> str: + return f"{self.executable_stem}{spec.exe_suffix}" + + @dataclass(frozen=True) class PackageInputs: - codex_bin: Path + entrypoint_bin: Path rg_bin: Path bwrap_bin: Path | None codex_command_runner_bin: Path | None codex_windows_sandbox_setup_bin: Path | None +PACKAGE_VARIANTS: dict[str, PackageVariant] = { + "codex": PackageVariant( + name="codex", + cargo_bin="codex", + executable_stem="codex", + ), + "codex-app-server": PackageVariant( + name="codex-app-server", + cargo_bin="codex-app-server", + executable_stem="codex-app-server", + ), +} + + TARGET_SPECS: dict[str, TargetSpec] = { "x86_64-unknown-linux-gnu": TargetSpec( target="x86_64-unknown-linux-gnu", diff --git a/scripts/codex_package/version.py b/scripts/codex_package/version.py new file mode 100644 index 0000000000..7aec8b9ccb --- /dev/null +++ b/scripts/codex_package/version.py @@ -0,0 +1,29 @@ +"""Version discovery for Codex packages.""" + +import re + +from .targets import REPO_ROOT + + +WORKSPACE_VERSION_PATTERN = re.compile(r'^version\s*=\s*"([^"]+)"') + + +def read_workspace_version() -> str: + cargo_toml = REPO_ROOT / "codex-rs" / "Cargo.toml" + in_workspace_package = False + with open(cargo_toml, encoding="utf-8") as fh: + for line in fh: + stripped = line.strip() + if stripped == "[workspace.package]": + in_workspace_package = True + continue + + if in_workspace_package and stripped.startswith("["): + break + + if in_workspace_package: + match = WORKSPACE_VERSION_PATTERN.match(stripped) + if match is not None: + return match.group(1) + + raise RuntimeError(f"Could not find [workspace.package].version in {cargo_toml}") From 57a68fb9e3a3a1b2132eba5381178b0d6c7729be Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 19 May 2026 22:43:53 -0700 Subject: [PATCH 23/63] ci: build Codex package archives in release workflow (#23582) ## Why Release CI already builds the Codex entrypoints before staging artifacts, and the package builder can now package those prebuilt binaries directly. The workflow should produce package-shaped sidecar archives from the same staged entrypoints that downstream distribution channels will eventually consume, without rebuilding `codex` or `codex-app-server` inside the packaging step. This intentionally does **not** publish the new package archives as GitHub Release assets yet. The archives are kept with workflow artifacts until npm, Homebrew, `install.sh`, winget, and related consumers are ready to switch over. ## What changed - Adds a `Build Codex package archive` step to `.github/workflows/rust-release.yml` after target artifacts are staged. - Runs `scripts/build_codex_package.py` for both release bundles: - `primary` builds `codex-package-${TARGET}.tar.gz` with `--variant codex`. - `app-server` builds `codex-app-server-package-${TARGET}.tar.gz` with `--variant codex-app-server`. - Passes `--entrypoint-bin target/${TARGET}/release/` so packages contain the entrypoint already built by the workflow. - Deletes both package archive names before the final GitHub Release upload so they remain workflow artifacts only for now. ## Verification - Parsed `.github/workflows/rust-release.yml` with Ruby's YAML loader. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/23582). * #23596 * __->__ #23582 --- .../scripts/build-codex-package-archive.sh | 112 ++++++++++++++++++ .github/workflows/rust-release-windows.yml | 12 ++ .github/workflows/rust-release.yml | 33 ++++++ 3 files changed, 157 insertions(+) create mode 100644 .github/scripts/build-codex-package-archive.sh diff --git a/.github/scripts/build-codex-package-archive.sh b/.github/scripts/build-codex-package-archive.sh new file mode 100644 index 0000000000..90eae12ef0 --- /dev/null +++ b/.github/scripts/build-codex-package-archive.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: build-codex-package-archive.sh \ + --target \ + --bundle \ + --entrypoint-dir \ + --archive-dir \ + [--target-suffixed-entrypoint] +EOF +} + +target="" +bundle="" +entrypoint_dir="" +archive_dir="" +target_suffixed_entrypoint="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --target) + target="${2:?--target requires a value}" + shift 2 + ;; + --bundle) + bundle="${2:?--bundle requires a value}" + shift 2 + ;; + --entrypoint-dir) + entrypoint_dir="${2:?--entrypoint-dir requires a value}" + shift 2 + ;; + --archive-dir) + archive_dir="${2:?--archive-dir requires a value}" + shift 2 + ;; + --target-suffixed-entrypoint) + target_suffixed_entrypoint="true" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unexpected argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "$target" || -z "$bundle" || -z "$entrypoint_dir" || -z "$archive_dir" ]]; then + usage >&2 + exit 1 +fi + +case "$bundle" in + primary) + variant="codex" + entrypoint="codex" + archive_stem="codex-package" + ;; + app-server) + variant="codex-app-server" + entrypoint="codex-app-server" + archive_stem="codex-app-server-package" + ;; + *) + echo "No Codex package variant for bundle: $bundle" >&2 + exit 1 + ;; +esac + +exe_suffix="" +case "$target" in + *windows*) + exe_suffix=".exe" + ;; +esac + +entrypoint_name="$entrypoint" +if [[ "$target_suffixed_entrypoint" == "true" ]]; then + entrypoint_name="${entrypoint_name}-${target}" +fi + +repo_root="${GITHUB_WORKSPACE:-}" +if [[ -z "$repo_root" ]]; then + repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +fi + +if command -v python3 >/dev/null 2>&1; then + python_bin="python3" +else + python_bin="python" +fi + +mkdir -p "$archive_dir" +package_dir="${RUNNER_TEMP:-/tmp}/${archive_stem}-${target}" +archive_path="${archive_dir}/${archive_stem}-${target}.tar.gz" +rm -rf "$package_dir" + +"$python_bin" "${repo_root}/scripts/build_codex_package.py" \ + --target "$target" \ + --variant "$variant" \ + --entrypoint-bin "${entrypoint_dir%/}/${entrypoint_name}${exe_suffix}" \ + --cargo-profile release \ + --package-dir "$package_dir" \ + --archive-output "$archive_path" \ + --force diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 8a1b6030a8..ac28b7855a 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -220,6 +220,18 @@ jobs: "$dest/${binary}-${{ matrix.target }}.exe" done + - name: Build Codex package archives + shell: bash + run: | + set -euo pipefail + for bundle in primary app-server; do + bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ + --target "${{ matrix.target }}" \ + --bundle "$bundle" \ + --entrypoint-dir "target/${{ matrix.target }}/release" \ + --archive-dir "dist/${{ matrix.target }}" + done + - name: Build Python runtime wheel shell: bash run: | diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 50953506d3..c55337ecfe 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -519,6 +519,20 @@ jobs: cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi + - name: Build Codex package archive + if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} + shell: bash + env: + TARGET: ${{ matrix.target }} + BUNDLE: ${{ matrix.bundle }} + run: | + set -euo pipefail + bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ + --target "$TARGET" \ + --bundle "$BUNDLE" \ + --entrypoint-dir "target/${TARGET}/release" \ + --archive-dir "dist/${TARGET}" + - name: Build Python runtime wheel if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} shell: bash @@ -819,6 +833,20 @@ jobs: --platform-tag "$platform_tag" "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" + - name: Build Codex package archive + shell: bash + env: + TARGET: ${{ matrix.target }} + BUNDLE: ${{ matrix.bundle }} + run: | + set -euo pipefail + bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ + --target "$TARGET" \ + --bundle "$BUNDLE" \ + --entrypoint-dir "dist/${TARGET}" \ + --archive-dir "dist/${TARGET}" \ + --target-suffixed-entrypoint + - name: Upload Python runtime wheel if: ${{ matrix.bundle == 'primary' }} uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 @@ -1079,6 +1107,11 @@ jobs: # If included in files: dist/**, release upload races on duplicate # asset names and can fail with 404s. find dist -type f -name 'cargo-timing.html' -delete + # Keep package-builder sidecar archives as workflow artifacts only + # until distribution channels are ready to consume them. + find dist -type f \ + \( -name 'codex-package-*' -o -name 'codex-app-server-package-*' \) \ + -delete find dist -type d -empty -delete ls -R dist/ From cfa16fcc2e24ba01816fb53e8cfb581f4019e42e Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 19 May 2026 23:13:49 -0700 Subject: [PATCH 24/63] runtime: detect Codex package layout (#23596) ## Why The package-builder stack now creates a canonical Codex package directory where the entrypoint lives under `bin/`, bundled helper resources live under `codex-resources/`, and bundled PATH-style tools live under `codex-path/`. That layout is not specific to the standalone installer: npm, brew, install scripts, and manually unpacked artifacts should all be able to use the same package shape. The Rust runtime still only knew about the legacy standalone release layout, where resources sit next to the executable. A packaged binary therefore would not identify its package root or prefer the bundled `rg` from `codex-path/`. ## What changed - Adds `CodexPackageLayout` to `codex-install-context` and detects it from an executable path shaped like `/bin/` when `/codex-package.json` is present. - Splits `InstallContext` into an install `method` plus an optional package layout so the layout is shared across npm, bun, brew, standalone, and other launch contexts. - Stores package-layout paths as `AbsolutePathBuf` values. - Keeps `codex-resources/` and `codex-path/` optional so Codex can still run with degraded behavior if sidecar directories are missing. - Updates `InstallContext::rg_command()` to prefer bundled `codex-path/rg` or `rg.exe`, then fall back to the legacy standalone resources location, then system `rg`. - Updates `codex doctor` reporting so package installs show package, bin, resources, and path directories, and so bundled search detection recognizes `codex-path/` for any install method. ## Test plan - `cargo test -p codex-install-context` - `cargo test -p codex-cli` - `cargo test -p codex-tui update_action::tests::maps_install_context_to_update_action` - `just bazel-lock-check` --- codex-rs/Cargo.lock | 1 + codex-rs/cli/src/doctor.rs | 77 ++++-- codex-rs/cli/src/doctor/runtime.rs | 44 ++-- codex-rs/cli/src/doctor/updates.rs | 35 +-- codex-rs/install-context/Cargo.toml | 1 + codex-rs/install-context/src/lib.rs | 372 +++++++++++++++++++++++----- codex-rs/tui/src/update_action.rs | 62 +++-- 7 files changed, 467 insertions(+), 125 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1950939e65..09c5786a9c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2997,6 +2997,7 @@ dependencies = [ name = "codex-install-context" version = "0.0.0" dependencies = [ + "codex-utils-absolute-path", "codex-utils-home-dir", "pretty_assertions", "tempfile", diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index aec57c1e82..3f8c3ff094 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -39,7 +39,9 @@ use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_features::FEATURES; +use codex_install_context::CodexPackageLayout; use codex_install_context::InstallContext; +use codex_install_context::InstallMethod; use codex_install_context::StandalonePlatform; use codex_login::AuthDotJson; use codex_login::AuthManager; @@ -812,7 +814,10 @@ fn installation_check(show_details: bool) -> DoctorCheck { fn doctor_install_context(current_exe: Option<&Path>) -> InstallContext { if inherited_managed_env_for_cargo_binary(current_exe) { - InstallContext::Other + InstallContext { + method: InstallMethod::Other, + package_layout: None, + } } else { InstallContext::current().clone() } @@ -843,8 +848,8 @@ fn inherited_managed_env_for_cargo_binary(current_exe: Option<&Path>) -> bool { } fn describe_install_context(context: &InstallContext) -> String { - match context { - InstallContext::Standalone { + match &context.method { + InstallMethod::Standalone { release_dir, resources_dir, platform, @@ -853,22 +858,63 @@ fn describe_install_context(context: &InstallContext) -> String { StandalonePlatform::Unix => "unix", StandalonePlatform::Windows => "windows", }; - let resources = resources_dir - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "none".to_string()); + match &context.package_layout { + Some(package_layout) => { + let resources = display_optional_path(package_layout.resources_dir.as_deref()); + let path = display_optional_path(package_layout.path_dir.as_deref()); + format!( + "standalone ({platform}, package {}, bin {}, resources {resources}, path {path})", + package_layout.package_dir.display(), + package_layout.bin_dir.display() + ) + } + None => { + let resources = display_optional_path(resources_dir.as_deref()); + format!( + "standalone ({platform}, release {}, resources {resources})", + release_dir.display() + ) + } + } + } + InstallMethod::Npm => { + describe_method_with_package_layout("npm", context.package_layout.as_ref()) + } + InstallMethod::Bun => { + describe_method_with_package_layout("bun", context.package_layout.as_ref()) + } + InstallMethod::Brew => { + describe_method_with_package_layout("brew", context.package_layout.as_ref()) + } + InstallMethod::Other => { + describe_method_with_package_layout("other", context.package_layout.as_ref()) + } + } +} + +fn describe_method_with_package_layout( + method: &str, + package_layout: Option<&CodexPackageLayout>, +) -> String { + match package_layout { + Some(package_layout) => { + let resources = display_optional_path(package_layout.resources_dir.as_deref()); + let path = display_optional_path(package_layout.path_dir.as_deref()); format!( - "standalone ({platform}, release {}, resources {resources})", - release_dir.display() + "{method} (package {}, bin {}, resources {resources}, path {path})", + package_layout.package_dir.display(), + package_layout.bin_dir.display() ) } - InstallContext::Npm => "npm".to_string(), - InstallContext::Bun => "bun".to_string(), - InstallContext::Brew => "brew".to_string(), - InstallContext::Other => "other".to_string(), + None => method.to_string(), } } +fn display_optional_path(path: Option<&Path>) -> String { + path.map(|path| path.display().to_string()) + .unwrap_or_else(|| "none".to_string()) +} + #[derive(Debug, PartialEq, Eq)] enum NpmRootCheck { Match { @@ -2791,13 +2837,14 @@ fn path_readiness(details: &mut Vec, label: &str, path: &Path) { } fn standalone_release_cache_details(details: &mut Vec) { - let InstallContext::Standalone { release_dir, .. } = InstallContext::current() else { + let context = InstallContext::current(); + let InstallMethod::Standalone { release_dir, .. } = &context.method else { return; }; let Some(releases_dir) = release_dir.parent() else { return; }; - let Ok(entries) = std::fs::read_dir(releases_dir) else { + let Ok(entries) = std::fs::read_dir(&releases_dir) else { return; }; let release_count = entries.filter_map(Result::ok).count(); diff --git a/codex-rs/cli/src/doctor/runtime.rs b/codex-rs/cli/src/doctor/runtime.rs index 96e806762c..14afa70e4c 100644 --- a/codex-rs/cli/src/doctor/runtime.rs +++ b/codex-rs/cli/src/doctor/runtime.rs @@ -3,12 +3,13 @@ //! Runtime diagnostics answer provenance questions that are hard to infer from //! user reports: which binary is running, which install channel it resembles, //! which platform it targets, and whether the search command comes from bundled -//! standalone resources or from PATH. +//! package files or from PATH. use std::env; use std::process::Command; use codex_install_context::InstallContext; +use codex_install_context::InstallMethod; use super::CheckStatus; use super::DoctorCheck; @@ -49,9 +50,10 @@ pub(super) fn runtime_check() -> DoctorCheck { /// Verifies that the search command selected by the install context is usable. /// -/// Standalone installs should point at a bundled ripgrep binary, while local or -/// package-managed installs usually resolve rg from PATH. A warning here means -/// features that depend on file search may degrade even when the CLI launches. +/// Package-layout installs should point at a bundled ripgrep binary, while local +/// installs without that layout usually resolve rg from PATH. A warning here +/// means features that depend on file search may degrade even when the CLI +/// launches. pub(super) fn search_check() -> DoctorCheck { let current_exe = env::current_exe().ok(); let install_context = doctor_install_context(current_exe.as_deref()); @@ -109,28 +111,40 @@ pub(super) fn search_check() -> DoctorCheck { }; let mut check = DoctorCheck::new("runtime.search", "search", status, summary).details(details); if status != CheckStatus::Ok { - check = check.remediation("Install ripgrep or repair the bundled standalone resources."); + check = check.remediation("Install ripgrep or repair the bundled Codex package."); } check } fn install_method_name(context: &InstallContext) -> &'static str { - match context { - InstallContext::Standalone { .. } => "standalone", - InstallContext::Npm => "npm", - InstallContext::Bun => "bun", - InstallContext::Brew => "brew", - InstallContext::Other => "local build", + match &context.method { + InstallMethod::Standalone { .. } => "standalone", + InstallMethod::Npm => "npm", + InstallMethod::Bun => "bun", + InstallMethod::Brew => "brew", + InstallMethod::Other => "local build", } } fn search_provider(context: &InstallContext) -> &'static str { - match context { - InstallContext::Standalone { + let rg_command = context.rg_command(); + let from_package_layout = context + .package_layout + .as_ref() + .and_then(|package_layout| package_layout.path_dir.as_ref()) + .is_some_and(|path_dir| rg_command.starts_with(path_dir)); + let from_legacy_standalone = matches!( + &context.method, + InstallMethod::Standalone { resources_dir: Some(resources_dir), .. - } if context.rg_command().starts_with(resources_dir) => "bundled", - _ => "system", + } if rg_command.starts_with(resources_dir) + ); + + if from_package_layout || from_legacy_standalone { + "bundled" + } else { + "system" } } diff --git a/codex-rs/cli/src/doctor/updates.rs b/codex-rs/cli/src/doctor/updates.rs index 3d728fc07b..246eac2b39 100644 --- a/codex-rs/cli/src/doctor/updates.rs +++ b/codex-rs/cli/src/doctor/updates.rs @@ -10,6 +10,7 @@ use std::path::Path; use codex_core::config::Config; use codex_install_context::InstallContext; +use codex_install_context::InstallMethod; use serde::Deserialize; use super::CheckStatus; @@ -129,22 +130,22 @@ fn push_cached_version_details(details: &mut Vec, version_file: &Path) { } fn update_action_label(context: &InstallContext) -> &'static str { - match context { - InstallContext::Npm => "npm install -g @openai/codex", - InstallContext::Bun => "bun install -g @openai/codex", - InstallContext::Brew => "brew upgrade --cask codex", - InstallContext::Standalone { .. } => "standalone installer", - InstallContext::Other => "manual or unknown", + match &context.method { + InstallMethod::Npm => "npm install -g @openai/codex", + InstallMethod::Bun => "bun install -g @openai/codex", + InstallMethod::Brew => "brew upgrade --cask codex", + InstallMethod::Standalone { .. } => "standalone installer", + InstallMethod::Other => "manual or unknown", } } fn fetch_latest_version(context: &InstallContext) -> Result { - match context { - InstallContext::Brew => fetch_homebrew_cask_version(), - InstallContext::Npm - | InstallContext::Bun - | InstallContext::Standalone { .. } - | InstallContext::Other => fetch_latest_github_release_version(), + match &context.method { + InstallMethod::Brew => fetch_homebrew_cask_version(), + InstallMethod::Npm + | InstallMethod::Bun + | InstallMethod::Standalone { .. } + | InstallMethod::Other => fetch_latest_github_release_version(), } } @@ -216,11 +217,17 @@ mod tests { #[test] fn update_action_labels_install_contexts() { assert_eq!( - update_action_label(&InstallContext::Npm), + update_action_label(&InstallContext { + method: InstallMethod::Npm, + package_layout: None, + }), "npm install -g @openai/codex" ); assert_eq!( - update_action_label(&InstallContext::Other), + update_action_label(&InstallContext { + method: InstallMethod::Other, + package_layout: None, + }), "manual or unknown" ); } diff --git a/codex-rs/install-context/Cargo.toml b/codex-rs/install-context/Cargo.toml index 52938a0812..5882a19763 100644 --- a/codex-rs/install-context/Cargo.toml +++ b/codex-rs/install-context/Cargo.toml @@ -13,6 +13,7 @@ doctest = false workspace = true [dependencies] +codex-utils-absolute-path = { workspace = true } codex-utils-home-dir = { workspace = true } [dev-dependencies] diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index 980fc3f546..d1f8d3839b 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -1,7 +1,13 @@ +use std::ffi::OsStr; use std::path::Path; use std::path::PathBuf; use std::sync::OnceLock; +use codex_utils_absolute_path::AbsolutePathBuf; + +const BIN_DIRNAME: &str = "bin"; +const PACKAGE_METADATA_FILENAME: &str = "codex-package.json"; +const PATH_DIRNAME: &str = "codex-path"; const RELEASES_DIRNAME: &str = "releases"; const RESOURCES_DIRNAME: &str = "codex-resources"; const STANDALONE_PACKAGES_DIRNAME: &str = "standalone"; @@ -14,14 +20,34 @@ pub enum StandalonePlatform { } #[derive(Clone, Debug, Eq, PartialEq)] -pub enum InstallContext { +pub struct CodexPackageLayout { + /// The package root that contains the metadata file and layout directories. + pub package_dir: AbsolutePathBuf, + /// Directory containing the Codex entrypoint executable. + pub bin_dir: AbsolutePathBuf, + /// Directory containing managed helper binaries and data files, when present. + pub resources_dir: Option, + /// Folder that should be prepended to the PATH, when present. + pub path_dir: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InstallContext { + pub method: InstallMethod, + pub package_layout: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum InstallMethod { Standalone { - /// The managed standalone release directory, for example + /// The managed standalone release directory. Legacy installs use paths + /// such as /// `~/.codex/packages/standalone/releases/0.111.0-x86_64-unknown-linux-musl`. - release_dir: PathBuf, - /// The bundled resource directory that sits next to the executable when - /// this install ships managed dependencies. - resources_dir: Option, + /// Package-layout installs use the package root that contains `bin/`, + /// `codex-resources/`, and `codex-path/`. + release_dir: AbsolutePathBuf, + /// The bundled resource directory for managed dependencies. + resources_dir: Option, /// The platform of the standalone release, either `Unix` or `Windows`. platform: StandalonePlatform, }, @@ -62,28 +88,21 @@ impl InstallContext { managed_by_bun: bool, codex_home: Option<&Path>, ) -> Self { - if managed_by_npm { - return Self::Npm; - } + let package_layout = current_exe.and_then(CodexPackageLayout::from_exe); + let method = if managed_by_npm { + InstallMethod::Npm + } else if managed_by_bun { + InstallMethod::Bun + } else if let Some(exe_path) = current_exe { + install_method_from_exe(exe_path, codex_home, package_layout.as_ref(), is_macos) + } else { + InstallMethod::Other + }; - if managed_by_bun { - return Self::Bun; + Self { + method, + package_layout, } - - if let Some(exe_path) = current_exe - && let Some(standalone_context) = standalone_install_context(exe_path, codex_home) - { - return standalone_context; - } - - if is_macos - && let Some(exe_path) = current_exe - && (exe_path.starts_with("/opt/homebrew") || exe_path.starts_with("/usr/local")) - { - return Self::Brew; - } - - Self::Other } pub fn current() -> &'static Self { @@ -101,53 +120,102 @@ impl InstallContext { } pub fn rg_command(&self) -> PathBuf { - match self { - Self::Standalone { - resources_dir: Some(resources_dir), - .. - } => { - let bundled_rg = resources_dir.join(default_rg_command()); - if bundled_rg.exists() { - bundled_rg - } else { - default_rg_command() - } + if let Some(package_layout) = &self.package_layout + && let Some(path_dir) = &package_layout.path_dir + { + let bundled_rg = path_dir.join(default_rg_command()); + if bundled_rg.exists() { + return bundled_rg.into_path_buf(); } - Self::Standalone { - resources_dir: None, - .. - } - | Self::Npm - | Self::Bun - | Self::Brew - | Self::Other => default_rg_command(), } + + if let InstallMethod::Standalone { + resources_dir: Some(resources_dir), + .. + } = &self.method + { + let bundled_rg = resources_dir.join(default_rg_command()); + if bundled_rg.exists() { + return bundled_rg.into_path_buf(); + } + } + + default_rg_command() } } -fn standalone_install_context( +impl CodexPackageLayout { + fn from_exe(exe_path: &Path) -> Option { + let canonical_exe = canonical_absolute_path(exe_path)?; + let bin_dir = canonical_exe.parent()?; + if bin_dir.file_name() != Some(OsStr::new(BIN_DIRNAME)) { + return None; + } + + let package_dir = bin_dir.parent()?; + if !package_dir.join(PACKAGE_METADATA_FILENAME).is_file() { + return None; + } + + Some(Self { + resources_dir: existing_dir(package_dir.join(RESOURCES_DIRNAME)), + path_dir: existing_dir(package_dir.join(PATH_DIRNAME)), + package_dir, + bin_dir, + }) + } +} + +fn install_method_from_exe( exe_path: &Path, codex_home: Option<&Path>, -) -> Option { - let canonical_exe = std::fs::canonicalize(exe_path).ok()?; - let canonical_codex_home = std::fs::canonicalize(codex_home?).ok()?; - let release_dir = canonical_exe.parent()?.to_path_buf(); + package_layout: Option<&CodexPackageLayout>, + is_macos: bool, +) -> InstallMethod { + if let Some(standalone_method) = standalone_install_method(exe_path, codex_home, package_layout) + { + return standalone_method; + } + + if is_macos && (exe_path.starts_with("/opt/homebrew") || exe_path.starts_with("/usr/local")) { + InstallMethod::Brew + } else { + InstallMethod::Other + } +} + +fn standalone_install_method( + exe_path: &Path, + codex_home: Option<&Path>, + package_layout: Option<&CodexPackageLayout>, +) -> Option { + let canonical_codex_home = canonical_absolute_path(codex_home?)?; + let release_dir = if let Some(package_layout) = package_layout { + package_layout.package_dir.clone() + } else { + canonical_absolute_path(exe_path)?.parent()? + }; let releases_root = canonical_codex_home .join("packages") .join(STANDALONE_PACKAGES_DIRNAME) .join(RELEASES_DIRNAME); - if !release_dir.starts_with(releases_root) { + if !release_dir.starts_with(releases_root.as_path()) { return None; } let resources_dir = release_dir.join(RESOURCES_DIRNAME); - Some(InstallContext::Standalone { + Some(InstallMethod::Standalone { release_dir, resources_dir: resources_dir.is_dir().then_some(resources_dir), platform: standalone_platform(), }) } +fn canonical_absolute_path(path: &Path) -> Option { + let canonical_path = std::fs::canonicalize(path).ok()?; + AbsolutePathBuf::from_absolute_path(canonical_path).ok() +} + fn standalone_platform() -> StandalonePlatform { if cfg!(windows) { StandalonePlatform::Windows @@ -156,6 +224,10 @@ fn standalone_platform() -> StandalonePlatform { } } +fn existing_dir(path: AbsolutePathBuf) -> Option { + path.is_dir().then_some(path) +} + fn default_rg_command() -> PathBuf { if cfg!(windows) { PathBuf::from("rg.exe") @@ -181,8 +253,10 @@ mod tests { let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); fs::write(&exe_path, "")?; fs::write(resources_dir.join(default_rg_command()), "")?; - let canonical_release_dir = release_dir.canonicalize()?; - let canonical_resources_dir = resources_dir.canonicalize()?; + let canonical_release_dir = + AbsolutePathBuf::from_absolute_path(release_dir.canonicalize()?)?; + let canonical_resources_dir = + AbsolutePathBuf::from_absolute_path(resources_dir.canonicalize()?)?; let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, @@ -193,10 +267,13 @@ mod tests { ); assert_eq!( context, - InstallContext::Standalone { - release_dir: canonical_release_dir, - resources_dir: Some(canonical_resources_dir), - platform: standalone_platform(), + InstallContext { + method: InstallMethod::Standalone { + release_dir: canonical_release_dir, + resources_dir: Some(canonical_resources_dir), + platform: standalone_platform(), + }, + package_layout: None, } ); Ok(()) @@ -223,6 +300,161 @@ mod tests { Ok(()) } + #[test] + fn detects_package_layout_independently_from_install_method() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + let resources_dir = package_dir.path().join(RESOURCES_DIRNAME); + let path_dir = package_dir.path().join(PATH_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&resources_dir)?; + fs::create_dir_all(&path_dir)?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + fs::write(path_dir.join(default_rg_command()), "")?; + let canonical_package_dir = + AbsolutePathBuf::from_absolute_path(package_dir.path().canonicalize()?)?; + let canonical_bin_dir = AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?; + let canonical_resources_dir = + AbsolutePathBuf::from_absolute_path(resources_dir.canonicalize()?)?; + let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?; + let package_layout = CodexPackageLayout { + package_dir: canonical_package_dir, + bin_dir: canonical_bin_dir, + resources_dir: Some(canonical_resources_dir), + path_dir: Some(canonical_path_dir.clone()), + }; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*managed_by_npm*/ false, + /*managed_by_bun*/ false, + /*codex_home*/ None, + ); + assert_eq!( + context, + InstallContext { + method: InstallMethod::Other, + package_layout: Some(package_layout), + } + ); + assert_eq!( + context.rg_command(), + canonical_path_dir + .join(default_rg_command()) + .into_path_buf() + ); + Ok(()) + } + + #[test] + fn standalone_package_layout_keeps_standalone_install_method() -> std::io::Result<()> { + let codex_home = tempfile::tempdir()?; + let package_dir = codex_home + .path() + .join("packages/standalone/releases/1.2.3-x86_64-unknown-linux-musl"); + let bin_dir = package_dir.join(BIN_DIRNAME); + let resources_dir = package_dir.join(RESOURCES_DIRNAME); + let path_dir = package_dir.join(PATH_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&resources_dir)?; + fs::create_dir_all(&path_dir)?; + fs::write(package_dir.join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + fs::write(path_dir.join(default_rg_command()), "")?; + let canonical_package_dir = + AbsolutePathBuf::from_absolute_path(package_dir.canonicalize()?)?; + let canonical_bin_dir = AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?; + let canonical_resources_dir = + AbsolutePathBuf::from_absolute_path(resources_dir.canonicalize()?)?; + let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*managed_by_npm*/ false, + /*managed_by_bun*/ false, + /*codex_home*/ Some(codex_home.path()), + ); + assert_eq!( + context, + InstallContext { + method: InstallMethod::Standalone { + release_dir: canonical_package_dir.clone(), + resources_dir: Some(canonical_resources_dir.clone()), + platform: standalone_platform(), + }, + package_layout: Some(CodexPackageLayout { + package_dir: canonical_package_dir, + bin_dir: canonical_bin_dir, + resources_dir: Some(canonical_resources_dir), + path_dir: Some(canonical_path_dir.clone()), + }), + } + ); + assert_eq!( + context.rg_command(), + canonical_path_dir + .join(default_rg_command()) + .into_path_buf() + ); + Ok(()) + } + + #[test] + fn npm_managed_package_keeps_package_layout() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + let path_dir = package_dir.path().join(PATH_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&path_dir)?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + fs::write(path_dir.join(default_rg_command()), "")?; + let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*managed_by_npm*/ true, + /*managed_by_bun*/ false, + /*codex_home*/ None, + ); + assert_eq!(context.method, InstallMethod::Npm); + assert!(context.package_layout.is_some()); + assert_eq!( + context.rg_command(), + canonical_path_dir + .join(default_rg_command()) + .into_path_buf() + ); + Ok(()) + } + + #[test] + fn standalone_package_rg_falls_back_when_codex_path_is_missing() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*managed_by_npm*/ false, + /*managed_by_bun*/ false, + /*codex_home*/ None, + ); + assert_eq!(context.rg_command(), default_rg_command()); + Ok(()) + } + #[test] fn npm_and_bun_take_precedence() { let npm_context = InstallContext::from_exe_with_codex_home( @@ -232,7 +464,13 @@ mod tests { /*managed_by_bun*/ false, /*codex_home*/ None, ); - assert_eq!(npm_context, InstallContext::Npm); + assert_eq!( + npm_context, + InstallContext { + method: InstallMethod::Npm, + package_layout: None, + } + ); let bun_context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, @@ -241,7 +479,13 @@ mod tests { /*managed_by_bun*/ true, /*codex_home*/ None, ); - assert_eq!(bun_context, InstallContext::Bun); + assert_eq!( + bun_context, + InstallContext { + method: InstallMethod::Bun, + package_layout: None, + } + ); } #[test] @@ -253,6 +497,12 @@ mod tests { /*managed_by_bun*/ false, /*codex_home*/ None, ); - assert_eq!(context, InstallContext::Brew); + assert_eq!( + context, + InstallContext { + method: InstallMethod::Brew, + package_layout: None, + } + ); } } diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index aca065440c..cb4aa662ca 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -1,6 +1,8 @@ #[cfg(any(not(debug_assertions), test))] use codex_install_context::InstallContext; #[cfg(any(not(debug_assertions), test))] +use codex_install_context::InstallMethod; +#[cfg(any(not(debug_assertions), test))] use codex_install_context::StandalonePlatform; /// Update action the CLI should perform after the TUI exits. @@ -21,15 +23,15 @@ pub enum UpdateAction { impl UpdateAction { #[cfg(any(not(debug_assertions), test))] pub(crate) fn from_install_context(context: &InstallContext) -> Option { - match context { - InstallContext::Npm => Some(UpdateAction::NpmGlobalLatest), - InstallContext::Bun => Some(UpdateAction::BunGlobalLatest), - InstallContext::Brew => Some(UpdateAction::BrewUpgrade), - InstallContext::Standalone { platform, .. } => Some(match platform { + match &context.method { + InstallMethod::Npm => Some(UpdateAction::NpmGlobalLatest), + InstallMethod::Bun => Some(UpdateAction::BunGlobalLatest), + InstallMethod::Brew => Some(UpdateAction::BrewUpgrade), + InstallMethod::Standalone { platform, .. } => Some(match platform { StandalonePlatform::Unix => UpdateAction::StandaloneUnix, StandalonePlatform::Windows => UpdateAction::StandaloneWindows, }), - InstallContext::Other => None, + InstallMethod::Other => None, } } @@ -66,42 +68,62 @@ pub fn get_update_action() -> Option { #[cfg(test)] mod tests { use super::*; + use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; - use std::path::PathBuf; #[test] fn maps_install_context_to_update_action() { - let native_release_dir = PathBuf::from("/tmp/native-release"); + let native_release_dir = + AbsolutePathBuf::from_absolute_path(std::env::temp_dir().join("native-release")) + .expect("temp dir path should be absolute"); assert_eq!( - UpdateAction::from_install_context(&InstallContext::Other), + UpdateAction::from_install_context(&InstallContext { + method: InstallMethod::Other, + package_layout: None, + }), None ); assert_eq!( - UpdateAction::from_install_context(&InstallContext::Npm), + UpdateAction::from_install_context(&InstallContext { + method: InstallMethod::Npm, + package_layout: None, + }), Some(UpdateAction::NpmGlobalLatest) ); assert_eq!( - UpdateAction::from_install_context(&InstallContext::Bun), + UpdateAction::from_install_context(&InstallContext { + method: InstallMethod::Bun, + package_layout: None, + }), Some(UpdateAction::BunGlobalLatest) ); assert_eq!( - UpdateAction::from_install_context(&InstallContext::Brew), + UpdateAction::from_install_context(&InstallContext { + method: InstallMethod::Brew, + package_layout: None, + }), Some(UpdateAction::BrewUpgrade) ); assert_eq!( - UpdateAction::from_install_context(&InstallContext::Standalone { - platform: StandalonePlatform::Unix, - release_dir: native_release_dir.clone(), - resources_dir: Some(native_release_dir.join("codex-resources")), + UpdateAction::from_install_context(&InstallContext { + method: InstallMethod::Standalone { + platform: StandalonePlatform::Unix, + release_dir: native_release_dir.clone(), + resources_dir: Some(native_release_dir.join("codex-resources")), + }, + package_layout: None, }), Some(UpdateAction::StandaloneUnix) ); assert_eq!( - UpdateAction::from_install_context(&InstallContext::Standalone { - platform: StandalonePlatform::Windows, - release_dir: native_release_dir.clone(), - resources_dir: Some(native_release_dir.join("codex-resources")), + UpdateAction::from_install_context(&InstallContext { + method: InstallMethod::Standalone { + platform: StandalonePlatform::Windows, + release_dir: native_release_dir.clone(), + resources_dir: Some(native_release_dir.join("codex-resources")), + }, + package_layout: None, }), Some(UpdateAction::StandaloneWindows) ); From 34aad436849963399e903d72bbf140935e9fc66f Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Tue, 19 May 2026 23:47:48 -0700 Subject: [PATCH 25/63] add encryptedcontent to functioncalloutput (#23500) add new `EncryptedContent` variant to `FunctionCallOutputContentItem` ahead of standalone websearch. we need to be able to receive and pass encrypted function call output from the new web search endpoint back to responsesapi, as we cannot expose direct search results. --- .../schema/json/ClientRequest.json | 20 ++++++ .../codex_app_server_protocol.schemas.json | 20 ++++++ .../codex_app_server_protocol.v2.schemas.json | 20 ++++++ .../RawResponseItemCompletedNotification.json | 20 ++++++ .../schema/json/v2/ThreadResumeParams.json | 20 ++++++ .../FunctionCallOutputContentItem.ts | 2 +- codex-rs/core/src/context_manager/history.rs | 51 ++++++++++++--- .../core/src/context_manager/history_tests.rs | 20 ++++++ codex-rs/protocol/src/models.rs | 62 ++++++++++++++++++- codex-rs/tools/src/tool_output.rs | 3 +- codex-rs/utils/output-truncation/src/lib.rs | 13 +++- .../output-truncation/src/truncate_tests.rs | 54 ++++++++++++++++ 12 files changed, 291 insertions(+), 14 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index ee68faf001..788e52e82b 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -984,6 +984,26 @@ ], "title": "InputImageFunctionCallOutputContentItem", "type": "object" + }, + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentFunctionCallOutputContentItem", + "type": "object" } ] }, 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 3db4a87ef2..f6e5a46c95 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 @@ -9242,6 +9242,26 @@ ], "title": "InputImageFunctionCallOutputContentItem", "type": "object" + }, + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentFunctionCallOutputContentItem", + "type": "object" } ] }, 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 7b62d6d4bf..c4e93338a1 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 @@ -5631,6 +5631,26 @@ ], "title": "InputImageFunctionCallOutputContentItem", "type": "object" + }, + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentFunctionCallOutputContentItem", + "type": "object" } ] }, 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 74420ea57e..e69b342282 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -140,6 +140,26 @@ ], "title": "InputImageFunctionCallOutputContentItem", "type": "object" + }, + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentFunctionCallOutputContentItem", + "type": "object" } ] }, 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 027989479a..d4bdeda0ed 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -199,6 +199,26 @@ ], "title": "InputImageFunctionCallOutputContentItem", "type": "object" + }, + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentFunctionCallOutputContentItem", + "type": "object" } ] }, diff --git a/codex-rs/app-server-protocol/schema/typescript/FunctionCallOutputContentItem.ts b/codex-rs/app-server-protocol/schema/typescript/FunctionCallOutputContentItem.ts index fb2996f1e5..cd18908145 100644 --- a/codex-rs/app-server-protocol/schema/typescript/FunctionCallOutputContentItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/FunctionCallOutputContentItem.ts @@ -7,4 +7,4 @@ import type { ImageDetail } from "./ImageDetail"; * Responses API compatible content items that can be returned by a tool call. * This is a subset of ContentItem with the types we support as function call outputs. */ -export type FunctionCallOutputContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, detail?: ImageDetail, }; +export type FunctionCallOutputContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, detail?: ImageDetail, } | { "type": "encrypted_content", encrypted_content: string, }; diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index cfd9297382..6a05535488 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -504,6 +504,10 @@ fn estimate_reasoning_length(encoded_len: usize) -> usize { .saturating_sub(650) } +fn estimate_encrypted_function_output_length(encoded_len: usize) -> usize { + encoded_len.saturating_mul(9).div_ceil(16) +} + fn estimate_item_token_count(item: &ResponseItem) -> i64 { let model_visible_bytes = estimate_response_item_model_visible_bytes(item); approx_tokens_from_byte_count_i64(model_visible_bytes) @@ -547,16 +551,18 @@ pub(crate) fn estimate_response_item_model_visible_bytes(item: &ResponseItem) -> let raw = serde_json::to_string(item) .map(|serialized| i64::try_from(serialized.len()).unwrap_or(i64::MAX)) .unwrap_or_default(); - let (payload_bytes, replacement_bytes) = image_data_url_estimate_adjustment(item); - if payload_bytes == 0 || replacement_bytes == 0 { - raw - } else { - // Replace raw base64 payload bytes with a per-image estimate. - // We intentionally preserve the data URL prefix and JSON - // wrapper bytes already included in `raw`. - raw.saturating_sub(payload_bytes) - .saturating_add(replacement_bytes) - } + let (image_payload_bytes, image_replacement_bytes) = + image_data_url_estimate_adjustment(item); + let (encrypted_payload_bytes, encrypted_replacement_bytes) = + encrypted_function_output_estimate_adjustment(item); + // Replace raw base64 payload bytes with a per-image estimate. + // We intentionally preserve the data URL prefix and JSON + // wrapper bytes already included in `raw`. + let raw = raw + .saturating_sub(image_payload_bytes) + .saturating_add(image_replacement_bytes); + raw.saturating_sub(encrypted_payload_bytes) + .saturating_add(encrypted_replacement_bytes) } } } @@ -678,6 +684,31 @@ fn image_data_url_estimate_adjustment(item: &ResponseItem) -> (i64, i64) { (payload_bytes, replacement_bytes) } +fn encrypted_function_output_estimate_adjustment(item: &ResponseItem) -> (i64, i64) { + let ResponseItem::FunctionCallOutput { output, .. } = item else { + return (0, 0); + }; + let FunctionCallOutputBody::ContentItems(items) = &output.body else { + return (0, 0); + }; + + items.iter().fold((0i64, 0i64), |acc, item| { + let FunctionCallOutputContentItem::EncryptedContent { encrypted_content } = item else { + return acc; + }; + let payload_bytes = acc + .0 + .saturating_add(i64::try_from(encrypted_content.len()).unwrap_or(i64::MAX)); + let replacement_bytes = acc.1.saturating_add( + i64::try_from(estimate_encrypted_function_output_length( + encrypted_content.len(), + )) + .unwrap_or(i64::MAX), + ); + (payload_bytes, replacement_bytes) + }) +} + fn is_model_generated_item(item: &ResponseItem) -> bool { match item { ResponseItem::Message { role, .. } => role == "assistant", diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 8ba5d56e32..f63ae6ec1f 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -1754,6 +1754,26 @@ fn non_base64_image_urls_are_unchanged() { ); } +#[test] +fn encrypted_function_output_uses_plaintext_byte_estimate() { + let encrypted_content = "A".repeat(1_868); + let item = ResponseItem::FunctionCallOutput { + call_id: "call-encrypted".to_string(), + output: FunctionCallOutputPayload::from_content_items(vec![ + FunctionCallOutputContentItem::EncryptedContent { + encrypted_content: encrypted_content.clone(), + }, + ]), + }; + + let raw_len = serde_json::to_string(&item).unwrap().len() as i64; + let estimated = estimate_response_item_model_visible_bytes(&item); + let expected = raw_len - encrypted_content.len() as i64 + + estimate_encrypted_function_output_length(encrypted_content.len()) as i64; + + assert_eq!(estimated, expected); +} + #[test] fn data_url_without_base64_marker_is_unchanged() { let item = ResponseItem::Message { diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index d1d2abe755..2927cb1cd1 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1314,6 +1314,9 @@ pub enum FunctionCallOutputContentItem { #[ts(optional)] detail: Option, }, + EncryptedContent { + encrypted_content: String, + }, } /// Converts structured function-call output content into plain text for @@ -1337,7 +1340,8 @@ pub fn function_call_output_content_items_to_text( Some(text.as_str()) } FunctionCallOutputContentItem::InputText { .. } - | FunctionCallOutputContentItem::InputImage { .. } => None, + | FunctionCallOutputContentItem::InputImage { .. } + | FunctionCallOutputContentItem::EncryptedContent { .. } => None, }) .collect::>(); @@ -2063,6 +2067,9 @@ mod tests { image_url: "data:image/png;base64,AAA".to_string(), detail: Some(DEFAULT_IMAGE_DETAIL), }, + FunctionCallOutputContentItem::EncryptedContent { + encrypted_content: "enc_opaque".to_string(), + }, ]; let text = function_call_output_content_items_to_text(&content_items); @@ -2270,6 +2277,35 @@ mod tests { Ok(()) } + #[test] + fn serializes_encrypted_function_output_content_as_array() -> Result<()> { + let item = ResponseInputItem::FunctionCallOutput { + call_id: "call1".into(), + output: FunctionCallOutputPayload::from_content_items(vec![ + FunctionCallOutputContentItem::EncryptedContent { + encrypted_content: "enc_opaque".into(), + }, + ]), + }; + + let json = serde_json::to_value(&item)?; + assert_eq!( + json, + serde_json::json!({ + "type": "function_call_output", + "call_id": "call1", + "output": [ + { + "type": "encrypted_content", + "encrypted_content": "enc_opaque", + } + ], + }) + ); + + Ok(()) + } + #[test] fn preserves_existing_image_data_urls() -> Result<()> { let call_tool_result = CallToolResult { @@ -2394,6 +2430,30 @@ mod tests { Ok(()) } + #[test] + fn deserializes_encrypted_array_payload_into_items() -> Result<()> { + let json = r#"[ + {"type": "encrypted_content", "encrypted_content": "enc_opaque"} + ]"#; + + let payload: FunctionCallOutputPayload = serde_json::from_str(json)?; + let expected_items = vec![FunctionCallOutputContentItem::EncryptedContent { + encrypted_content: "enc_opaque".into(), + }]; + + assert_eq!(payload.success, None); + assert_eq!( + payload.body, + FunctionCallOutputBody::ContentItems(expected_items.clone()) + ); + assert_eq!( + serde_json::to_string(&payload)?, + serde_json::to_string(&expected_items)? + ); + + Ok(()) + } + #[test] fn deserializes_compaction_alias() -> Result<()> { let json = r#"{"type":"compaction_summary","encrypted_content":"abc"}"#; diff --git a/codex-rs/tools/src/tool_output.rs b/codex-rs/tools/src/tool_output.rs index 64a8164da3..b61bb80de8 100644 --- a/codex-rs/tools/src/tool_output.rs +++ b/codex-rs/tools/src/tool_output.rs @@ -209,7 +209,8 @@ fn content_items_to_code_mode_result(items: &[FunctionCallOutputContentItem]) -> Some(image_url.clone()) } FunctionCallOutputContentItem::InputText { .. } - | FunctionCallOutputContentItem::InputImage { .. } => None, + | FunctionCallOutputContentItem::InputImage { .. } + | FunctionCallOutputContentItem::EncryptedContent { .. } => None, }) .collect::>() .join("\n"), diff --git a/codex-rs/utils/output-truncation/src/lib.rs b/codex-rs/utils/output-truncation/src/lib.rs index 24b1630da1..52cd741b7d 100644 --- a/codex-rs/utils/output-truncation/src/lib.rs +++ b/codex-rs/utils/output-truncation/src/lib.rs @@ -34,7 +34,8 @@ pub fn formatted_truncate_text_content_items_with_policy( .iter() .filter_map(|item| match item { FunctionCallOutputContentItem::InputText { text } => Some(text.as_str()), - FunctionCallOutputContentItem::InputImage { .. } => None, + FunctionCallOutputContentItem::InputImage { .. } + | FunctionCallOutputContentItem::EncryptedContent { .. } => None, }) .collect::>(); @@ -64,6 +65,11 @@ pub fn formatted_truncate_text_content_items_with_policy( detail: *detail, }) } + FunctionCallOutputContentItem::EncryptedContent { encrypted_content } => { + Some(FunctionCallOutputContentItem::EncryptedContent { + encrypted_content: encrypted_content.clone(), + }) + } FunctionCallOutputContentItem::InputText { .. } => None, })); @@ -117,6 +123,11 @@ pub fn truncate_function_output_items_with_policy( detail: *detail, }); } + FunctionCallOutputContentItem::EncryptedContent { encrypted_content } => { + out.push(FunctionCallOutputContentItem::EncryptedContent { + encrypted_content: encrypted_content.clone(), + }); + } } } diff --git a/codex-rs/utils/output-truncation/src/truncate_tests.rs b/codex-rs/utils/output-truncation/src/truncate_tests.rs index 74acb15ca3..baf26a058e 100644 --- a/codex-rs/utils/output-truncation/src/truncate_tests.rs +++ b/codex-rs/utils/output-truncation/src/truncate_tests.rs @@ -251,6 +251,60 @@ fn formatted_truncate_text_content_items_with_policy_merges_text_and_appends_ima assert_eq!(original_token_count, Some(4)); } +#[test] +fn formatted_truncate_text_content_items_with_policy_preserves_encrypted_content() { + let items = vec![ + FunctionCallOutputContentItem::InputText { + text: "abcdefgh".to_string(), + }, + FunctionCallOutputContentItem::EncryptedContent { + encrypted_content: "enc_opaque".to_string(), + }, + ]; + + let (output, original_token_count) = + formatted_truncate_text_content_items_with_policy(&items, TruncationPolicy::Bytes(2)); + + assert_eq!( + output, + vec![ + FunctionCallOutputContentItem::InputText { + text: "Total output lines: 1\n\na…6 chars truncated…h".to_string(), + }, + FunctionCallOutputContentItem::EncryptedContent { + encrypted_content: "enc_opaque".to_string(), + }, + ] + ); + assert_eq!(original_token_count, Some(2)); +} + +#[test] +fn truncate_function_output_items_with_policy_preserves_encrypted_content() { + let items = vec![ + FunctionCallOutputContentItem::InputText { + text: "abcdefgh".to_string(), + }, + FunctionCallOutputContentItem::EncryptedContent { + encrypted_content: "enc_opaque".to_string(), + }, + ]; + + let output = truncate_function_output_items_with_policy(&items, TruncationPolicy::Bytes(2)); + + assert_eq!( + output, + vec![ + FunctionCallOutputContentItem::InputText { + text: "a…6 chars truncated…h".to_string(), + }, + FunctionCallOutputContentItem::EncryptedContent { + encrypted_content: "enc_opaque".to_string(), + }, + ] + ); +} + #[test] fn formatted_truncate_text_content_items_with_policy_merges_all_text_for_token_budget() { let items = vec![ From 000bf5ce6d0afd3155a6e2315e28799ec73f8679 Mon Sep 17 00:00:00 2001 From: richardopenai Date: Wed, 20 May 2026 00:25:04 -0700 Subject: [PATCH 26/63] Migrate exec-server remote registration to environments (#23633) ## Summary - migrate exec-server remote registration naming from executor to environment - align CLI, public Rust exports, registry error messages, and relay test fixtures with the environment registry contract - keep the live registration path and response model consistent with `/cloud/environment/{environment_id}/register` ## Verification - `cargo test -p codex-exec-server remote::tests::register_environment_posts_with_auth_provider_headers --manifest-path /Users/richardlee/code/codex/codex-rs/Cargo.toml` - `cargo test -p codex-exec-server --test relay multiplexed_remote_environment_routes_independent_virtual_streams --manifest-path /Users/richardlee/code/codex/codex-rs/Cargo.toml` - `cargo check -p codex-cli --manifest-path /Users/richardlee/code/codex/codex-rs/Cargo.toml` (still running when PR opened; will update after completion if needed) --- codex-rs/cli/src/main.rs | 27 +++--- codex-rs/core/tests/suite/rmcp_client.rs | 2 +- codex-rs/exec-server/README.md | 16 ++-- codex-rs/exec-server/src/client.rs | 24 ++--- codex-rs/exec-server/src/lib.rs | 4 +- codex-rs/exec-server/src/process.rs | 2 +- codex-rs/exec-server/src/relay.rs | 4 +- codex-rs/exec-server/src/remote.rs | 112 +++++++++++------------ codex-rs/exec-server/tests/relay.rs | 32 ++++--- codex-rs/file-system/src/lib.rs | 2 +- 10 files changed, 115 insertions(+), 110 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 1b71ab15ae..f4127c437c 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -482,15 +482,15 @@ struct ExecServerCommand { #[arg(long = "listen", value_name = "URL", conflicts_with = "remote")] listen: Option, - /// Register this exec-server as a remote executor using the given base URL. - #[arg(long = "remote", value_name = "URL", requires = "executor_id")] + /// Register this exec-server as a remote environment using the given base URL. + #[arg(long = "remote", value_name = "URL", requires = "environment_id")] remote: Option, - /// Executor id to attach to when registering remotely. - #[arg(long = "executor-id", value_name = "ID")] - executor_id: Option, + /// Environment id to attach to when registering remotely. + #[arg(long = "environment-id", value_name = "ID")] + environment_id: Option, - /// Human-readable executor name. + /// Human-readable environment name. #[arg(long = "name", value_name = "NAME")] name: Option, @@ -1491,21 +1491,24 @@ async fn run_exec_server_command( arg0_paths.codex_linux_sandbox_exe.clone(), )?; if let Some(base_url) = cmd.remote { - let executor_id = cmd - .executor_id - .ok_or_else(|| anyhow::anyhow!("--executor-id is required when --remote is set"))?; + let environment_id = cmd + .environment_id + .ok_or_else(|| anyhow::anyhow!("--environment-id is required when --remote is set"))?; let auth_provider = load_exec_server_remote_auth_provider( root_config_overrides, config_profile, cmd.use_agent_identity_auth, ) .await?; - let mut remote_config = - codex_exec_server::RemoteExecutorConfig::new(base_url, executor_id, auth_provider)?; + let mut remote_config = codex_exec_server::RemoteEnvironmentConfig::new( + base_url, + environment_id, + auth_provider, + )?; if let Some(name) = cmd.name { remote_config.name = name; } - codex_exec_server::run_remote_executor(remote_config, runtime_paths).await?; + codex_exec_server::run_remote_environment(remote_config, runtime_paths).await?; return Ok(()); } let listen_url = cmd diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 7c811a88e0..210c287c2f 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1808,7 +1808,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { .await; // Phase 2: start the Streamable HTTP MCP test server in the active - // placement. In full CI this may be the remote executor container; locally + // placement. In full CI this may be the remote environment container; locally // it is a host process. let expected_env_value = "propagated-env-http"; let Some(http_server) = diff --git a/codex-rs/exec-server/README.md b/codex-rs/exec-server/README.md index 01439108f8..468ac205f7 100644 --- a/codex-rs/exec-server/README.md +++ b/codex-rs/exec-server/README.md @@ -22,10 +22,10 @@ the wire. The CLI entrypoint supports: - `ws://IP:PORT` (default) -- `--remote URL --executor-id ID [--name NAME]` +- `--remote URL --environment-id ID [--name NAME]` -Remote mode registers the local exec-server with the executor registry, -then reconnects to the service-provided rendezvous websocket as the executor. +Remote mode registers the local exec-server with the environment registry, +then reconnects to the service-provided rendezvous websocket as the environment. It uses the standard Codex ChatGPT sign-in state; run `codex login` first when remote registration needs authentication. Containerized callers that receive an Agent Identity JWT in `CODEX_ACCESS_TOKEN` can opt into that auth path with @@ -39,7 +39,7 @@ Wire framing: ## Remote Relay Message Format -In remote mode, the harness and executor communicate through rendezvous using +In remote mode, the harness and environment communicate through rendezvous using `codex.exec_server.relay.v1.RelayMessageFrame`; the checked-in schema is in `src/proto/codex.exec_server.relay.v1.proto`. The relay frame carries stream identity plus endpoint-owned reliability metadata: @@ -58,8 +58,8 @@ next_seq // resume only: next sender seq reason // reset only: reset reason ``` -`stream_id` identifies one virtual harness/executor JSON-RPC session on the -executor websocket. The harness generates a UUIDv4 `stream_id`; the executor +`stream_id` identifies one virtual harness/environment JSON-RPC session on the +environment websocket. The harness generates a UUIDv4 `stream_id`; the environment demuxes frames by `stream_id` and runs an independent `ConnectionProcessor` per stream. @@ -375,12 +375,12 @@ The crate exports: - `DEFAULT_LISTEN_URL` and `ExecServerListenUrlParseError` - `ExecServerRuntimePaths` - `run_main()` for embedding the websocket server -- `RemoteExecutorConfig` and `run_remote_executor()` for embedding remote +- `RemoteEnvironmentConfig` and `run_remote_environment()` for embedding remote registration mode Callers must pass `ExecServerRuntimePaths` to `run_main()`. The top-level `codex exec-server` command builds these paths from the `codex` arg0 dispatch -state. `RemoteExecutorConfig::new(...)` also takes the auth provider that +state. `RemoteEnvironmentConfig::new(...)` also takes the auth provider that remote registration should use; the CLI builds that provider from Codex auth state before starting remote mode. diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index 9261a59542..bb5a3855dc 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -162,7 +162,7 @@ struct Inner { // 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 executor operation should fail quickly + // 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, @@ -261,18 +261,18 @@ pub enum ExecServerError { Protocol(String), #[error("exec-server rejected request ({code}): {message}")] Server { code: i64, message: String }, - #[error("executor registry request failed ({status}{code_suffix}): {message}", code_suffix = .code.as_ref().map(|code| format!(", {code}")).unwrap_or_default())] - ExecutorRegistryHttp { + #[error("environment registry request failed ({status}{code_suffix}): {message}", code_suffix = .code.as_ref().map(|code| format!(", {code}")).unwrap_or_default())] + EnvironmentRegistryHttp { status: reqwest::StatusCode, code: Option, message: String, }, - #[error("executor registry configuration error: {0}")] - ExecutorRegistryConfig(String), - #[error("executor registry authentication error: {0}")] - ExecutorRegistryAuth(String), - #[error("executor registry request failed: {0}")] - ExecutorRegistryRequest(#[from] reqwest::Error), + #[error("environment registry configuration error: {0}")] + EnvironmentRegistryConfig(String), + #[error("environment registry authentication error: {0}")] + EnvironmentRegistryAuth(String), + #[error("environment registry request failed: {0}")] + EnvironmentRegistryRequest(#[from] reqwest::Error), } impl ExecServerClient { @@ -495,7 +495,7 @@ impl ExecServerClient { { // 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 executor disconnect. + // this is the shared low-level failure path after environment disconnect. if let Some(error) = self.inner.disconnected_error() { return Err(error); } @@ -720,7 +720,7 @@ impl Inner { session: Arc, ) -> Result<(), ExecServerError> { let _sessions_write_guard = self.sessions_write_lock.lock().await; - // Do not register a process session that can never receive executor + // 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() { @@ -795,7 +795,7 @@ async fn fail_all_sessions(inner: &Arc, message: String) { 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 executor-backed MCP stdio. + // such as environment-backed MCP stdio. session.set_failure(message.clone()).await; } } diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index bd556638ea..f2d16f8fac 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -91,8 +91,8 @@ pub use protocol::TerminateResponse; pub use protocol::WriteParams; pub use protocol::WriteResponse; pub use protocol::WriteStatus; -pub use remote::RemoteExecutorConfig; -pub use remote::run_remote_executor; +pub use remote::RemoteEnvironmentConfig; +pub use remote::run_remote_environment; pub use runtime_paths::ExecServerRuntimePaths; pub use server::DEFAULT_LISTEN_URL; pub use server::ExecServerListenUrlParseError; diff --git a/codex-rs/exec-server/src/process.rs b/codex-rs/exec-server/src/process.rs index 052e4aa643..cb6c832138 100644 --- a/codex-rs/exec-server/src/process.rs +++ b/codex-rs/exec-server/src/process.rs @@ -24,7 +24,7 @@ pub struct StartedExecProcess { /// stdout, stderr, or pty bytes. `Exited` reports the process exit status, while /// `Closed` means all output streams have ended and no more output events will /// arrive. `Failed` is used when the process session cannot continue, for -/// example because the remote executor connection disconnected. +/// example because the remote environment connection disconnected. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ExecProcessEvent { Output(ProcessOutputChunk), diff --git a/codex-rs/exec-server/src/relay.rs b/codex-rs/exec-server/src/relay.rs index b3708f1d83..ae07d7652c 100644 --- a/codex-rs/exec-server/src/relay.rs +++ b/codex-rs/exec-server/src/relay.rs @@ -298,7 +298,7 @@ where } } -pub(crate) async fn run_multiplexed_executor( +pub(crate) async fn run_multiplexed_environment( stream: WebSocketStream, processor: ConnectionProcessor, ) where @@ -337,7 +337,7 @@ pub(crate) async fn run_multiplexed_executor( continue; } Some(Err(err)) => { - debug!("multiplexed executor websocket read failed: {err}"); + debug!("multiplexed environment websocket read failed: {err}"); break; } } diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index 2493135c9a..de09d94e7c 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -11,28 +11,28 @@ use codex_utils_rustls_provider::ensure_rustls_crypto_provider; use crate::ExecServerError; use crate::ExecServerRuntimePaths; -use crate::relay::run_multiplexed_executor; +use crate::relay::run_multiplexed_environment; use crate::server::ConnectionProcessor; const ERROR_BODY_PREVIEW_BYTES: usize = 4096; #[derive(Clone)] -struct ExecutorRegistryClient { +struct EnvironmentRegistryClient { base_url: String, auth_provider: SharedAuthProvider, http: reqwest::Client, } -impl std::fmt::Debug for ExecutorRegistryClient { +impl std::fmt::Debug for EnvironmentRegistryClient { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ExecutorRegistryClient") + f.debug_struct("EnvironmentRegistryClient") .field("base_url", &self.base_url) .field("auth_provider", &"") .finish_non_exhaustive() } } -impl ExecutorRegistryClient { +impl EnvironmentRegistryClient { fn new(base_url: String, auth_provider: SharedAuthProvider) -> Result { let base_url = normalize_base_url(base_url)?; Ok(Self { @@ -42,15 +42,15 @@ impl ExecutorRegistryClient { }) } - async fn register_executor( + async fn register_environment( &self, - executor_id: &str, - ) -> Result { + environment_id: &str, + ) -> Result { let response = self .http .post(endpoint_url( &self.base_url, - &format!("/cloud/executor/{executor_id}/register"), + &format!("/cloud/environment/{environment_id}/register"), )) .headers(self.auth_provider.to_auth_headers()) .send() @@ -72,49 +72,49 @@ impl ExecutorRegistryClient { let status = response.status(); let body = response.text().await.unwrap_or_default(); if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) { - return Err(executor_registry_auth_error(status, &body)); + return Err(environment_registry_auth_error(status, &body)); } - Err(executor_registry_http_error(status, &body)) + Err(environment_registry_http_error(status, &body)) } } #[derive(Debug, Clone, Eq, PartialEq, Deserialize)] -struct ExecutorRegistryExecutorRegistrationResponse { - executor_id: String, +struct EnvironmentRegistryRegistrationResponse { + environment_id: String, url: String, } /// Configuration for registering an exec-server for remote use. #[derive(Clone)] -pub struct RemoteExecutorConfig { +pub struct RemoteEnvironmentConfig { pub base_url: String, - pub executor_id: String, + pub environment_id: String, pub name: String, auth_provider: SharedAuthProvider, } -impl std::fmt::Debug for RemoteExecutorConfig { +impl std::fmt::Debug for RemoteEnvironmentConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("RemoteExecutorConfig") + f.debug_struct("RemoteEnvironmentConfig") .field("base_url", &self.base_url) - .field("executor_id", &self.executor_id) + .field("environment_id", &self.environment_id) .field("name", &self.name) .field("auth_provider", &"") .finish() } } -impl RemoteExecutorConfig { +impl RemoteEnvironmentConfig { pub fn new( base_url: String, - executor_id: String, + environment_id: String, auth_provider: SharedAuthProvider, ) -> Result { - let executor_id = normalize_executor_id(executor_id)?; + let environment_id = normalize_environment_id(environment_id)?; Ok(Self { base_url, - executor_id, + environment_id, name: "codex-exec-server".to_string(), auth_provider, }) @@ -123,27 +123,27 @@ impl RemoteExecutorConfig { /// Register an exec-server for remote use and serve requests over the returned /// rendezvous websocket. -pub async fn run_remote_executor( - config: RemoteExecutorConfig, +pub async fn run_remote_environment( + config: RemoteEnvironmentConfig, runtime_paths: ExecServerRuntimePaths, ) -> Result<(), ExecServerError> { ensure_rustls_crypto_provider(); let client = - ExecutorRegistryClient::new(config.base_url.clone(), config.auth_provider.clone())?; + EnvironmentRegistryClient::new(config.base_url.clone(), config.auth_provider.clone())?; let processor = ConnectionProcessor::new(runtime_paths); let mut backoff = Duration::from_secs(1); loop { - let response = client.register_executor(&config.executor_id).await?; + let response = client.register_environment(&config.environment_id).await?; eprintln!( - "codex exec-server remote executor registered with executor_id {}", - response.executor_id + "codex exec-server remote environment registered with environment_id {}", + response.environment_id ); match connect_async(response.url.as_str()).await { Ok((websocket, _)) => { backoff = Duration::from_secs(1); - run_multiplexed_executor(websocket, processor.clone()).await; + run_multiplexed_environment(websocket, processor.clone()).await; } Err(err) => { warn!("failed to connect remote exec-server websocket: {err}"); @@ -155,14 +155,14 @@ pub async fn run_remote_executor( } } -fn normalize_executor_id(executor_id: String) -> Result { - let executor_id = executor_id.trim().to_string(); - if executor_id.is_empty() { - return Err(ExecServerError::ExecutorRegistryConfig( - "executor id is required for remote exec-server registration".to_string(), +fn normalize_environment_id(environment_id: String) -> Result { + let environment_id = environment_id.trim().to_string(); + if environment_id.is_empty() { + return Err(ExecServerError::EnvironmentRegistryConfig( + "environment id is required for remote exec-server registration".to_string(), )); } - Ok(executor_id) + Ok(environment_id) } #[derive(Deserialize)] @@ -179,8 +179,8 @@ struct RegistryError { fn normalize_base_url(base_url: String) -> Result { let trimmed = base_url.trim().trim_end_matches('/').to_string(); if trimmed.is_empty() { - return Err(ExecServerError::ExecutorRegistryConfig( - "executor registry base URL is required".to_string(), + return Err(ExecServerError::EnvironmentRegistryConfig( + "environment registry base URL is required".to_string(), )); } Ok(trimmed) @@ -190,14 +190,14 @@ fn endpoint_url(base_url: &str, path: &str) -> String { format!("{base_url}/{}", path.trim_start_matches('/')) } -fn executor_registry_auth_error(status: StatusCode, body: &str) -> ExecServerError { +fn environment_registry_auth_error(status: StatusCode, body: &str) -> ExecServerError { let message = registry_error_message(body).unwrap_or_else(|| "empty error body".to_string()); - ExecServerError::ExecutorRegistryAuth(format!( - "executor registry authentication failed ({status}): {message}" + ExecServerError::EnvironmentRegistryAuth(format!( + "environment registry authentication failed ({status}): {message}" )) } -fn executor_registry_http_error(status: StatusCode, body: &str) -> ExecServerError { +fn environment_registry_http_error(status: StatusCode, body: &str) -> ExecServerError { let parsed = serde_json::from_str::(body).ok(); let (code, message) = parsed .and_then(|body| body.error) @@ -216,7 +216,7 @@ fn executor_registry_http_error(status: StatusCode, body: &str) -> ExecServerErr .unwrap_or_else(|| "empty or malformed error body".to_string()), ) }); - ExecServerError::ExecutorRegistryHttp { + ExecServerError::EnvironmentRegistryHttp { status, code, message, @@ -277,46 +277,46 @@ mod tests { } #[tokio::test] - async fn register_executor_posts_with_auth_provider_headers() { + async fn register_environment_posts_with_auth_provider_headers() { let server = MockServer::start().await; - let config = RemoteExecutorConfig::new( + let config = RemoteEnvironmentConfig::new( server.uri(), - "exec-requested".to_string(), + "environment-requested".to_string(), static_registry_auth_provider(), ) .expect("config"); Mock::given(method("POST")) - .and(path("/cloud/executor/exec-requested/register")) + .and(path("/cloud/environment/environment-requested/register")) .and(header("authorization", "Bearer registry-token")) .and(header("chatgpt-account-id", "workspace-123")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "executor_id": "exec-1", - "url": "wss://rendezvous.test/executor/exec-1?role=executor&sig=abc" + "environment_id": "env-1", + "url": "wss://rendezvous.test/cloud-agent/default/ws/environment/env-1?role=environment&sig=abc" }))) .mount(&server) .await; - let client = ExecutorRegistryClient::new(server.uri(), static_registry_auth_provider()) + let client = EnvironmentRegistryClient::new(server.uri(), static_registry_auth_provider()) .expect("client"); let response = client - .register_executor(&config.executor_id) + .register_environment(&config.environment_id) .await - .expect("register executor"); + .expect("register environment"); assert_eq!( response, - ExecutorRegistryExecutorRegistrationResponse { - executor_id: "exec-1".to_string(), - url: "wss://rendezvous.test/executor/exec-1?role=executor&sig=abc".to_string(), + EnvironmentRegistryRegistrationResponse { + environment_id: "env-1".to_string(), + url: "wss://rendezvous.test/cloud-agent/default/ws/environment/env-1?role=environment&sig=abc".to_string(), } ); } #[test] fn debug_output_redacts_auth_provider() { - let config = RemoteExecutorConfig::new( + let config = RemoteEnvironmentConfig::new( "https://registry.example".to_string(), - "exec-1".to_string(), + "env-1".to_string(), static_registry_auth_provider(), ) .expect("config"); diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index 9f985e6982..ff41dd525e 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -20,7 +20,7 @@ use codex_app_server_protocol::RequestId; use codex_exec_server::ExecServerRuntimePaths; use codex_exec_server::InitializeParams; use codex_exec_server::InitializeResponse; -use codex_exec_server::RemoteExecutorConfig; +use codex_exec_server::RemoteEnvironmentConfig; use futures::SinkExt; use futures::StreamExt; use http::HeaderMap; @@ -45,7 +45,7 @@ use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; -const EXECUTOR_ID: &str = "exec-mux-test"; +const ENVIRONMENT_ID: &str = "env-mux-test"; const REGISTRY_TOKEN: &str = "registry-token"; const RELAY_MESSAGE_FRAME_VERSION: u32 = 1; const TEST_TIMEOUT: Duration = Duration::from_secs(5); @@ -67,15 +67,17 @@ fn static_registry_auth_provider() -> codex_api::SharedAuthProvider { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn multiplexed_remote_executor_routes_independent_virtual_streams() -> Result<()> { +async fn multiplexed_remote_environment_routes_independent_virtual_streams() -> Result<()> { let listener = TcpListener::bind("127.0.0.1:0").await?; let rendezvous_url = format!("ws://{}", listener.local_addr()?); let registry = MockServer::start().await; Mock::given(method("POST")) - .and(path(format!("/cloud/executor/{EXECUTOR_ID}/register"))) + .and(path(format!( + "/cloud/environment/{ENVIRONMENT_ID}/register" + ))) .and(header("authorization", format!("Bearer {REGISTRY_TOKEN}"))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "executor_id": EXECUTOR_ID, + "environment_id": ENVIRONMENT_ID, "url": rendezvous_url, }))) .mount(®istry) @@ -83,22 +85,22 @@ async fn multiplexed_remote_executor_routes_independent_virtual_streams() -> Res let (codex_exe, codex_linux_sandbox_exe) = common::current_test_binary_helper_paths()?; let runtime_paths = ExecServerRuntimePaths::new(codex_exe, codex_linux_sandbox_exe)?; - let config = RemoteExecutorConfig::new( + let config = RemoteEnvironmentConfig::new( registry.uri(), - EXECUTOR_ID.to_string(), + ENVIRONMENT_ID.to_string(), static_registry_auth_provider(), )?; - let remote_executor = tokio::spawn(codex_exec_server::run_remote_executor( + let remote_environment = tokio::spawn(codex_exec_server::run_remote_environment( config, runtime_paths, )); let (socket, _peer_addr) = timeout(TEST_TIMEOUT, listener.accept()) .await - .context("remote executor should connect to fake rendezvous")??; + .context("remote environment should connect to fake rendezvous")??; let mut websocket = timeout(TEST_TIMEOUT, accept_async(socket)) .await - .context("fake rendezvous should accept executor websocket")??; + .context("fake rendezvous should accept environment websocket")??; let stream_a = "stream-a"; let stream_b = "stream-b"; @@ -192,8 +194,8 @@ async fn multiplexed_remote_executor_routes_independent_virtual_streams() -> Res )?; websocket.close(None).await?; - remote_executor.abort(); - let _ = remote_executor.await; + remote_environment.abort(); + let _ = remote_environment.await; Ok(()) } @@ -285,7 +287,7 @@ where let frame = timeout(TEST_TIMEOUT, websocket.next()) .await .context("timed out waiting for relay frame")? - .ok_or_else(|| anyhow!("executor websocket closed"))??; + .ok_or_else(|| anyhow!("environment websocket closed"))??; match frame { Message::Binary(bytes) => { let frame = RelayMessageFrame::decode(bytes.as_ref())?; @@ -297,8 +299,8 @@ where return Ok((stream_id, message)); } Message::Ping(_) | Message::Pong(_) => {} - Message::Close(_) => bail!("executor websocket closed"), - Message::Text(_) => bail!("executor sent text frame on relay websocket"), + Message::Close(_) => bail!("environment websocket closed"), + Message::Text(_) => bail!("environment sent text frame on relay websocket"), Message::Frame(_) => {} } } diff --git a/codex-rs/file-system/src/lib.rs b/codex-rs/file-system/src/lib.rs index 026523eb2f..606f987cbd 100644 --- a/codex-rs/file-system/src/lib.rs +++ b/codex-rs/file-system/src/lib.rs @@ -130,7 +130,7 @@ fn file_system_policy_has_cwd_dependent_entries( pub type FileSystemResult = io::Result; /// Abstract filesystem access used by components that may operate locally or via -/// a remote executor. +/// a remote environment. #[async_trait] pub trait ExecutorFileSystem: Send + Sync { async fn read_file( From 18cefba922ab17fda8783ac7c61c9b96798439cc Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 20 May 2026 11:56:00 +0200 Subject: [PATCH 27/63] Add timeout for remote compaction requests (#23451) ## Why Remote compaction currently sends a unary `POST /responses/compact` and waits for the full response before replacing history or emitting the completed `ContextCompaction` item. Unlike normal `/responses` streaming requests, this unary compact request had no timeout boundary. If the backend accepts the request and then stalls before returning a body, the existing request retry policy never sees a transport error, so the compact turn can remain stuck after the started item with no completion or actionable error. That matches the reported hang shape in issues such as #18363, where logs show `responses/compact` was posted but no corresponding compact completion followed. A bounded request timeout gives the existing retry policy a concrete timeout error to retry instead of letting the user sit indefinitely on automatic context compaction. ## What - Add a request timeout to legacy `/responses/compact` calls. - Size that timeout from the provider stream idle timeout with a conservative multiplier, so the default compact attempt gets 20 minutes rather than the 5 minute stream idle window. - Map API transport timeouts to a request timeout error instead of the child-process timeout message. ## Testing - Not run (per request; CI will cover). --- codex-rs/codex-api/src/api_bridge.rs | 2 +- codex-rs/codex-api/src/endpoint/compact.rs | 15 +++++++++++++-- codex-rs/core/src/client.rs | 9 ++++++++- codex-rs/protocol/src/error.rs | 3 +++ 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/codex-rs/codex-api/src/api_bridge.rs b/codex-rs/codex-api/src/api_bridge.rs index 4f37c2ff9b..401dfab3a9 100644 --- a/codex-rs/codex-api/src/api_bridge.rs +++ b/codex-rs/codex-api/src/api_bridge.rs @@ -123,7 +123,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr { status: http::StatusCode::INTERNAL_SERVER_ERROR, request_id: None, }), - TransportError::Timeout => CodexErr::Timeout, + TransportError::Timeout => CodexErr::RequestTimeout, TransportError::Network(msg) | TransportError::Build(msg) => { CodexErr::Stream(msg, None) } diff --git a/codex-rs/codex-api/src/endpoint/compact.rs b/codex-rs/codex-api/src/endpoint/compact.rs index 336e958d4e..a3da854844 100644 --- a/codex-rs/codex-api/src/endpoint/compact.rs +++ b/codex-rs/codex-api/src/endpoint/compact.rs @@ -11,6 +11,7 @@ use http::Method; use serde::Deserialize; use serde_json::to_value; use std::sync::Arc; +use std::time::Duration; pub struct CompactClient { session: EndpointSession, @@ -37,10 +38,19 @@ impl CompactClient { &self, body: serde_json::Value, extra_headers: HeaderMap, + request_timeout: Duration, ) -> Result, ApiError> { let resp = self .session - .execute(Method::POST, Self::path(), extra_headers, Some(body)) + .execute_with( + Method::POST, + Self::path(), + extra_headers, + Some(body), + |req| { + req.timeout = Some(request_timeout); + }, + ) .await?; let parsed: CompactHistoryResponse = serde_json::from_slice(&resp.body).map_err(|e| ApiError::Stream(e.to_string()))?; @@ -51,10 +61,11 @@ impl CompactClient { &self, input: &CompactionInput<'_>, extra_headers: HeaderMap, + request_timeout: Duration, ) -> Result, ApiError> { let body = to_value(input) .map_err(|e| ApiError::Stream(format!("failed to encode compaction input: {e}")))?; - self.compact(body, extra_headers).await + self.compact(body, extra_headers, request_timeout).await } } diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index f604a63458..e3569e6d18 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -146,6 +146,9 @@ const X_CODEX_WS_STREAM_REQUEST_START_MS_CLIENT_METADATA_KEY: &str = const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; const RESPONSES_ENDPOINT: &str = "/responses"; const RESPONSES_COMPACT_ENDPOINT: &str = "/responses/compact"; +// `/responses/compact` is unary, so the timeout covers the full response rather than one idle +// period between stream events. +const COMPACT_REQUEST_TIMEOUT_IDLE_MULTIPLIER: u32 = 4; const MEMORIES_SUMMARIZE_ENDPOINT: &str = "/memories/trace_summarize"; #[cfg(test)] pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration = @@ -502,12 +505,16 @@ impl ModelClient { if let Some(header_value) = self.generate_attestation_header_for().await { extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value); } + let compact_request_timeout = client_setup + .api_provider + .stream_idle_timeout + .saturating_mul(COMPACT_REQUEST_TIMEOUT_IDLE_MULTIPLIER); let client = ApiCompactClient::new(transport, client_setup.api_provider, client_setup.api_auth) .with_telemetry(Some(request_telemetry)); let trace_attempt = compaction_trace.start_attempt(&payload); let result = client - .compact_input(&payload, extra_headers) + .compact_input(&payload, extra_headers, compact_request_timeout) .await .map_err(map_api_error); trace_attempt.record_result(result.as_deref()); diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index 207fd94ca2..ef9c86cadf 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -89,6 +89,8 @@ pub enum CodexErr { /// Returned by run_command_stream when the spawned child process timed out (10s). #[error("timeout waiting for child process to exit")] Timeout, + #[error("request timed out")] + RequestTimeout, /// Returned by run_command_stream when the child could not be spawned (its stdout/stderr pipes /// could not be captured). Analogous to the previous `CodexError::Spawn` variant. #[error("spawn failed: child stdout/stderr not captured")] @@ -192,6 +194,7 @@ impl CodexErr { | CodexErr::CyberPolicy { .. } => false, CodexErr::Stream(..) | CodexErr::Timeout + | CodexErr::RequestTimeout | CodexErr::UnexpectedStatus(_) | CodexErr::ResponseStreamFailed(_) | CodexErr::ConnectionFailed(_) From 93456320ef6d95543eb944b923302a5c062794c2 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 20 May 2026 12:05:58 +0200 Subject: [PATCH 28/63] feat: rename 1 (#23667) Just a mechanical renaming --- .../tests/suite/v2/thread_resume.rs | 2 +- codex-rs/core/src/goals.rs | 10 ++-- codex-rs/state/src/lib.rs | 2 +- codex-rs/state/src/runtime.rs | 2 +- codex-rs/state/src/runtime/goals.rs | 54 +++++++++---------- 5 files changed, 35 insertions(+), 35 deletions(-) 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 036f2368a0..d4baf7d773 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -1108,7 +1108,7 @@ async fn thread_goal_set_edits_objective_without_resetting_usage() -> Result<()> thread_id, /*time_delta_seconds*/ 12, /*token_delta*/ 50, - codex_state::ThreadGoalAccountingMode::ActiveOnly, + codex_state::GoalAccountingMode::ActiveOnly, Some(persisted_goal.goal_id.as_str()), ) .await?; diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index 6843264e44..794b14ea79 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -459,7 +459,7 @@ impl Session { self.account_thread_goal_wall_clock_usage( &state_db, - codex_state::ThreadGoalAccountingMode::ActiveOnly, + codex_state::GoalAccountingMode::ActiveOnly, TerminalMetricEmission::Emit, ) .await?; @@ -601,7 +601,7 @@ impl Session { let state_db = self.require_state_db_for_thread_goals().await?; self.account_thread_goal_wall_clock_usage( &state_db, - codex_state::ThreadGoalAccountingMode::ActiveOnly, + codex_state::GoalAccountingMode::ActiveOnly, TerminalMetricEmission::Emit, ) .await?; @@ -1028,7 +1028,7 @@ impl Session { self.conversation_id, time_delta_seconds, token_delta, - codex_state::ThreadGoalAccountingMode::ActiveOnly, + codex_state::GoalAccountingMode::ActiveOnly, expected_goal_id.as_deref(), ) .await?; @@ -1119,7 +1119,7 @@ impl Session { }; self.account_thread_goal_wall_clock_usage( &state_db, - codex_state::ThreadGoalAccountingMode::ActiveOnly, + codex_state::GoalAccountingMode::ActiveOnly, TerminalMetricEmission::Suppress, ) .await?; @@ -1129,7 +1129,7 @@ impl Session { async fn account_thread_goal_wall_clock_usage( &self, state_db: &StateDbHandle, - mode: codex_state::ThreadGoalAccountingMode, + mode: codex_state::GoalAccountingMode, terminal_metric_emission: TerminalMetricEmission, ) -> anyhow::Result> { let _accounting_permit = self.goal_runtime.accounting_permit().await?; diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index 8746ff3a0d..f835000fa4 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -48,11 +48,11 @@ pub use model::ThreadGoalStatus; pub use model::ThreadMetadata; pub use model::ThreadMetadataBuilder; pub use model::ThreadsPage; +pub use runtime::GoalAccountingMode; pub use runtime::GoalStore; pub use runtime::RemoteControlEnrollmentRecord; pub use runtime::RuntimeDbPath; pub use runtime::ThreadFilterOptions; -pub use runtime::ThreadGoalAccountingMode; pub use runtime::ThreadGoalAccountingOutcome; pub use runtime::ThreadGoalUpdate; pub use runtime::goals_db_filename; diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index ac03f227da..1dc349b231 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -66,8 +66,8 @@ mod remote_control; mod test_support; mod threads; +pub use goals::GoalAccountingMode; pub use goals::GoalStore; -pub use goals::ThreadGoalAccountingMode; pub use goals::ThreadGoalAccountingOutcome; pub use goals::ThreadGoalUpdate; pub use remote_control::RemoteControlEnrollmentRecord; diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs index bf5a7bd825..c9a1d126bb 100644 --- a/codex-rs/state/src/runtime/goals.rs +++ b/codex-rs/state/src/runtime/goals.rs @@ -26,7 +26,7 @@ pub enum ThreadGoalAccountingOutcome { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ThreadGoalAccountingMode { +pub enum GoalAccountingMode { ActiveStatusOnly, ActiveOnly, ActiveOrComplete, @@ -387,7 +387,7 @@ WHERE thread_id = ? thread_id: ThreadId, time_delta_seconds: i64, token_delta: i64, - mode: ThreadGoalAccountingMode, + mode: GoalAccountingMode, expected_goal_id: Option<&str>, ) -> anyhow::Result { let time_delta_seconds = time_delta_seconds.max(0); @@ -400,20 +400,20 @@ WHERE thread_id = ? let now_ms = datetime_to_epoch_millis(Utc::now()); let status_filter = match mode { - ThreadGoalAccountingMode::ActiveStatusOnly => "status = 'active'", - ThreadGoalAccountingMode::ActiveOnly => "status IN ('active', 'budget_limited')", - ThreadGoalAccountingMode::ActiveOrComplete => { + GoalAccountingMode::ActiveStatusOnly => "status = 'active'", + GoalAccountingMode::ActiveOnly => "status IN ('active', 'budget_limited')", + GoalAccountingMode::ActiveOrComplete => { "status IN ('active', 'budget_limited', 'complete')" } - ThreadGoalAccountingMode::ActiveOrStopped => { + GoalAccountingMode::ActiveOrStopped => { "status IN ('active', 'paused', 'blocked', 'usage_limited', 'budget_limited')" } }; let budget_limit_status_filter = match mode { - ThreadGoalAccountingMode::ActiveStatusOnly - | ThreadGoalAccountingMode::ActiveOnly - | ThreadGoalAccountingMode::ActiveOrComplete => "status = 'active'", - ThreadGoalAccountingMode::ActiveOrStopped => { + GoalAccountingMode::ActiveStatusOnly + | GoalAccountingMode::ActiveOnly + | GoalAccountingMode::ActiveOrComplete => "status = 'active'", + GoalAccountingMode::ActiveOrStopped => { "status IN ('active', 'paused', 'blocked', 'usage_limited', 'budget_limited')" } }; @@ -802,7 +802,7 @@ mod tests { thread_id, /*time_delta_seconds*/ 5, /*token_delta*/ 5, - ThreadGoalAccountingMode::ActiveOnly, + GoalAccountingMode::ActiveOnly, Some(original.goal_id.as_str()), ) .await @@ -840,7 +840,7 @@ mod tests { thread_id, /*time_delta_seconds*/ 12, /*token_delta*/ 30, - ThreadGoalAccountingMode::ActiveOnly, + GoalAccountingMode::ActiveOnly, /*expected_goal_id*/ None, ) .await @@ -1062,7 +1062,7 @@ mod tests { thread_id, /*time_delta_seconds*/ 7, /*token_delta*/ 5, - ThreadGoalAccountingMode::ActiveOnly, + GoalAccountingMode::ActiveOnly, /*expected_goal_id*/ None, ) .await @@ -1080,7 +1080,7 @@ mod tests { thread_id, /*time_delta_seconds*/ 3, /*token_delta*/ 15, - ThreadGoalAccountingMode::ActiveOnly, + GoalAccountingMode::ActiveOnly, /*expected_goal_id*/ None, ) .await @@ -1098,7 +1098,7 @@ mod tests { thread_id, /*time_delta_seconds*/ 5, /*token_delta*/ 5, - ThreadGoalAccountingMode::ActiveOnly, + GoalAccountingMode::ActiveOnly, /*expected_goal_id*/ None, ) .await @@ -1133,7 +1133,7 @@ mod tests { thread_id, /*time_delta_seconds*/ 5, /*token_delta*/ 5, - ThreadGoalAccountingMode::ActiveStatusOnly, + GoalAccountingMode::ActiveStatusOnly, /*expected_goal_id*/ None, ) .await @@ -1181,7 +1181,7 @@ mod tests { thread_id, /*time_delta_seconds*/ 3, /*token_delta*/ 25, - ThreadGoalAccountingMode::ActiveOrStopped, + GoalAccountingMode::ActiveOrStopped, /*expected_goal_id*/ None, ) .await @@ -1215,7 +1215,7 @@ mod tests { thread_id, /*time_delta_seconds*/ 1, /*token_delta*/ 50, - ThreadGoalAccountingMode::ActiveOnly, + GoalAccountingMode::ActiveOnly, /*expected_goal_id*/ None, ) .await @@ -1262,7 +1262,7 @@ mod tests { thread_id, /*time_delta_seconds*/ 1, /*token_delta*/ 50, - ThreadGoalAccountingMode::ActiveOnly, + GoalAccountingMode::ActiveOnly, /*expected_goal_id*/ None, ) .await @@ -1313,7 +1313,7 @@ mod tests { thread_id, /*time_delta_seconds*/ 1, /*token_delta*/ 50, - ThreadGoalAccountingMode::ActiveOnly, + GoalAccountingMode::ActiveOnly, /*expected_goal_id*/ None, ) .await @@ -1360,7 +1360,7 @@ mod tests { thread_id, /*time_delta_seconds*/ 1, /*token_delta*/ 50, - ThreadGoalAccountingMode::ActiveOnly, + GoalAccountingMode::ActiveOnly, /*expected_goal_id*/ None, ) .await @@ -1413,7 +1413,7 @@ mod tests { thread_id, /*time_delta_seconds*/ 30, /*token_delta*/ 200, - ThreadGoalAccountingMode::ActiveOnly, + GoalAccountingMode::ActiveOnly, /*expected_goal_id*/ None, ) .await @@ -1431,7 +1431,7 @@ mod tests { thread_id, /*time_delta_seconds*/ 30, /*token_delta*/ 200, - ThreadGoalAccountingMode::ActiveOrComplete, + GoalAccountingMode::ActiveOrComplete, /*expected_goal_id*/ None, ) .await @@ -1480,7 +1480,7 @@ mod tests { thread_id, /*time_delta_seconds*/ 30, /*token_delta*/ 200, - ThreadGoalAccountingMode::ActiveOnly, + GoalAccountingMode::ActiveOnly, /*expected_goal_id*/ None, ) .await @@ -1498,7 +1498,7 @@ mod tests { thread_id, /*time_delta_seconds*/ 30, /*token_delta*/ 200, - ThreadGoalAccountingMode::ActiveOrStopped, + GoalAccountingMode::ActiveOrStopped, /*expected_goal_id*/ None, ) .await @@ -1531,14 +1531,14 @@ mod tests { thread_id, /*time_delta_seconds*/ 4, /*token_delta*/ 40, - ThreadGoalAccountingMode::ActiveOnly, + GoalAccountingMode::ActiveOnly, /*expected_goal_id*/ None, ); let second = runtime.thread_goals().account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 6, /*token_delta*/ 60, - ThreadGoalAccountingMode::ActiveOnly, + GoalAccountingMode::ActiveOnly, /*expected_goal_id*/ None, ); let (first, second) = tokio::join!(first, second); From 66d5edf8258d7c07d9b904e5e4a046b8027ae45f Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 20 May 2026 12:07:06 +0200 Subject: [PATCH 29/63] feat: rename 3 (#23669) Just a mechanical renaming --- .../thread_goal_processor.rs | 4 +-- codex-rs/core/src/goals.rs | 4 +-- codex-rs/core/src/session/tests.rs | 2 +- codex-rs/state/src/lib.rs | 2 +- codex-rs/state/src/runtime.rs | 3 +- codex-rs/state/src/runtime/goals.rs | 32 +++++++++---------- 6 files changed, 24 insertions(+), 23 deletions(-) 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 cf1e58345b..c18806ea03 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 @@ -161,7 +161,7 @@ impl ThreadGoalRequestProcessor { .thread_goals() .update_thread_goal( thread_id, - codex_state::ThreadGoalUpdate { + codex_state::GoalUpdate { objective: Some(objective.to_string()), status, token_budget: params.token_budget, @@ -206,7 +206,7 @@ impl ThreadGoalRequestProcessor { .thread_goals() .update_thread_goal( thread_id, - codex_state::ThreadGoalUpdate { + codex_state::GoalUpdate { objective: None, status, token_budget: params.token_budget, diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index 794b14ea79..ac7d4cfb46 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -476,7 +476,7 @@ impl Session { .thread_goals() .update_thread_goal( self.conversation_id, - codex_state::ThreadGoalUpdate { + codex_state::GoalUpdate { objective: Some(objective.to_string()), status: status.map(state_goal_status_from_protocol), token_budget, @@ -516,7 +516,7 @@ impl Session { .thread_goals() .update_thread_goal( self.conversation_id, - codex_state::ThreadGoalUpdate { + codex_state::GoalUpdate { objective: None, status, token_budget, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index bfe3cfb3fe..175f022289 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -8620,7 +8620,7 @@ async fn external_goal_mutation_accounts_active_turn_before_status_change() -> a .thread_goals() .update_thread_goal( sess.conversation_id, - codex_state::ThreadGoalUpdate { + codex_state::GoalUpdate { objective: None, status: Some(codex_state::ThreadGoalStatus::Complete), token_budget: None, diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index f835000fa4..e18b9f8970 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -50,11 +50,11 @@ pub use model::ThreadMetadataBuilder; pub use model::ThreadsPage; pub use runtime::GoalAccountingMode; pub use runtime::GoalStore; +pub use runtime::GoalUpdate; pub use runtime::RemoteControlEnrollmentRecord; pub use runtime::RuntimeDbPath; pub use runtime::ThreadFilterOptions; pub use runtime::ThreadGoalAccountingOutcome; -pub use runtime::ThreadGoalUpdate; pub use runtime::goals_db_filename; pub use runtime::goals_db_path; pub use runtime::logs_db_filename; diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 1dc349b231..0387dd3f98 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -68,8 +68,9 @@ mod threads; pub use goals::GoalAccountingMode; pub use goals::GoalStore; +pub use goals::GoalUpdate; +pub use goals::ThreadGoalAccountingMode; pub use goals::ThreadGoalAccountingOutcome; -pub use goals::ThreadGoalUpdate; pub use remote_control::RemoteControlEnrollmentRecord; pub use threads::ThreadFilterOptions; diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs index c9a1d126bb..217feb208f 100644 --- a/codex-rs/state/src/runtime/goals.rs +++ b/codex-rs/state/src/runtime/goals.rs @@ -13,7 +13,7 @@ impl GoalStore { } } -pub struct ThreadGoalUpdate { +pub struct GoalUpdate { pub objective: Option, pub status: Option, pub token_budget: Option>, @@ -170,9 +170,9 @@ RETURNING pub async fn update_thread_goal( &self, thread_id: ThreadId, - update: ThreadGoalUpdate, + update: GoalUpdate, ) -> anyhow::Result> { - let ThreadGoalUpdate { + let GoalUpdate { objective, status, token_budget, @@ -556,7 +556,7 @@ mod tests { .thread_goals() .update_thread_goal( thread_id, - ThreadGoalUpdate { + GoalUpdate { objective: None, status: Some(crate::ThreadGoalStatus::Paused), token_budget: Some(Some(200_000)), @@ -732,7 +732,7 @@ mod tests { .thread_goals() .update_thread_goal( thread_id, - ThreadGoalUpdate { + GoalUpdate { objective: None, status: Some(crate::ThreadGoalStatus::Complete), token_budget: None, @@ -756,7 +756,7 @@ mod tests { .thread_goals() .update_thread_goal( thread_id, - ThreadGoalUpdate { + GoalUpdate { objective: None, status: Some(crate::ThreadGoalStatus::Complete), token_budget: None, @@ -853,7 +853,7 @@ mod tests { .thread_goals() .update_thread_goal( thread_id, - ThreadGoalUpdate { + GoalUpdate { objective: Some("draft the report clearly".to_string()), status: Some(crate::ThreadGoalStatus::Paused), token_budget: Some(Some(200)), @@ -891,7 +891,7 @@ mod tests { let status_update = runtime.thread_goals().update_thread_goal( thread_id, - ThreadGoalUpdate { + GoalUpdate { objective: None, status: Some(crate::ThreadGoalStatus::Paused), token_budget: None, @@ -900,7 +900,7 @@ mod tests { ); let budget_update = runtime.thread_goals().update_thread_goal( thread_id, - ThreadGoalUpdate { + GoalUpdate { objective: None, status: None, token_budget: Some(Some(200_000)), @@ -954,7 +954,7 @@ mod tests { .thread_goals() .update_thread_goal( thread_id, - ThreadGoalUpdate { + GoalUpdate { objective: None, status: Some(crate::ThreadGoalStatus::Complete), token_budget: None, @@ -1165,7 +1165,7 @@ mod tests { .thread_goals() .update_thread_goal( thread_id, - crate::ThreadGoalUpdate { + crate::GoalUpdate { objective: None, status: Some(crate::ThreadGoalStatus::Paused), token_budget: None, @@ -1225,7 +1225,7 @@ mod tests { .thread_goals() .update_thread_goal( thread_id, - ThreadGoalUpdate { + GoalUpdate { objective: None, status: None, token_budget: Some(Some(40)), @@ -1272,7 +1272,7 @@ mod tests { .thread_goals() .update_thread_goal( thread_id, - ThreadGoalUpdate { + GoalUpdate { objective: Some("stay within budget, with clearer wording".to_string()), status: Some(crate::ThreadGoalStatus::Active), token_budget: None, @@ -1323,7 +1323,7 @@ mod tests { .thread_goals() .update_thread_goal( thread_id, - ThreadGoalUpdate { + GoalUpdate { objective: None, status: Some(crate::ThreadGoalStatus::Paused), token_budget: None, @@ -1373,7 +1373,7 @@ mod tests { .thread_goals() .update_thread_goal( thread_id, - ThreadGoalUpdate { + GoalUpdate { objective: None, status: Some(crate::ThreadGoalStatus::Blocked), token_budget: None, @@ -1463,7 +1463,7 @@ mod tests { .thread_goals() .update_thread_goal( thread_id, - ThreadGoalUpdate { + GoalUpdate { objective: None, status: Some(crate::ThreadGoalStatus::Paused), token_budget: None, From 9483b09ea4b6f2c36c50235d83686e324ab9c895 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 20 May 2026 12:11:44 +0200 Subject: [PATCH 30/63] feat: rename 2 (#23668) Just a mechanical renaming --- codex-rs/core/src/goals.rs | 8 +++---- codex-rs/state/src/lib.rs | 2 +- codex-rs/state/src/runtime.rs | 2 +- codex-rs/state/src/runtime/goals.rs | 34 ++++++++++++++--------------- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index ac7d4cfb46..e58e82c36d 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -1039,7 +1039,7 @@ impl Session { .is_some_and(|goal_id| reported_goal_id.as_deref() == Some(goal_id)) }; let goal = match outcome { - codex_state::ThreadGoalAccountingOutcome::Updated(goal) => { + codex_state::GoalAccountingOutcome::Updated(goal) => { let clear_active_goal = match goal.status { codex_state::ThreadGoalStatus::Active => false, codex_state::ThreadGoalStatus::BudgetLimited => { @@ -1072,7 +1072,7 @@ impl Session { } goal } - codex_state::ThreadGoalAccountingOutcome::Unchanged(_) => return Ok(()), + codex_state::GoalAccountingOutcome::Unchanged(_) => return Ok(()), }; let should_steer_budget_limit = matches!(budget_limit_steering, BudgetLimitSteering::Allowed) @@ -1158,7 +1158,7 @@ impl Session { ) .await? { - codex_state::ThreadGoalAccountingOutcome::Updated(goal) => { + codex_state::GoalAccountingOutcome::Updated(goal) => { if matches!(terminal_metric_emission, TerminalMetricEmission::Emit) { self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); } @@ -1171,7 +1171,7 @@ impl Session { let goal = protocol_goal_from_state(goal); Ok(Some(goal)) } - codex_state::ThreadGoalAccountingOutcome::Unchanged(goal) => { + codex_state::GoalAccountingOutcome::Unchanged(goal) => { { let mut accounting = self.goal_runtime.accounting.lock().await; accounting.wall_clock.reset_baseline(); diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index e18b9f8970..f35b3420f6 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -49,12 +49,12 @@ pub use model::ThreadMetadata; pub use model::ThreadMetadataBuilder; pub use model::ThreadsPage; pub use runtime::GoalAccountingMode; +pub use runtime::GoalAccountingOutcome; pub use runtime::GoalStore; pub use runtime::GoalUpdate; pub use runtime::RemoteControlEnrollmentRecord; pub use runtime::RuntimeDbPath; pub use runtime::ThreadFilterOptions; -pub use runtime::ThreadGoalAccountingOutcome; pub use runtime::goals_db_filename; pub use runtime::goals_db_path; pub use runtime::logs_db_filename; diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 0387dd3f98..7041af9146 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -67,10 +67,10 @@ mod test_support; mod threads; pub use goals::GoalAccountingMode; +pub use goals::GoalAccountingOutcome; pub use goals::GoalStore; pub use goals::GoalUpdate; pub use goals::ThreadGoalAccountingMode; -pub use goals::ThreadGoalAccountingOutcome; pub use remote_control::RemoteControlEnrollmentRecord; pub use threads::ThreadFilterOptions; diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs index 217feb208f..38a64d0845 100644 --- a/codex-rs/state/src/runtime/goals.rs +++ b/codex-rs/state/src/runtime/goals.rs @@ -20,7 +20,7 @@ pub struct GoalUpdate { pub expected_goal_id: Option, } -pub enum ThreadGoalAccountingOutcome { +pub enum GoalAccountingOutcome { Unchanged(Option), Updated(crate::ThreadGoal), } @@ -389,11 +389,11 @@ WHERE thread_id = ? token_delta: i64, mode: GoalAccountingMode, expected_goal_id: Option<&str>, - ) -> anyhow::Result { + ) -> anyhow::Result { let time_delta_seconds = time_delta_seconds.max(0); let token_delta = token_delta.max(0); if time_delta_seconds == 0 && token_delta == 0 { - return Ok(ThreadGoalAccountingOutcome::Unchanged( + return Ok(GoalAccountingOutcome::Unchanged( self.get_thread_goal(thread_id).await?, )); } @@ -464,13 +464,13 @@ RETURNING let row = query.fetch_optional(self.pool.as_ref()).await?; let Some(row) = row else { - return Ok(ThreadGoalAccountingOutcome::Unchanged( + return Ok(GoalAccountingOutcome::Unchanged( self.get_thread_goal(thread_id).await?, )); }; let updated = thread_goal_from_row(&row)?; - Ok(ThreadGoalAccountingOutcome::Updated(updated)) + Ok(GoalAccountingOutcome::Updated(updated)) } } @@ -808,7 +808,7 @@ mod tests { .await .expect("usage accounting should succeed"); - let ThreadGoalAccountingOutcome::Unchanged(Some(goal)) = outcome else { + let GoalAccountingOutcome::Unchanged(Some(goal)) = outcome else { panic!("stale goal version should not be updated"); }; assert_ne!(replacement.goal_id, original.goal_id); @@ -845,7 +845,7 @@ mod tests { ) .await .expect("usage accounting should succeed"); - let ThreadGoalAccountingOutcome::Updated(accounted) = outcome else { + let GoalAccountingOutcome::Updated(accounted) = outcome else { panic!("active goal should account usage"); }; @@ -1067,7 +1067,7 @@ mod tests { ) .await .expect("usage accounting should succeed"); - let ThreadGoalAccountingOutcome::Updated(goal) = outcome else { + let GoalAccountingOutcome::Updated(goal) = outcome else { panic!("active goal should be updated"); }; assert_eq!(crate::ThreadGoalStatus::Active, goal.status); @@ -1085,7 +1085,7 @@ mod tests { ) .await .expect("usage accounting should succeed"); - let ThreadGoalAccountingOutcome::Updated(goal) = outcome else { + let GoalAccountingOutcome::Updated(goal) = outcome else { panic!("budget crossing should update the goal"); }; assert_eq!(crate::ThreadGoalStatus::BudgetLimited, goal.status); @@ -1103,7 +1103,7 @@ mod tests { ) .await .expect("usage accounting should succeed"); - let ThreadGoalAccountingOutcome::Updated(goal) = outcome else { + let GoalAccountingOutcome::Updated(goal) = outcome else { panic!("budget-limited goal should still account in-flight active usage"); }; assert_eq!(crate::ThreadGoalStatus::BudgetLimited, goal.status); @@ -1138,7 +1138,7 @@ mod tests { ) .await .expect("usage accounting should succeed"); - let ThreadGoalAccountingOutcome::Unchanged(Some(goal)) = outcome else { + let GoalAccountingOutcome::Unchanged(Some(goal)) = outcome else { panic!("budget-limited goal should not be updated"); }; assert_eq!(crate::ThreadGoalStatus::BudgetLimited, goal.status); @@ -1186,7 +1186,7 @@ mod tests { ) .await .expect("usage accounting should succeed"); - let ThreadGoalAccountingOutcome::Updated(goal) = outcome else { + let GoalAccountingOutcome::Updated(goal) = outcome else { panic!("stopped goal should account final usage"); }; assert_eq!(crate::ThreadGoalStatus::BudgetLimited, goal.status); @@ -1365,7 +1365,7 @@ mod tests { ) .await .expect("usage accounting should succeed"); - let ThreadGoalAccountingOutcome::Updated(budget_limited) = outcome else { + let GoalAccountingOutcome::Updated(budget_limited) = outcome else { panic!("budget crossing should update the goal"); }; @@ -1418,7 +1418,7 @@ mod tests { ) .await .expect("usage accounting should succeed"); - let ThreadGoalAccountingOutcome::Unchanged(Some(goal)) = active_only else { + let GoalAccountingOutcome::Unchanged(Some(goal)) = active_only else { panic!("completed goal should not be updated by active-only accounting"); }; assert_eq!(crate::ThreadGoalStatus::Complete, goal.status); @@ -1436,7 +1436,7 @@ mod tests { ) .await .expect("usage accounting should succeed"); - let ThreadGoalAccountingOutcome::Updated(goal) = completing_turn else { + let GoalAccountingOutcome::Updated(goal) = completing_turn else { panic!("completed goal should be updated for final accounting"); }; assert_eq!(crate::ThreadGoalStatus::Complete, goal.status); @@ -1485,7 +1485,7 @@ mod tests { ) .await .expect("usage accounting should succeed"); - let ThreadGoalAccountingOutcome::Unchanged(Some(goal)) = active_only else { + let GoalAccountingOutcome::Unchanged(Some(goal)) = active_only else { panic!("paused goal should not be updated by active-only accounting"); }; assert_eq!(crate::ThreadGoalStatus::Paused, goal.status); @@ -1503,7 +1503,7 @@ mod tests { ) .await .expect("usage accounting should succeed"); - let ThreadGoalAccountingOutcome::Updated(goal) = in_flight_turn else { + let GoalAccountingOutcome::Updated(goal) = in_flight_turn else { panic!("stopped goal should be updated for in-flight accounting"); }; assert_eq!(crate::ThreadGoalStatus::Paused, goal.status); From 51d661643103d17f61ccb4e92dc88ea7fe957f7b Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 20 May 2026 12:27:39 +0200 Subject: [PATCH 31/63] fix: main (#23675) Fix main due to conflicting merges This is only fixing some imports and mechanics --- codex-rs/state/src/runtime.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 7041af9146..8c9c6d0267 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -70,7 +70,6 @@ pub use goals::GoalAccountingMode; pub use goals::GoalAccountingOutcome; pub use goals::GoalStore; pub use goals::GoalUpdate; -pub use goals::ThreadGoalAccountingMode; pub use remote_control::RemoteControlEnrollmentRecord; pub use threads::ThreadFilterOptions; From b555dd5d1d6d7c3ca7e5b00f8e305239b5a651b5 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 20 May 2026 14:44:17 +0200 Subject: [PATCH 32/63] feat: wire goal extension tools to the dedicated goal store (#23685) ## Why `ext/goal` already had the tool specs and contributor wiring for `/goal`, but the installed tools still depended on a placeholder backend that always errored. That meant the extension could not actually own goal persistence even though the dedicated `thread_goals` store already exists. This change wires the extension tools directly to the dedicated goal store so the extension can create, read, and complete goals against real state instead of falling back to host-side placeholders. ## What changed - make `install_with_backend(...)` require `Arc` so goal storage is always available when the extension is installed - remove the unused no-backend/public backend abstraction from `ext/goal` and have the tool executors talk directly to `StateRuntime` - map `thread_goals` rows into the existing protocol response shape for `get_goal`, `create_goal`, and `update_goal` - preserve current thread-list behavior by filling an empty thread preview from the goal objective when a goal is created through the extension path - add integration coverage for the installed tool surface, including successful goal creation and duplicate-create rejection ## Testing - `cargo test -p codex-goal-extension` --- codex-rs/Cargo.lock | 7 + codex-rs/ext/goal/Cargo.toml | 9 + codex-rs/ext/goal/src/extension.rs | 83 +-------- codex-rs/ext/goal/src/lib.rs | 3 - codex-rs/ext/goal/src/tool.rs | 107 +++++++++-- .../ext/goal/tests/goal_extension_backend.rs | 171 ++++++++++++++++++ 6 files changed, 286 insertions(+), 94 deletions(-) create mode 100644 codex-rs/ext/goal/tests/goal_extension_backend.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 09c5786a9c..6dfc44d844 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2952,12 +2952,19 @@ dependencies = [ name = "codex-goal-extension" version = "0.0.0" dependencies = [ + "anyhow", "async-trait", + "chrono", "codex-extension-api", "codex-protocol", + "codex-state", "codex-tools", + "pretty_assertions", "serde", "serde_json", + "tempfile", + "tokio", + "tracing", ] [[package]] diff --git a/codex-rs/ext/goal/Cargo.toml b/codex-rs/ext/goal/Cargo.toml index 7f8b9bd308..b6590f68e6 100644 --- a/codex-rs/ext/goal/Cargo.toml +++ b/codex-rs/ext/goal/Cargo.toml @@ -17,6 +17,15 @@ workspace = true async-trait = { workspace = true } codex-extension-api = { workspace = true } codex-protocol = { workspace = true } +codex-state = { workspace = true } codex-tools = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +chrono = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index a8d4f5c289..657d285f74 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -5,7 +5,6 @@ use codex_extension_api::ConfigContributor; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionRegistryBuilder; -use codex_extension_api::NoopExtensionEventSink; use codex_extension_api::ThreadLifecycleContributor; use codex_extension_api::ThreadStartInput; use codex_extension_api::TokenUsageContributor; @@ -19,14 +18,12 @@ use codex_extension_api::TurnLifecycleContributor; use codex_extension_api::TurnStartInput; use codex_extension_api::TurnStopInput; use codex_protocol::ThreadId; -use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::TurnAbortReason; use crate::accounting::GoalAccountingState; use crate::events::GoalEventEmitter; use crate::spec::UPDATE_GOAL_TOOL_NAME; -use crate::tool::CreateGoalRequest; use crate::tool::GoalToolExecutor; #[derive(Clone, Debug)] @@ -42,7 +39,7 @@ impl GoalExtensionConfig { #[derive(Clone)] pub struct GoalExtension { - backend: Arc, + state_dbs: Arc, event_emitter: GoalEventEmitter, goals_enabled: Arc bool + Send + Sync>, } @@ -54,70 +51,17 @@ impl std::fmt::Debug for GoalExtension { } impl GoalExtension { - pub fn new( - backend: Arc, - goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static, - ) -> Self { - Self::new_with_event_sink(backend, Arc::new(NoopExtensionEventSink), goals_enabled) - } - - pub fn new_with_event_sink( - backend: Arc, + pub(crate) fn new_with_event_sink( + state_dbs: Arc, event_sink: Arc, goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static, ) -> Self { Self { - backend, + state_dbs, event_emitter: GoalEventEmitter::new(event_sink), goals_enabled: Arc::new(goals_enabled), } } - - pub fn without_backend(goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static) -> Self { - Self::new(Arc::new(NoGoalToolBackend), goals_enabled) - } -} - -#[async_trait] -pub trait GoalToolBackend: Send + Sync { - async fn get_goal(&self, thread_id: ThreadId) -> Result, String>; - - async fn create_goal( - &self, - thread_id: ThreadId, - request: CreateGoalRequest, - ) -> Result; - - async fn complete_goal(&self, thread_id: ThreadId) -> Result; -} - -#[derive(Clone, Copy, Debug, Default)] -pub struct NoGoalToolBackend; - -#[async_trait] -impl GoalToolBackend for NoGoalToolBackend { - async fn get_goal(&self, _thread_id: ThreadId) -> Result, String> { - Err(missing_backend_message()) - } - - async fn create_goal( - &self, - _thread_id: ThreadId, - _request: CreateGoalRequest, - ) -> Result { - Err(missing_backend_message()) - } - - async fn complete_goal(&self, _thread_id: ThreadId) -> Result { - Err(missing_backend_message()) - } -} - -fn missing_backend_message() -> String { - // TODO: replace this fallback with a host-provided goal backend once - // ToolContributor invocations can reach thread-scoped goal persistence and - // the current turn context. - "goal tools are not connected to host goal persistence yet".to_string() } #[async_trait] @@ -270,41 +214,32 @@ where vec![ Arc::new(GoalToolExecutor::get( thread_id, - Arc::clone(&self.backend), + Arc::clone(&self.state_dbs), self.event_emitter.clone(), )), Arc::new(GoalToolExecutor::create( thread_id, - Arc::clone(&self.backend), + Arc::clone(&self.state_dbs), self.event_emitter.clone(), )), Arc::new(GoalToolExecutor::update( thread_id, - Arc::clone(&self.backend), + Arc::clone(&self.state_dbs), self.event_emitter.clone(), )), ] } } -pub fn install( - registry: &mut ExtensionRegistryBuilder, - goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static, -) where - C: Send + Sync + 'static, -{ - install_with_backend(registry, Arc::new(NoGoalToolBackend), goals_enabled); -} - pub fn install_with_backend( registry: &mut ExtensionRegistryBuilder, - backend: Arc, + state_dbs: Arc, goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static, ) where C: Send + Sync + 'static, { let extension = Arc::new(GoalExtension::new_with_event_sink( - backend, + state_dbs, registry.event_sink(), goals_enabled, )); diff --git a/codex-rs/ext/goal/src/lib.rs b/codex-rs/ext/goal/src/lib.rs index 38332aee41..1625aeae14 100644 --- a/codex-rs/ext/goal/src/lib.rs +++ b/codex-rs/ext/goal/src/lib.rs @@ -12,9 +12,6 @@ mod tool; pub use extension::GoalExtension; pub use extension::GoalExtensionConfig; -pub use extension::GoalToolBackend; -pub use extension::NoGoalToolBackend; -pub use extension::install; pub use extension::install_with_backend; pub use spec::CREATE_GOAL_TOOL_NAME; pub use spec::GET_GOAL_TOOL_NAME; diff --git a/codex-rs/ext/goal/src/tool.rs b/codex-rs/ext/goal/src/tool.rs index 8ad4541a3c..96b160f938 100644 --- a/codex-rs/ext/goal/src/tool.rs +++ b/codex-rs/ext/goal/src/tool.rs @@ -16,7 +16,6 @@ use serde::Deserialize; use serde::Serialize; use crate::events::GoalEventEmitter; -use crate::extension::GoalToolBackend; use crate::spec::CREATE_GOAL_TOOL_NAME; use crate::spec::GET_GOAL_TOOL_NAME; use crate::spec::UPDATE_GOAL_TOOL_NAME; @@ -28,7 +27,7 @@ use crate::spec::create_update_goal_tool; pub(crate) struct GoalToolExecutor { kind: GoalToolKind, thread_id: ThreadId, - backend: Arc, + state_db: Arc, event_emitter: GoalEventEmitter, } @@ -69,39 +68,39 @@ enum CompletionBudgetReport { impl GoalToolExecutor { pub(crate) fn get( thread_id: ThreadId, - backend: Arc, + state_db: Arc, event_emitter: GoalEventEmitter, ) -> Self { Self { kind: GoalToolKind::Get, thread_id, - backend, + state_db, event_emitter, } } pub(crate) fn create( thread_id: ThreadId, - backend: Arc, + state_db: Arc, event_emitter: GoalEventEmitter, ) -> Self { Self { kind: GoalToolKind::Create, thread_id, - backend, + state_db, event_emitter, } } pub(crate) fn update( thread_id: ThreadId, - backend: Arc, + state_db: Arc, event_emitter: GoalEventEmitter, ) -> Self { Self { kind: GoalToolKind::Update, thread_id, - backend, + state_db, event_emitter, } } @@ -141,10 +140,14 @@ impl GoalToolExecutor { ) -> Result, FunctionCallError> { let _ = invocation.function_arguments()?; let goal = self - .backend - .get_goal(self.thread_id) + .state_db + .thread_goals() + .get_thread_goal(self.thread_id) .await - .map_err(FunctionCallError::RespondToModel)?; + .map(|goal| goal.map(protocol_goal_from_state)) + .map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to read goal: {err}")) + })?; goal_response(goal, CompletionBudgetReport::Omit) } @@ -159,10 +162,24 @@ impl GoalToolExecutor { validate_goal_budget(request.token_budget).map_err(FunctionCallError::RespondToModel)?; let goal = self - .backend - .create_goal(self.thread_id, request) + .state_db + .thread_goals() + .insert_thread_goal( + self.thread_id, + request.objective.as_str(), + codex_state::ThreadGoalStatus::Active, + request.token_budget, + ) .await - .map_err(FunctionCallError::RespondToModel)?; + .map_err(|err| FunctionCallError::RespondToModel(format!("failed to create goal: {err}")))? + .ok_or_else(|| { + FunctionCallError::RespondToModel( + "cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete" + .to_string(), + ) + })?; + fill_empty_thread_preview_if_possible(self.state_db.as_ref(), self.thread_id, &goal).await; + let goal = protocol_goal_from_state(goal); self.emit_goal_updated_from_tool_call(&invocation, goal.clone()); goal_response(Some(goal), CompletionBudgetReport::Omit) } @@ -182,10 +199,27 @@ impl GoalToolExecutor { // TODO: update_goal needs a host callback before completion to flush // final active-turn accounting with budget steering suppressed. let goal = self - .backend - .complete_goal(self.thread_id) + .state_db + .thread_goals() + .update_thread_goal( + self.thread_id, + codex_state::GoalUpdate { + objective: None, + status: Some(codex_state::ThreadGoalStatus::Complete), + token_budget: None, + expected_goal_id: None, + }, + ) .await - .map_err(FunctionCallError::RespondToModel)?; + .map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to complete goal: {err}")) + })? + .map(protocol_goal_from_state) + .ok_or_else(|| { + FunctionCallError::RespondToModel( + "cannot update goal because this thread has no goal".to_string(), + ) + })?; self.emit_goal_updated_from_tool_call(&invocation, goal.clone()); goal_response(Some(goal), CompletionBudgetReport::Include) } @@ -249,6 +283,45 @@ impl GoalToolResponse { } } +async fn fill_empty_thread_preview_if_possible( + state_db: &codex_state::StateRuntime, + thread_id: ThreadId, + goal: &codex_state::ThreadGoal, +) { + if let Err(err) = state_db + .set_thread_preview_if_empty(thread_id, goal.objective.as_str()) + .await + { + tracing::warn!( + "failed to set empty thread preview from goal objective for {thread_id}: {err}" + ); + } +} + +fn protocol_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal { + ThreadGoal { + thread_id: goal.thread_id, + objective: goal.objective, + status: protocol_status_from_state(goal.status), + token_budget: goal.token_budget, + tokens_used: goal.tokens_used, + time_used_seconds: goal.time_used_seconds, + created_at: goal.created_at.timestamp(), + updated_at: goal.updated_at.timestamp(), + } +} + +fn protocol_status_from_state(status: codex_state::ThreadGoalStatus) -> ThreadGoalStatus { + match status { + codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active, + codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused, + codex_state::ThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked, + codex_state::ThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited, + codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited, + codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete, + } +} + fn completion_budget_report(goal: &ThreadGoal) -> Option { if goal.token_budget.is_none() && goal.time_used_seconds <= 0 { None diff --git a/codex-rs/ext/goal/tests/goal_extension_backend.rs b/codex-rs/ext/goal/tests/goal_extension_backend.rs new file mode 100644 index 0000000000..a79c69c633 --- /dev/null +++ b/codex-rs/ext/goal/tests/goal_extension_backend.rs @@ -0,0 +1,171 @@ +use std::sync::Arc; + +use codex_extension_api::ExtensionData; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::FunctionCallError; +use codex_extension_api::ThreadStartInput; +use codex_extension_api::ToolCall; +use codex_extension_api::ToolExecutor; +use codex_extension_api::ToolPayload; +use codex_goal_extension::install_with_backend; +use codex_protocol::ThreadId; +use codex_protocol::ToolName; +use codex_protocol::protocol::SessionSource; +use pretty_assertions::assert_eq; +use serde_json::json; +use tempfile::TempDir; + +#[tokio::test] +async fn installed_goal_tools_create_goal_and_fill_empty_preview() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + seed_thread_metadata(runtime.as_ref(), thread_id).await?; + let tools = installed_tools(runtime.clone(), thread_id).await; + + let create_tool = tool_by_name(&tools, "create_goal"); + let invocation = ToolCall { + call_id: "call-create-goal".to_string(), + tool_name: ToolName::plain("create_goal"), + payload: ToolPayload::Function { + arguments: json!({ + "objective": "ship goal extension backend", + "token_budget": 123, + }) + .to_string(), + }, + }; + let output = create_tool.handle(invocation.clone()).await?; + let result = output.code_mode_result(&invocation.payload); + assert_eq!( + result, + json!({ + "goal": { + "threadId": thread_id, + "objective": "ship goal extension backend", + "status": "active", + "tokenBudget": 123, + "tokensUsed": 0, + "timeUsedSeconds": 0, + "createdAt": result["goal"]["createdAt"], + "updatedAt": result["goal"]["updatedAt"], + }, + "remainingTokens": 123, + "completionBudgetReport": serde_json::Value::Null, + }) + ); + + let metadata = runtime + .get_thread(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("seeded thread metadata should exist"))?; + assert_eq!( + metadata.preview.as_deref(), + Some("ship goal extension backend") + ); + Ok(()) +} + +#[tokio::test] +async fn installed_goal_tools_reject_duplicate_goal_creation() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + seed_thread_metadata(runtime.as_ref(), thread_id).await?; + let tools = installed_tools(runtime, thread_id).await; + + let create_tool = tool_by_name(&tools, "create_goal"); + let first = tool_call( + "create_goal", + "call-create-goal-1", + json!({ "objective": "first goal" }), + ); + create_tool.handle(first).await?; + + let second = tool_call( + "create_goal", + "call-create-goal-2", + json!({ "objective": "second goal" }), + ); + let err = match create_tool.handle(second).await { + Ok(_) => panic!("duplicate create should fail"), + Err(err) => err, + }; + + assert_eq!( + err, + FunctionCallError::RespondToModel( + "cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete" + .to_string() + ) + ); + Ok(()) +} + +async fn installed_tools( + runtime: Arc, + thread_id: ThreadId, +) -> Vec>> { + let mut builder = ExtensionRegistryBuilder::<()>::new(); + install_with_backend(&mut builder, runtime, |_| true); + let registry = builder.build(); + let session_store = ExtensionData::new("session-1"); + let thread_store = ExtensionData::new(thread_id.to_string()); + for contributor in registry.thread_lifecycle_contributors() { + contributor + .on_thread_start(ThreadStartInput { + config: &(), + session_store: &session_store, + thread_store: &thread_store, + }) + .await; + } + + registry + .tool_contributors() + .iter() + .flat_map(|contributor| contributor.tools(&session_store, &thread_store)) + .collect() +} + +fn tool_by_name<'a>( + tools: &'a [Arc>], + name: &str, +) -> &'a Arc> { + tools + .iter() + .find(|tool| tool.tool_name().namespace.is_none() && tool.tool_name().name == name) + .unwrap_or_else(|| panic!("missing tool {name}")) +} + +fn tool_call(tool_name: &str, call_id: &str, arguments: serde_json::Value) -> ToolCall { + ToolCall { + call_id: call_id.to_string(), + tool_name: ToolName::plain(tool_name), + payload: ToolPayload::Function { + arguments: arguments.to_string(), + }, + } +} + +async fn test_runtime() -> anyhow::Result> { + let tempdir = TempDir::new()?; + codex_state::StateRuntime::init(tempdir.keep(), "test-provider".to_string()).await +} + +fn test_thread_id() -> anyhow::Result { + ThreadId::from_string("11111111-1111-4111-8111-111111111111").map_err(anyhow::Error::msg) +} + +async fn seed_thread_metadata( + runtime: &codex_state::StateRuntime, + thread_id: ThreadId, +) -> anyhow::Result<()> { + let builder = codex_state::ThreadMetadataBuilder::new( + thread_id, + runtime + .codex_home() + .join(format!("rollout-{thread_id}.jsonl")), + chrono::Utc::now(), + SessionSource::Cli, + ); + runtime.upsert_thread(&builder.build("test-provider")).await +} From f64fce61b32a921968667752a947232e8eb964bf Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 20 May 2026 15:13:54 +0200 Subject: [PATCH 33/63] feat: async approval contrib (#23690) --- .../ext/extension-api/src/contributors.rs | 17 +++++------ codex-rs/ext/extension-api/src/lib.rs | 1 - codex-rs/ext/extension-api/src/registry.rs | 28 ++++++++++++------- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index 8968e08573..cbe8e58244 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -134,18 +134,15 @@ pub trait ToolLifecycleContributor: Send + Sync { } } -/// Future returned by one claimed approval-review contribution. -pub type ApprovalReviewFuture<'a> = - std::pin::Pin + Send + 'a>>; - /// Extension contribution that can claim rendered approval-review prompts. +#[async_trait::async_trait] pub trait ApprovalReviewContributor: Send + Sync { - fn contribute<'a>( - &'a self, - session_store: &'a ExtensionData, - thread_store: &'a ExtensionData, - prompt: &'a str, - ) -> Option>; + async fn contribute( + &self, + session_store: &ExtensionData, + thread_store: &ExtensionData, + prompt: &str, + ) -> Option; } /// Future returned by one ordered turn-item contribution. diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index 373f3735a4..f9af83d38d 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -18,7 +18,6 @@ pub use codex_tools::ToolPayload; pub use codex_tools::ToolSpec; pub use codex_tools::parse_tool_input_schema; pub use contributors::ApprovalReviewContributor; -pub use contributors::ApprovalReviewFuture; pub use contributors::ConfigContributor; pub use contributors::ContextContributor; pub use contributors::PromptFragment; diff --git a/codex-rs/ext/extension-api/src/registry.rs b/codex-rs/ext/extension-api/src/registry.rs index 4577ddc048..a359726257 100644 --- a/codex-rs/ext/extension-api/src/registry.rs +++ b/codex-rs/ext/extension-api/src/registry.rs @@ -1,7 +1,8 @@ use std::sync::Arc; +use codex_protocol::protocol::ReviewDecision; + use crate::ApprovalReviewContributor; -use crate::ApprovalReviewFuture; use crate::ConfigContributor; use crate::ContextContributor; use crate::ExtensionData; @@ -171,15 +172,22 @@ impl ExtensionRegistry { /// Claims the first rendered approval-review prompt accepted by an /// installed contributor. - pub fn approval_review<'a>( - &'a self, - session_store: &'a ExtensionData, - thread_store: &'a ExtensionData, - prompt: &'a str, - ) -> Option> { - self.approval_review_contributors - .iter() - .find_map(|contributor| contributor.contribute(session_store, thread_store, prompt)) + pub async fn approval_review( + &self, + session_store: &ExtensionData, + thread_store: &ExtensionData, + prompt: &str, + ) -> Option { + for contributor in &self.approval_review_contributors { + if let Some(decision) = contributor + .contribute(session_store, thread_store, prompt) + .await + { + return Some(decision); + } + } + + None } /// Returns the registered prompt contributors. From 1392a2a770e57334f7ec859524230a273a425ab9 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 20 May 2026 15:30:01 +0200 Subject: [PATCH 34/63] feat: async turn item process (#23692) Mechanical change --- codex-rs/core/src/session/turn_tests.rs | 28 ++++----- .../core/src/stream_events_utils_tests.rs | 59 +++++++++---------- .../ext/extension-api/src/contributors.rs | 17 +++--- codex-rs/ext/extension-api/src/lib.rs | 1 - 4 files changed, 48 insertions(+), 57 deletions(-) diff --git a/codex-rs/core/src/session/turn_tests.rs b/codex-rs/core/src/session/turn_tests.rs index f1cf86b46c..81a43ff5a9 100644 --- a/codex-rs/core/src/session/turn_tests.rs +++ b/codex-rs/core/src/session/turn_tests.rs @@ -1,6 +1,5 @@ use super::*; use codex_extension_api::ExtensionData; -use codex_extension_api::TurnItemContributionFuture; use codex_extension_api::TurnItemContributor; use codex_protocol::items::AgentMessageContent; use pretty_assertions::assert_eq; @@ -8,21 +7,20 @@ use std::sync::Arc; struct RewriteAgentMessageContributor; +#[async_trait::async_trait] impl TurnItemContributor for RewriteAgentMessageContributor { - fn contribute<'a>( - &'a self, - _thread_store: &'a ExtensionData, - _turn_store: &'a ExtensionData, - item: &'a mut TurnItem, - ) -> TurnItemContributionFuture<'a> { - Box::pin(async move { - if let TurnItem::AgentMessage(agent_message) = item { - agent_message.content = vec![AgentMessageContent::Text { - text: "plan contributed assistant text".to_string(), - }]; - } - Ok(()) - }) + async fn contribute( + &self, + _thread_store: &ExtensionData, + _turn_store: &ExtensionData, + item: &mut TurnItem, + ) -> Result<(), String> { + if let TurnItem::AgentMessage(agent_message) = item { + agent_message.content = vec![AgentMessageContent::Text { + text: "plan contributed assistant text".to_string(), + }]; + } + Ok(()) } } diff --git a/codex-rs/core/src/stream_events_utils_tests.rs b/codex-rs/core/src/stream_events_utils_tests.rs index 28ffbb7a6b..9c4be94576 100644 --- a/codex-rs/core/src/stream_events_utils_tests.rs +++ b/codex-rs/core/src/stream_events_utils_tests.rs @@ -13,7 +13,6 @@ use crate::tools::ToolRouter; use crate::tools::parallel::ToolCallRuntime; use crate::turn_diff_tracker::TurnDiffTracker; use codex_extension_api::ExtensionData; -use codex_extension_api::TurnItemContributionFuture; use codex_extension_api::TurnItemContributor; use codex_protocol::error::CodexErr; use codex_protocol::items::AgentMessageContent; @@ -168,43 +167,41 @@ struct TestTurnItemContributor; #[derive(Debug)] struct TurnItemContributorRan; +#[async_trait::async_trait] impl TurnItemContributor for TestTurnItemContributor { - fn contribute<'a>( - &'a self, - _thread_store: &'a ExtensionData, - turn_store: &'a ExtensionData, - item: &'a mut TurnItem, - ) -> TurnItemContributionFuture<'a> { - Box::pin(async move { - turn_store.insert(TurnItemContributorRan); - if let TurnItem::AgentMessage(agent_message) = item { - agent_message.memory_citation = Some(MemoryCitation { - entries: Vec::new(), - rollout_ids: Vec::new(), - }); - } - Ok(()) - }) + async fn contribute( + &self, + _thread_store: &ExtensionData, + turn_store: &ExtensionData, + item: &mut TurnItem, + ) -> Result<(), String> { + turn_store.insert(TurnItemContributorRan); + if let TurnItem::AgentMessage(agent_message) = item { + agent_message.memory_citation = Some(MemoryCitation { + entries: Vec::new(), + rollout_ids: Vec::new(), + }); + } + Ok(()) } } struct RewriteAgentMessageContributor; +#[async_trait::async_trait] impl TurnItemContributor for RewriteAgentMessageContributor { - fn contribute<'a>( - &'a self, - _thread_store: &'a ExtensionData, - _turn_store: &'a ExtensionData, - item: &'a mut TurnItem, - ) -> TurnItemContributionFuture<'a> { - Box::pin(async move { - if let TurnItem::AgentMessage(agent_message) = item { - agent_message.content = vec![AgentMessageContent::Text { - text: "contributed assistant text".to_string(), - }]; - } - Ok(()) - }) + async fn contribute( + &self, + _thread_store: &ExtensionData, + _turn_store: &ExtensionData, + item: &mut TurnItem, + ) -> Result<(), String> { + if let TurnItem::AgentMessage(agent_message) = item { + agent_message.content = vec![AgentMessageContent::Text { + text: "contributed assistant text".to_string(), + }]; + } + Ok(()) } } diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index cbe8e58244..2113fdef8e 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -145,20 +145,17 @@ pub trait ApprovalReviewContributor: Send + Sync { ) -> Option; } -/// Future returned by one ordered turn-item contribution. -pub type TurnItemContributionFuture<'a> = - std::pin::Pin> + Send + 'a>>; - /// Ordered post-processing contribution for one parsed turn item. /// /// Implementations may mutate the item before it is emitted and may use the /// explicitly exposed thread- and turn-lifetime stores when they need durable /// extension-private state. +#[async_trait::async_trait] pub trait TurnItemContributor: Send + Sync { - fn contribute<'a>( - &'a self, - thread_store: &'a ExtensionData, - turn_store: &'a ExtensionData, - item: &'a mut TurnItem, - ) -> TurnItemContributionFuture<'a>; + async fn contribute( + &self, + thread_store: &ExtensionData, + turn_store: &ExtensionData, + item: &mut TurnItem, + ) -> Result<(), String>; } diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index f9af83d38d..2ec132ef86 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -35,7 +35,6 @@ pub use contributors::ToolLifecycleContributor; pub use contributors::ToolLifecycleFuture; pub use contributors::ToolStartInput; pub use contributors::TurnAbortInput; -pub use contributors::TurnItemContributionFuture; pub use contributors::TurnItemContributor; pub use contributors::TurnLifecycleContributor; pub use contributors::TurnStartInput; From 59507b849126f598ed7c624bbaf75d7ebc5588c2 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 20 May 2026 15:54:29 +0200 Subject: [PATCH 35/63] feat: expose turn-start metadata to extensions (#23688) ## Why The goal extension needs more context when a turn starts than `turn_store` alone provides. In particular, goal accounting needs the stable turn id, the effective collaboration mode, and the cumulative token-usage baseline captured at turn start so it can: - suppress goal accounting for plan-mode turns - compute exact per-turn deltas from cumulative `total_token_usage` snapshots instead of relying on the most recent usage event alone - keep the extension-owned accounting path aligned with the host turn lifecycle ## What - extend `codex_extension_api::TurnStartInput` to expose `turn_id`, `collaboration_mode`, and `token_usage_at_turn_start` - pass the full `TurnContext` plus the captured token-usage baseline through the turn-start lifecycle emission path - initialize goal turn accounting from the turn-start baseline and collaboration mode - switch goal token accounting to compute deltas from cumulative `total_token_usage` snapshots - add coverage for the new turn-start lifecycle fields and for goal-accounting baseline behavior ## Testing - added `turn_start_lifecycle_exposes_turn_metadata_and_token_baseline` in `codex-rs/core/src/session/tests.rs` - added `ext/goal/tests/accounting.rs` coverage for baseline-aware goal accounting and plan-mode suppression --- codex-rs/core/src/session/tests.rs | 102 ++++++++++++++++++ codex-rs/core/src/tasks/lifecycle.rs | 13 ++- codex-rs/core/src/tasks/mod.rs | 4 +- .../src/contributors/turn_lifecycle.rs | 8 ++ codex-rs/ext/goal/BUILD.bazel | 1 + codex-rs/ext/goal/src/accounting.rs | 52 +++++++-- codex-rs/ext/goal/src/extension.rs | 11 +- codex-rs/ext/goal/tests/accounting.rs | 68 ++++++++++++ 8 files changed, 242 insertions(+), 17 deletions(-) create mode 100644 codex-rs/ext/goal/tests/accounting.rs diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 175f022289..5318b72151 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1911,6 +1911,108 @@ async fn record_token_usage_info_notifies_extension_contributors() { assert_eq!(expected, actual); } +#[tokio::test] +async fn turn_start_lifecycle_exposes_turn_metadata_and_token_baseline() { + struct SessionTurnStartMarker; + struct ThreadTurnStartMarker; + + #[derive(Debug, PartialEq, Eq)] + struct RecordedTurnStart { + turn_id: String, + collaboration_mode: CollaborationMode, + token_usage_at_turn_start: TokenUsage, + saw_session_store: bool, + saw_thread_store: bool, + } + + struct TurnStartRecorder { + records: Arc>>, + } + + #[async_trait::async_trait] + impl codex_extension_api::TurnLifecycleContributor for TurnStartRecorder { + async fn on_turn_start(&self, input: codex_extension_api::TurnStartInput<'_>) { + self.records + .lock() + .expect("turn start records lock") + .push(RecordedTurnStart { + turn_id: input.turn_id.to_string(), + collaboration_mode: input.collaboration_mode.clone(), + token_usage_at_turn_start: input.token_usage_at_turn_start.clone(), + saw_session_store: input + .session_store + .get::() + .is_some(), + saw_thread_store: input.thread_store.get::().is_some(), + }); + } + } + + let (mut session, turn_context) = make_session_and_context().await; + let records = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut builder = codex_extension_api::ExtensionRegistryBuilder::::new(); + builder.turn_lifecycle_contributor(Arc::new(TurnStartRecorder { + records: Arc::clone(&records), + })); + session.services.extensions = Arc::new(builder.build()); + session + .services + .session_extension_data + .insert(SessionTurnStartMarker); + session + .services + .thread_extension_data + .insert(ThreadTurnStartMarker); + + let token_usage_at_turn_start = TokenUsage { + input_tokens: 120, + cached_input_tokens: 15, + output_tokens: 40, + reasoning_output_tokens: 9, + total_tokens: 169, + }; + session + .state + .lock() + .await + .set_token_info(Some(TokenUsageInfo { + total_token_usage: token_usage_at_turn_start.clone(), + last_token_usage: TokenUsage::default(), + model_context_window: turn_context.model_context_window(), + })); + + let turn_context = Arc::new(turn_context); + let session = Arc::new(session); + session + .spawn_task( + Arc::clone(&turn_context), + Vec::new(), + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: true, + }, + ) + .await; + + session.abort_all_tasks(TurnAbortReason::Interrupted).await; + + let actual = records + .lock() + .expect("turn start records lock") + .drain(..) + .collect::>(); + assert_eq!( + vec![RecordedTurnStart { + turn_id: turn_context.sub_id.clone(), + collaboration_mode: turn_context.collaboration_mode.clone(), + token_usage_at_turn_start, + saw_session_store: true, + saw_thread_store: true, + }], + actual + ); +} + #[tokio::test] async fn config_change_contributor_observes_effective_config_changes() { struct SessionConfigMarker; diff --git a/codex-rs/core/src/tasks/lifecycle.rs b/codex-rs/core/src/tasks/lifecycle.rs index 94b51647c9..8b934175cf 100644 --- a/codex-rs/core/src/tasks/lifecycle.rs +++ b/codex-rs/core/src/tasks/lifecycle.rs @@ -1,16 +1,25 @@ use codex_extension_api::ExtensionData; +use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TurnAbortReason; use crate::session::session::Session; +use crate::session::turn_context::TurnContext; impl Session { - pub(super) async fn emit_turn_start_lifecycle(&self, turn_store: &ExtensionData) { + pub(super) async fn emit_turn_start_lifecycle( + &self, + turn_context: &TurnContext, + token_usage_at_turn_start: &TokenUsage, + ) { for contributor in self.services.extensions.turn_lifecycle_contributors() { contributor .on_turn_start(codex_extension_api::TurnStartInput { + turn_id: turn_context.sub_id.as_str(), + collaboration_mode: &turn_context.collaboration_mode, + token_usage_at_turn_start, session_store: &self.services.session_extension_data, thread_store: &self.services.thread_extension_data, - turn_store, + turn_store: turn_context.extension_data.as_ref(), }) .await; } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 164e65f07c..80ddfee897 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -357,7 +357,7 @@ impl Session { debug_assert!(turn.tasks.is_empty()); Arc::clone(&turn.turn_state) }; - turn_state.lock().await.token_usage_at_turn_start = token_usage_at_turn_start; + turn_state.lock().await.token_usage_at_turn_start = token_usage_at_turn_start.clone(); let mut pending_items = queued_response_items .into_iter() .map(TurnInput::ResponseInputItem) @@ -366,7 +366,7 @@ impl Session { self.input_queue .extend_pending_input_for_turn_state(turn_state.as_ref(), pending_items) .await; - self.emit_turn_start_lifecycle(turn_context.extension_data.as_ref()) + self.emit_turn_start_lifecycle(turn_context.as_ref(), &token_usage_at_turn_start) .await; let turn_extension_data = Arc::clone(&turn_context.extension_data); diff --git a/codex-rs/ext/extension-api/src/contributors/turn_lifecycle.rs b/codex-rs/ext/extension-api/src/contributors/turn_lifecycle.rs index 0b53183e28..bbd3ae8f39 100644 --- a/codex-rs/ext/extension-api/src/contributors/turn_lifecycle.rs +++ b/codex-rs/ext/extension-api/src/contributors/turn_lifecycle.rs @@ -1,9 +1,17 @@ +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TurnAbortReason; use crate::ExtensionData; /// Input supplied when the host starts a turn. pub struct TurnStartInput<'a> { + /// Stable host-owned turn identifier. + pub turn_id: &'a str, + /// Effective collaboration mode for this turn. + pub collaboration_mode: &'a CollaborationMode, + /// Total token usage snapshot captured when the turn started. + pub token_usage_at_turn_start: &'a TokenUsage, /// Store scoped to the host session runtime. pub session_store: &'a ExtensionData, /// Store scoped to this thread runtime. diff --git a/codex-rs/ext/goal/BUILD.bazel b/codex-rs/ext/goal/BUILD.bazel index 037313da37..05b80c3706 100644 --- a/codex-rs/ext/goal/BUILD.bazel +++ b/codex-rs/ext/goal/BUILD.bazel @@ -3,4 +3,5 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "goal", crate_name = "codex_goal_extension", + integration_compile_data_extra = ["src/accounting.rs"], ) diff --git a/codex-rs/ext/goal/src/accounting.rs b/codex-rs/ext/goal/src/accounting.rs index 712e325849..cc04684169 100644 --- a/codex-rs/ext/goal/src/accounting.rs +++ b/codex-rs/ext/goal/src/accounting.rs @@ -1,3 +1,4 @@ +use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::TokenUsage; use std::collections::HashMap; use std::sync::Mutex; @@ -17,6 +18,8 @@ struct GoalAccountingInner { #[derive(Debug, Default)] struct GoalTurnAccounting { token_delta: i64, + last_accounted_token_usage: TokenUsage, + account_tokens: bool, stopped: bool, } @@ -27,24 +30,42 @@ pub(crate) struct RecordedTokenDelta { } impl GoalAccountingState { - pub(crate) fn start_turn(&self, turn_id: impl Into) { + pub(crate) fn start_turn( + &self, + turn_id: impl Into, + collaboration_mode: ModeKind, + token_usage_at_turn_start: &TokenUsage, + ) { let turn_id = turn_id.into(); - self.inner().turns.entry(turn_id).or_default().stopped = false; + self.inner().turns.insert( + turn_id, + GoalTurnAccounting { + token_delta: 0, + last_accounted_token_usage: token_usage_at_turn_start.clone(), + account_tokens: !matches!(collaboration_mode, ModeKind::Plan), + stopped: false, + }, + ); } pub(crate) fn record_token_usage( &self, turn_id: impl Into, - usage: &TokenUsage, + total_usage: &TokenUsage, ) -> Option { - let delta = goal_token_delta_for_usage(usage); - if delta <= 0 { + let turn_id = turn_id.into(); + let mut inner = self.inner(); + let turn = inner.turns.get_mut(&turn_id)?; + if turn.stopped || !turn.account_tokens { return None; } - let turn_id = turn_id.into(); - let mut inner = self.inner(); - let turn = inner.turns.entry(turn_id).or_default(); + let delta = + token_delta_since_last_accounting(&turn.last_accounted_token_usage, total_usage); + turn.last_accounted_token_usage = total_usage.clone(); + if delta <= 0 { + return None; + } turn.token_delta = turn.token_delta.saturating_add(delta); let turn_delta = turn.token_delta; inner.unflushed_token_delta = inner.unflushed_token_delta.saturating_add(delta); @@ -65,6 +86,21 @@ impl GoalAccountingState { } } +fn token_delta_since_last_accounting(last: &TokenUsage, current: &TokenUsage) -> i64 { + let delta = TokenUsage { + input_tokens: current.input_tokens.saturating_sub(last.input_tokens), + cached_input_tokens: current + .cached_input_tokens + .saturating_sub(last.cached_input_tokens), + output_tokens: current.output_tokens.saturating_sub(last.output_tokens), + reasoning_output_tokens: current + .reasoning_output_tokens + .saturating_sub(last.reasoning_output_tokens), + total_tokens: current.total_tokens.saturating_sub(last.total_tokens), + }; + goal_token_delta_for_usage(&delta) +} + pub(crate) fn goal_token_delta_for_usage(usage: &TokenUsage) -> i64 { usage .input_tokens diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index 657d285f74..6d8eb42f0e 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -108,10 +108,11 @@ where return; } - // TODO: TurnStartInput should expose collaboration mode and token usage - // at turn start. Goals need mode to suppress plan-mode accounting and - // the token baseline to account deltas exactly. - accounting_state(input.thread_store).start_turn(input.turn_store.level_id()); + accounting_state(input.thread_store).start_turn( + input.turn_id, + input.collaboration_mode.mode, + input.token_usage_at_turn_start, + ); } async fn on_turn_stop(&self, input: TurnStopInput<'_>) { @@ -158,7 +159,7 @@ where } let Some(_recorded) = accounting_state(thread_store) - .record_token_usage(turn_store.level_id(), &token_usage.last_token_usage) + .record_token_usage(turn_store.level_id(), &token_usage.total_token_usage) else { return; }; diff --git a/codex-rs/ext/goal/tests/accounting.rs b/codex-rs/ext/goal/tests/accounting.rs new file mode 100644 index 0000000000..99e9c93005 --- /dev/null +++ b/codex-rs/ext/goal/tests/accounting.rs @@ -0,0 +1,68 @@ +#![allow(dead_code)] + +#[path = "../src/accounting.rs"] +mod accounting; + +use accounting::GoalAccountingState; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::TokenUsage; +use pretty_assertions::assert_eq; + +#[test] +fn goal_accounting_uses_turn_start_baseline_for_exact_deltas() { + let state = GoalAccountingState::default(); + state.start_turn( + "turn-1", + ModeKind::Default, + &token_usage( + /*input_tokens*/ 100, /*cached_input_tokens*/ 10, /*output_tokens*/ 30, + /*reasoning_output_tokens*/ 5, /*total_tokens*/ 135, + ), + ); + + let recorded = state + .record_token_usage( + "turn-1", + &token_usage( + /*input_tokens*/ 120, /*cached_input_tokens*/ 14, + /*output_tokens*/ 42, /*reasoning_output_tokens*/ 8, + /*total_tokens*/ 162, + ), + ) + .expect("token delta should be recorded"); + + assert_eq!(28, recorded.turn_delta); + assert_eq!(28, recorded.thread_unflushed_delta); +} + +#[test] +fn goal_accounting_ignores_plan_mode_turns() { + let state = GoalAccountingState::default(); + state.start_turn("turn-1", ModeKind::Plan, &TokenUsage::default()); + + let recorded = state.record_token_usage( + "turn-1", + &token_usage( + /*input_tokens*/ 20, /*cached_input_tokens*/ 5, /*output_tokens*/ 8, + /*reasoning_output_tokens*/ 2, /*total_tokens*/ 30, + ), + ); + + assert_eq!(None, recorded); +} + +fn token_usage( + input_tokens: i64, + cached_input_tokens: i64, + output_tokens: i64, + reasoning_output_tokens: i64, + total_tokens: i64, +) -> TokenUsage { + TokenUsage { + input_tokens, + cached_input_tokens, + output_tokens, + reasoning_output_tokens, + total_tokens, + } +} From a52c91d8b59601d1b01e8ccfffc2ae0d5dde31df Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 20 May 2026 08:09:45 -0700 Subject: [PATCH 36/63] [codex] Hide deferred tools from code mode prompt (#23605) ## Why `code_mode_only_guides_all_tools_search_and_calls_deferred_app_tools` was failing because code-mode prompt generation used the same nested tool spec list for both the model-visible `exec` guide and the runtime `ALL_TOOLS` surface. That allowed deferred MCP/app tools, such as `calendar_timezone_option_99`, to leak into the `exec` description even though they should only be discoverable through `ALL_TOOLS` at runtime. ## What changed Split code-mode nested tool planning into two sets in `core/src/tools/spec_plan.rs`: - runtime nested tool specs still include deferred tools, so `tools[...]` and `ALL_TOOLS` can call them - `exec` prompt docs only render non-deferred tools, so deferred app tools stay out of the model-visible guide ## Validation - `cargo test -p codex-core --test all code_mode_only_guides_all_tools_search_and_calls_deferred_app_tools -- --nocapture` - looped the same focused test 5 additional times with `cargo test -q -p codex-core --test all code_mode_only_guides_all_tools_search_and_calls_deferred_app_tools` --- codex-rs/core/src/tools/spec_plan.rs | 30 ++++++++++++++++++---------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index ab9d02f97c..a47ed9da34 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -367,19 +367,27 @@ fn build_code_mode_executors( return vec![]; } - let code_mode_nested_tool_specs = executors - .iter() - .filter_map(|executor| { - if executor.exposure() == ToolExposure::DirectModelOnly { - return None; - } + let mut code_mode_nested_tool_specs = Vec::new(); + let mut exec_prompt_tool_specs = Vec::new(); + for executor in executors { + let exposure = executor.exposure(); + if exposure == ToolExposure::DirectModelOnly { + continue; + } - executor.spec() - }) - .collect::>(); - let namespace_descriptions = code_mode_namespace_descriptions(&code_mode_nested_tool_specs); + let Some(spec) = executor.spec() else { + continue; + }; + + if exposure != ToolExposure::Deferred { + exec_prompt_tool_specs.push(spec.clone()); + } + code_mode_nested_tool_specs.push(spec); + } + + let namespace_descriptions = code_mode_namespace_descriptions(&exec_prompt_tool_specs); let mut enabled_tools = - collect_code_mode_exec_prompt_tool_definitions(code_mode_nested_tool_specs.iter()); + collect_code_mode_exec_prompt_tool_definitions(exec_prompt_tool_specs.iter()); enabled_tools .sort_by(|left, right| compare_code_mode_tools(left, right, &namespace_descriptions)); From b0b383bea3ba7830e6c9af4bbb6224d5ab1a2fe5 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 20 May 2026 08:24:43 -0700 Subject: [PATCH 37/63] runtime: use install context for bundled bwrap (#23634) ## Summary The Linux sandbox should find bundled `bwrap` through the same package-layout abstraction as the rest of the runtime, instead of maintaining a separate standalone-specific lookup path. This adds an `InstallContext` helper for bundled resources and updates `codex-linux-sandbox` to ask the current install context for `codex-resources/bwrap` before falling back to the old executable-relative probes. The tests cover npm-style, standalone, and canonical package layouts so `bwrap` lookup follows the package structure introduced earlier in the stack. ## Test plan - `cargo test -p codex-install-context` - `cargo test -p codex-linux-sandbox --lib` - `just fix -p codex-install-context -p codex-linux-sandbox` - `just bazel-lock-check` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/23634). * #23638 * #23637 * #23636 * #23635 * __->__ #23634 --- codex-rs/Cargo.lock | 1 + codex-rs/install-context/src/lib.rs | 76 +++++++++++++++++++-- codex-rs/linux-sandbox/Cargo.toml | 1 + codex-rs/linux-sandbox/src/bundled_bwrap.rs | 56 ++++++++++++--- 4 files changed, 121 insertions(+), 13 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6dfc44d844..7ea7dce4d4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3024,6 +3024,7 @@ version = "0.0.0" dependencies = [ "clap", "codex-core", + "codex-install-context", "codex-process-hardening", "codex-protocol", "codex-sandboxing", diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index d1f8d3839b..ec5c5217ca 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -124,7 +124,7 @@ impl InstallContext { && let Some(path_dir) = &package_layout.path_dir { let bundled_rg = path_dir.join(default_rg_command()); - if bundled_rg.exists() { + if bundled_rg.is_file() { return bundled_rg.into_path_buf(); } } @@ -135,13 +135,37 @@ impl InstallContext { } = &self.method { let bundled_rg = resources_dir.join(default_rg_command()); - if bundled_rg.exists() { + if bundled_rg.is_file() { return bundled_rg.into_path_buf(); } } default_rg_command() } + + pub fn bundled_resource(&self, file_name: impl AsRef) -> Option { + if let Some(package_layout) = &self.package_layout + && let Some(resources_dir) = &package_layout.resources_dir + { + let resource = resources_dir.join(file_name.as_ref()); + if resource.is_file() { + return Some(resource); + } + } + + if let InstallMethod::Standalone { + resources_dir: Some(resources_dir), + .. + } = &self.method + { + let resource = resources_dir.join(file_name); + if resource.is_file() { + return Some(resource); + } + } + + None + } } impl CodexPackageLayout { @@ -242,6 +266,8 @@ mod tests { use pretty_assertions::assert_eq; use std::fs; + const TEST_RESOURCE_NAME: &str = "codex-test-helper"; + #[test] fn detects_standalone_install_from_release_layout() -> std::io::Result<()> { let codex_home = tempfile::tempdir()?; @@ -253,6 +279,7 @@ mod tests { let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); fs::write(&exe_path, "")?; fs::write(resources_dir.join(default_rg_command()), "")?; + fs::write(resources_dir.join(TEST_RESOURCE_NAME), "")?; let canonical_release_dir = AbsolutePathBuf::from_absolute_path(release_dir.canonicalize()?)?; let canonical_resources_dir = @@ -270,12 +297,16 @@ mod tests { InstallContext { method: InstallMethod::Standalone { release_dir: canonical_release_dir, - resources_dir: Some(canonical_resources_dir), + resources_dir: Some(canonical_resources_dir.clone()), platform: standalone_platform(), }, package_layout: None, } ); + assert_eq!( + context.bundled_resource(TEST_RESOURCE_NAME), + Some(canonical_resources_dir.join(TEST_RESOURCE_NAME)) + ); Ok(()) } @@ -312,6 +343,7 @@ mod tests { fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); fs::write(&exe_path, "")?; + fs::write(resources_dir.join(TEST_RESOURCE_NAME), "")?; fs::write(path_dir.join(default_rg_command()), "")?; let canonical_package_dir = AbsolutePathBuf::from_absolute_path(package_dir.path().canonicalize()?)?; @@ -322,7 +354,7 @@ mod tests { let package_layout = CodexPackageLayout { package_dir: canonical_package_dir, bin_dir: canonical_bin_dir, - resources_dir: Some(canonical_resources_dir), + resources_dir: Some(canonical_resources_dir.clone()), path_dir: Some(canonical_path_dir.clone()), }; @@ -346,6 +378,10 @@ mod tests { .join(default_rg_command()) .into_path_buf() ); + assert_eq!( + context.bundled_resource(TEST_RESOURCE_NAME), + Some(canonical_resources_dir.join(TEST_RESOURCE_NAME)) + ); Ok(()) } @@ -364,6 +400,7 @@ mod tests { fs::write(package_dir.join(PACKAGE_METADATA_FILENAME), "{}")?; let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); fs::write(&exe_path, "")?; + fs::write(resources_dir.join(TEST_RESOURCE_NAME), "")?; fs::write(path_dir.join(default_rg_command()), "")?; let canonical_package_dir = AbsolutePathBuf::from_absolute_path(package_dir.canonicalize()?)?; @@ -390,7 +427,7 @@ mod tests { package_layout: Some(CodexPackageLayout { package_dir: canonical_package_dir, bin_dir: canonical_bin_dir, - resources_dir: Some(canonical_resources_dir), + resources_dir: Some(canonical_resources_dir.clone()), path_dir: Some(canonical_path_dir.clone()), }), } @@ -401,6 +438,10 @@ mod tests { .join(default_rg_command()) .into_path_buf() ); + assert_eq!( + context.bundled_resource(TEST_RESOURCE_NAME), + Some(canonical_resources_dir.join(TEST_RESOURCE_NAME)) + ); Ok(()) } @@ -455,6 +496,31 @@ mod tests { Ok(()) } + #[test] + fn bundled_file_lookups_ignore_directories() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + let resources_dir = package_dir.path().join(RESOURCES_DIRNAME); + let path_dir = package_dir.path().join(PATH_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(resources_dir.join(TEST_RESOURCE_NAME))?; + fs::create_dir_all(path_dir.join(default_rg_command()))?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*managed_by_npm*/ false, + /*managed_by_bun*/ false, + /*codex_home*/ None, + ); + assert_eq!(context.rg_command(), default_rg_command()); + assert_eq!(context.bundled_resource(TEST_RESOURCE_NAME), None); + Ok(()) + } + #[test] fn npm_and_bun_take_precedence() { let npm_context = InstallContext::from_exe_with_codex_home( diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml index 1ae2e6b5f5..fc7937536c 100644 --- a/codex-rs/linux-sandbox/Cargo.toml +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -18,6 +18,7 @@ workspace = true [target.'cfg(target_os = "linux")'.dependencies] clap = { workspace = true, features = ["derive"] } +codex-install-context = { workspace = true } codex-process-hardening = { workspace = true } codex-protocol = { workspace = true } codex-sandboxing = { workspace = true } diff --git a/codex-rs/linux-sandbox/src/bundled_bwrap.rs b/codex-rs/linux-sandbox/src/bundled_bwrap.rs index 505377907f..972be9dac3 100644 --- a/codex-rs/linux-sandbox/src/bundled_bwrap.rs +++ b/codex-rs/linux-sandbox/src/bundled_bwrap.rs @@ -12,6 +12,7 @@ use std::sync::OnceLock; use crate::bazel_bwrap; use crate::exec_util::argv_to_cstrings; use crate::exec_util::make_files_inheritable; +use codex_install_context::InstallContext; use codex_utils_absolute_path::AbsolutePathBuf; use sha2::Digest as _; use sha2::Sha256; @@ -26,7 +27,9 @@ pub(crate) struct BundledBwrapLauncher { pub(crate) fn launcher() -> Option { let current_exe = std::env::current_exe().ok()?; - find_for_exe(¤t_exe).map(|program| BundledBwrapLauncher { program }) + find_for_install_context(InstallContext::current()) + .or_else(|| find_legacy_for_exe(¤t_exe)) + .map(|program| BundledBwrapLauncher { program }) } impl BundledBwrapLauncher { @@ -66,8 +69,14 @@ impl BundledBwrapLauncher { } } -fn find_for_exe(exe: &Path) -> Option { - candidates_for_exe(exe) +fn find_for_install_context(context: &InstallContext) -> Option { + context + .bundled_resource("bwrap") + .filter(|path| is_executable_file(path)) +} + +fn find_legacy_for_exe(exe: &Path) -> Option { + legacy_candidates_for_exe(exe) .into_iter() .find(|candidate| is_executable_file(candidate)) .map(|path| { @@ -80,7 +89,7 @@ fn find_for_exe(exe: &Path) -> Option { }) } -fn candidates_for_exe(exe: &Path) -> Vec { +fn legacy_candidates_for_exe(exe: &Path) -> Vec { let Some(exe_dir) = exe.parent() else { return Vec::new(); }; @@ -180,13 +189,44 @@ fn bytes_to_hex(bytes: &[u8; 32]) -> String { #[cfg(test)] mod tests { use super::*; + use codex_install_context::CodexPackageLayout; + use codex_install_context::InstallContext; + use codex_install_context::InstallMethod; use pretty_assertions::assert_eq; use std::fs; use tempfile::NamedTempFile; use tempfile::tempdir; #[test] - fn finds_standalone_bundled_bwrap_next_to_exe_resources() { + fn finds_package_layout_bwrap_from_install_context() { + let temp_dir = tempdir().expect("temp dir"); + let package_dir = temp_dir.path(); + let bin_dir = package_dir.join("bin"); + let resources_dir = package_dir.join("codex-resources"); + let expected_bwrap = resources_dir.join("bwrap"); + fs::create_dir_all(&bin_dir).expect("create bin dir"); + write_executable(&expected_bwrap); + + let context = InstallContext { + method: InstallMethod::Other, + package_layout: Some(CodexPackageLayout { + package_dir: AbsolutePathBuf::from_absolute_path(package_dir).expect("absolute"), + bin_dir: AbsolutePathBuf::from_absolute_path(&bin_dir).expect("absolute"), + resources_dir: Some( + AbsolutePathBuf::from_absolute_path(&resources_dir).expect("absolute"), + ), + path_dir: None, + }), + }; + + assert_eq!( + find_for_install_context(&context), + Some(AbsolutePathBuf::from_absolute_path(&expected_bwrap).expect("absolute")) + ); + } + + #[test] + fn finds_legacy_standalone_bundled_bwrap_next_to_exe_resources() { let temp_dir = tempdir().expect("temp dir"); let exe = temp_dir.path().join("codex"); let expected_bwrap = temp_dir.path().join("codex-resources").join("bwrap"); @@ -194,7 +234,7 @@ mod tests { write_executable(&expected_bwrap); assert_eq!( - find_for_exe(&exe), + find_legacy_for_exe(&exe), Some(AbsolutePathBuf::from_absolute_path(&expected_bwrap).expect("absolute")) ); } @@ -209,7 +249,7 @@ mod tests { write_executable(&expected_bwrap); assert_eq!( - find_for_exe(&exe), + find_legacy_for_exe(&exe), Some(AbsolutePathBuf::from_absolute_path(&expected_bwrap).expect("absolute")) ); } @@ -223,7 +263,7 @@ mod tests { write_executable(&expected_bwrap); assert_eq!( - find_for_exe(&exe), + find_legacy_for_exe(&exe), Some(AbsolutePathBuf::from_absolute_path(&expected_bwrap).expect("absolute")) ); } From e9f59e30d9aea0d6f6d7cb17bd1417b4ea222262 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 20 May 2026 08:48:04 -0700 Subject: [PATCH 38/63] release: publish Codex package archive checksums (#23635) ## Summary Standalone installers and other downstream package consumers need a stable checksum source for the canonical package archives. Relying on per-asset metadata makes that harder to consume uniformly, especially when several package archives are produced in the same release. This keeps the `codex-package-*.tar.gz` and `codex-app-server-package-*.tar.gz` assets in the GitHub Release upload set and adds `codex-package_SHA256SUMS` to `dist/` before the release is created. The manifest contains one SHA-256 line per package archive and fails the release job if no package archives are present. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/23635). * #23638 * #23637 * #23636 * __->__ #23635 --- .github/workflows/rust-release.yml | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c55337ecfe..93e23b8fd7 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -1107,15 +1107,33 @@ jobs: # If included in files: dist/**, release upload races on duplicate # asset names and can fail with 404s. find dist -type f -name 'cargo-timing.html' -delete - # Keep package-builder sidecar archives as workflow artifacts only - # until distribution channels are ready to consume them. - find dist -type f \ - \( -name 'codex-package-*' -o -name 'codex-app-server-package-*' \) \ - -delete find dist -type d -empty -delete ls -R dist/ + - name: Add Codex package checksum manifest + run: | + set -euo pipefail + + manifest="dist/codex-package_SHA256SUMS" + tmp_manifest="$(mktemp)" + find dist -type f \ + \( -name 'codex-package-*.tar.gz' -o -name 'codex-app-server-package-*.tar.gz' \) \ + -print | + sort | + while IFS= read -r archive; do + sha256sum "$archive" | + awk -v name="$(basename "$archive")" '{ print $1 " " name }' + done > "$tmp_manifest" + + if [[ ! -s "$tmp_manifest" ]]; then + echo "No Codex package archives found for checksum manifest" + exit 1 + fi + + mv "$tmp_manifest" "$manifest" + cat "$manifest" + - name: Add config schema release asset run: | cp codex-rs/core/config.schema.json dist/config-schema.json From f198ca115b74a7c8deccddd106393ce0c167da4e Mon Sep 17 00:00:00 2001 From: anp-oai Date: Wed, 20 May 2026 08:49:35 -0700 Subject: [PATCH 39/63] feat: Add btw alias for side slash command (#23592) --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 108 +++++++++++++++++ codex-rs/tui/src/bottom_pane/command_popup.rs | 17 ++- ..._chat_composer__tests__slash_popup_bt.snap | 9 ++ ..._chat_composer__tests__slash_popup_si.snap | 9 ++ codex-rs/tui/src/chatwidget/slash_dispatch.rs | 30 +++-- codex-rs/tui/src/chatwidget/tests/side.rs | 113 ++++++++++++++++++ codex-rs/tui/src/slash_command.rs | 9 +- 7 files changed, 279 insertions(+), 16 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_bt.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_si.snap diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 2176e7762e..ef870c6776 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -7699,6 +7699,114 @@ mod tests { } } + #[test] + fn slash_popup_btw_for_bt_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['/', 'b', 't']); + + let mut terminal = Terminal::new(TestBackend::new(60, 5)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .expect("draw composer"); + + insta::assert_snapshot!("slash_popup_bt", terminal.backend()); + } + + #[test] + fn slash_popup_btw_for_bt_logic() { + use super::super::command_popup::CommandItem; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + type_chars_humanlike(&mut composer, &['/', 'b', 't']); + + match &composer.popups.active { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "btw") + } + Some(CommandItem::ServiceTier(command)) => { + panic!("expected btw command, got service tier {command:?}") + } + None => panic!("no selected command for '/bt'"), + }, + _ => panic!("slash popup not active after typing '/bt'"), + } + } + + #[test] + fn slash_popup_side_for_si_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['/', 's', 'i']); + + let mut terminal = Terminal::new(TestBackend::new(60, 5)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .expect("draw composer"); + + insta::assert_snapshot!("slash_popup_si", terminal.backend()); + } + + #[test] + fn slash_popup_side_for_si_logic() { + use super::super::command_popup::CommandItem; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + type_chars_humanlike(&mut composer, &['/', 's', 'i']); + + match &composer.popups.active { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "side") + } + Some(CommandItem::ServiceTier(command)) => { + panic!("expected side command, got service tier {command:?}") + } + None => panic!("no selected command for '/si'"), + }, + _ => panic!("slash popup not active after typing '/si'"), + } + } + #[test] fn service_tier_slash_command_dispatches_from_catalog_name() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 080f320f5e..f6e6b4929c 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -18,8 +18,9 @@ use crate::render::RectExt; use crate::slash_command::SlashCommand; // Hide alias commands in the default popup list so each unique action appears once. -// `quit` is an alias of `exit`, so we skip `quit` here. -const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit]; +// `quit` is an alias of `exit`, and `btw` is an alias of `side`, so we skip +// those aliases here. +const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Btw]; const COMMAND_COLUMN_WIDTH: ColumnWidthConfig = ColumnWidthConfig::new( ColumnWidthMode::AutoAllRows, /*name_column_width*/ None, @@ -418,6 +419,18 @@ mod tests { assert!(items.contains(&CommandItem::Builtin(SlashCommand::Quit))); } + #[test] + fn btw_hidden_in_empty_filter_but_shown_for_prefix() { + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); + popup.on_composer_text_change("/".to_string()); + let items = popup.filtered_items(); + assert!(!items.contains(&CommandItem::Builtin(SlashCommand::Btw))); + + popup.on_composer_text_change("/bt".to_string()); + let items = popup.filtered_items(); + assert!(items.contains(&CommandItem::Builtin(SlashCommand::Btw))); + } + #[test] fn plan_command_hidden_when_collaboration_modes_disabled() { let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_bt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_bt.snap new file mode 100644 index 0000000000..5aee8ff4d8 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_bt.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /bt " +" " +" " +" /btw start a side conversation in an ephemeral fork " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_si.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_si.snap new file mode 100644 index 0000000000..50a89ae1ab --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_si.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /si " +" " +" " +" /side start a side conversation in an ephemeral fork " diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 9d8d49a77c..dcf0486822 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -30,8 +30,6 @@ struct PreparedSlashCommandArgs { } const SIDE_STARTING_CONTEXT_LABEL: &str = "Side starting..."; -const SIDE_REVIEW_UNAVAILABLE_MESSAGE: &str = - "'/side' is unavailable while code review is running."; const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str = "Press Ctrl+C to return to the main thread first."; const GOAL_USAGE: &str = "Usage: /goal "; @@ -114,9 +112,12 @@ impl ChatWidget { }); } - fn request_empty_side_conversation(&mut self) { + fn request_empty_side_conversation(&mut self, cmd: SlashCommand) { let Some(parent_thread_id) = self.thread_id else { - self.add_error_message("'/side' is unavailable before the session starts.".to_string()); + let command = cmd.command(); + self.add_error_message(format!( + "'/{command}' is unavailable before the session starts." + )); return; }; @@ -239,8 +240,8 @@ impl ChatWidget { ); } } - SlashCommand::Side => { - self.request_empty_side_conversation(); + SlashCommand::Side | SlashCommand::Btw => { + self.request_empty_side_conversation(cmd); } SlashCommand::Agent | SlashCommand::MultiAgents => { self.app_event_tx.send(AppEvent::OpenAgentPicker); @@ -745,11 +746,12 @@ impl ChatWidget { self.bottom_pane.drain_pending_submission_state(); } } - SlashCommand::Side if !trimmed.is_empty() => { + SlashCommand::Side | SlashCommand::Btw if !trimmed.is_empty() => { let Some(parent_thread_id) = self.thread_id else { - self.add_error_message( - "'/side' is unavailable before the session starts.".to_string(), - ); + let command = cmd.command(); + self.add_error_message(format!( + "'/{command}' is unavailable before the session starts." + )); return; }; let user_message = self.prepared_inline_user_message( @@ -957,6 +959,7 @@ impl ChatWidget { | SlashCommand::Plan | SlashCommand::Goal | SlashCommand::Side + | SlashCommand::Btw | SlashCommand::Keymap | SlashCommand::Agent | SlashCommand::MultiAgents @@ -1017,11 +1020,14 @@ impl ChatWidget { } fn ensure_side_command_allowed_outside_review(&mut self, cmd: SlashCommand) -> bool { - if cmd != SlashCommand::Side || !self.review.is_review_mode { + if !matches!(cmd, SlashCommand::Side | SlashCommand::Btw) || !self.review.is_review_mode { return true; } - self.add_error_message(SIDE_REVIEW_UNAVAILABLE_MESSAGE.to_string()); + let command = cmd.command(); + self.add_error_message(format!( + "'/{command}' is unavailable while code review is running." + )); self.bottom_pane.drain_pending_submission_state(); false } diff --git a/codex-rs/tui/src/chatwidget/tests/side.rs b/codex-rs/tui/src/chatwidget/tests/side.rs index 906deb3b56..39a0cb9d73 100644 --- a/codex-rs/tui/src/chatwidget/tests/side.rs +++ b/codex-rs/tui/src/chatwidget/tests/side.rs @@ -173,6 +173,59 @@ async fn slash_side_is_rejected_during_review_mode() { ); } +#[tokio::test] +async fn slash_btw_is_rejected_during_review_mode() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.review.is_review_mode = true; + + chat.dispatch_command(SlashCommand::Btw); + + let event = rx + .try_recv() + .expect("expected review-mode btw conversation error"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); + assert!( + rendered.contains("'/btw' is unavailable while code review is running."), + "expected review-mode btw conversation error, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell error, got {other:?}"), + } + assert!(rx.try_recv().is_err(), "expected no follow-up events"); + assert!( + op_rx.try_recv().is_err(), + "expected no side conversation op" + ); +} + +#[tokio::test] +async fn slash_btw_is_rejected_before_the_session_starts() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Btw); + + let event = rx + .try_recv() + .expect("expected pre-session btw conversation error"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); + assert!( + rendered.contains("'/btw' is unavailable before the session starts."), + "expected pre-session btw conversation error, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell error, got {other:?}"), + } + assert!(rx.try_recv().is_err(), "expected no follow-up events"); + assert!( + op_rx.try_recv().is_err(), + "expected no side conversation op" + ); +} + #[tokio::test] async fn submit_user_message_as_plain_user_turn_does_not_run_shell_commands() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -219,6 +272,31 @@ async fn slash_side_without_args_starts_empty_side_conversation() { assert!(chat.input_queue.queued_user_messages.is_empty()); } +#[tokio::test] +async fn slash_btw_without_args_starts_empty_side_conversation() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let parent_thread_id = ThreadId::new(); + chat.thread_id = Some(parent_thread_id); + chat.on_task_started(); + chat.bottom_pane + .set_composer_text("/btw".to_string(), Vec::new(), Vec::new()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::StartSide { + parent_thread_id: emitted_parent_thread_id, + user_message: None, + }) if emitted_parent_thread_id == parent_thread_id + ); + assert!( + op_rx.try_recv().is_err(), + "bare /btw should not submit an op on the parent thread" + ); + assert!(chat.input_queue.queued_user_messages.is_empty()); +} + #[tokio::test] async fn slash_side_requests_forked_side_question_while_task_running() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -268,6 +346,41 @@ async fn slash_side_requests_forked_side_question_while_task_running() { ); } +#[tokio::test] +async fn slash_btw_requests_forked_side_question_while_task_running() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let parent_thread_id = ThreadId::new(); + chat.thread_id = Some(parent_thread_id); + chat.on_task_started(); + chat.bottom_pane.set_composer_text( + "/btw explore the codebase".to_string(), + Vec::new(), + Vec::new(), + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::StartSide { + parent_thread_id: emitted_parent_thread_id, + user_message: Some(user_message), + }) if emitted_parent_thread_id == parent_thread_id + && user_message + == UserMessage { + text: "explore the codebase".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + } + ); + assert!( + op_rx.try_recv().is_err(), + "expected no op on the parent thread" + ); +} + #[tokio::test] async fn side_context_label_preserves_status_line_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 970d1cbd5a..4a43409296 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -38,6 +38,7 @@ pub enum SlashCommand { Goal, Agent, Side, + Btw, Copy, Raw, Diff, @@ -114,7 +115,9 @@ impl SlashCommand { SlashCommand::Plan => "switch to Plan mode", SlashCommand::Goal => "set or view the goal for a long-running task", SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread", - SlashCommand::Side => "start a side conversation in an ephemeral fork", + SlashCommand::Side | SlashCommand::Btw => { + "start a side conversation in an ephemeral fork" + } SlashCommand::Permissions => "choose what Codex is allowed to do", SlashCommand::Keymap => "remap TUI shortcuts", SlashCommand::Vim => "toggle Vim mode for the composer", @@ -154,6 +157,7 @@ impl SlashCommand { | SlashCommand::Raw | SlashCommand::Pets | SlashCommand::Side + | SlashCommand::Btw | SlashCommand::Resume | SlashCommand::SandboxReadRoot ) @@ -217,7 +221,8 @@ impl SlashCommand { | SlashCommand::Ide | SlashCommand::Quit | SlashCommand::Exit - | SlashCommand::Side => true, + | SlashCommand::Side + | SlashCommand::Btw => true, SlashCommand::Rollout => true, SlashCommand::TestApproval => true, SlashCommand::Realtime => true, From d4f842f3b38595ea348d2a9bc7ed386b74e71a1c Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 20 May 2026 18:36:37 +0200 Subject: [PATCH 40/63] feat: account active goal progress in the goal extension (#23696) ## Why The goal extension can create and surface goals, but the live turn-accounting path still stopped short of persisting active-goal progress. That leaves token and wall-clock usage, plus `ThreadGoalUpdated` events, out of sync with the extension boundary once work actually advances or a goal transitions out of active state. ## What changed - Teach `GoalAccountingState` to track the current turn, active goal, token deltas, and wall-clock progress snapshots against the persisted goal id. - Flush active-goal accounting from tool-finish, turn-stop, and turn-abort lifecycle hooks, and emit `ThreadGoalUpdated` events when persisted progress changes. - Route `create_goal` and `update_goal` through the same accounting state so new goals start from the right baseline, final progress is flushed before status changes, and `update_goal` can mark a goal `blocked` as well as `complete`. - Keep budget-limited goals accruing through the end of the turn while clearing local active-goal state once a turn or explicit update is finished. - Expand backend and lifecycle coverage around store ids, baseline reset, tool-finish accounting, budget-limited carry-through, and blocked-goal updates. ## Testing - Added focused backend coverage in `codex-rs/ext/goal/tests/goal_extension_backend.rs` for baseline reset, tool-finish accounting, budget-limited turns, and blocked-goal updates. - Extended `codex-rs/core/src/session/tests.rs` to assert that lifecycle inputs expose the expected session, thread, and turn store ids. --- codex-rs/core/src/session/tests.rs | 71 ++- codex-rs/ext/goal/src/accounting.rs | 260 +++++++++- codex-rs/ext/goal/src/extension.rs | 139 ++++- codex-rs/ext/goal/src/spec.rs | 7 +- codex-rs/ext/goal/src/tool.rs | 128 ++++- .../ext/goal/tests/goal_extension_backend.rs | 484 +++++++++++++++++- 6 files changed, 978 insertions(+), 111 deletions(-) diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 5318b72151..a08243701c 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1918,6 +1918,9 @@ async fn turn_start_lifecycle_exposes_turn_metadata_and_token_baseline() { #[derive(Debug, PartialEq, Eq)] struct RecordedTurnStart { + session_level_id: String, + thread_level_id: String, + turn_level_id: String, turn_id: String, collaboration_mode: CollaborationMode, token_usage_at_turn_start: TokenUsage, @@ -1936,6 +1939,9 @@ async fn turn_start_lifecycle_exposes_turn_metadata_and_token_baseline() { .lock() .expect("turn start records lock") .push(RecordedTurnStart { + session_level_id: input.session_store.level_id().to_string(), + thread_level_id: input.thread_store.level_id().to_string(), + turn_level_id: input.turn_store.level_id().to_string(), turn_id: input.turn_id.to_string(), collaboration_mode: input.collaboration_mode.clone(), token_usage_at_turn_start: input.token_usage_at_turn_start.clone(), @@ -1965,52 +1971,43 @@ async fn turn_start_lifecycle_exposes_turn_metadata_and_token_baseline() { .insert(ThreadTurnStartMarker); let token_usage_at_turn_start = TokenUsage { - input_tokens: 120, - cached_input_tokens: 15, - output_tokens: 40, - reasoning_output_tokens: 9, - total_tokens: 169, + input_tokens: 100, + cached_input_tokens: 40, + output_tokens: 25, + reasoning_output_tokens: 5, + total_tokens: 130, }; - session - .state - .lock() - .await - .set_token_info(Some(TokenUsageInfo { - total_token_usage: token_usage_at_turn_start.clone(), - last_token_usage: TokenUsage::default(), - model_context_window: turn_context.model_context_window(), - })); + set_total_token_usage(&session, token_usage_at_turn_start.clone()).await; - let turn_context = Arc::new(turn_context); - let session = Arc::new(session); - session - .spawn_task( - Arc::clone(&turn_context), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: true, - }, - ) - .await; + let expected = RecordedTurnStart { + session_level_id: session.session_id().to_string(), + thread_level_id: session.conversation_id.to_string(), + turn_level_id: turn_context.sub_id.clone(), + turn_id: turn_context.sub_id.clone(), + collaboration_mode: turn_context.collaboration_mode.clone(), + token_usage_at_turn_start, + saw_session_store: true, + saw_thread_store: true, + }; - session.abort_all_tasks(TurnAbortReason::Interrupted).await; + let sess = Arc::new(session); + sess.spawn_task( + Arc::new(turn_context), + Vec::new(), + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: true, + }, + ) + .await; + sess.abort_all_tasks(TurnAbortReason::Interrupted).await; let actual = records .lock() .expect("turn start records lock") .drain(..) .collect::>(); - assert_eq!( - vec![RecordedTurnStart { - turn_id: turn_context.sub_id.clone(), - collaboration_mode: turn_context.collaboration_mode.clone(), - token_usage_at_turn_start, - saw_session_store: true, - saw_thread_store: true, - }], - actual - ); + assert_eq!(vec![expected], actual); } #[tokio::test] diff --git a/codex-rs/ext/goal/src/accounting.rs b/codex-rs/ext/goal/src/accounting.rs index cc04684169..b4fa14669d 100644 --- a/codex-rs/ext/goal/src/accounting.rs +++ b/codex-rs/ext/goal/src/accounting.rs @@ -1,26 +1,50 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::TokenUsage; +use codex_state::ThreadGoalStatus; use std::collections::HashMap; use std::sync::Mutex; use std::sync::PoisonError; +use std::time::Duration; +use std::time::Instant; #[derive(Debug, Default)] pub(crate) struct GoalAccountingState { inner: Mutex, } -#[derive(Debug, Default)] +#[derive(Debug)] struct GoalAccountingInner { + current_turn_id: Option, turns: HashMap, - unflushed_token_delta: i64, + wall_clock: GoalWallClockAccounting, } -#[derive(Debug, Default)] +#[derive(Debug)] struct GoalTurnAccounting { - token_delta: i64, + current_token_usage: TokenUsage, last_accounted_token_usage: TokenUsage, + active_goal_id: Option, account_tokens: bool, - stopped: bool, +} + +#[derive(Debug)] +struct GoalWallClockAccounting { + last_accounted_at: Instant, + active_goal_id: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct GoalProgressSnapshot { + pub(crate) current_token_usage: TokenUsage, + pub(crate) expected_goal_id: String, + pub(crate) time_delta_seconds: i64, + pub(crate) token_delta: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BudgetLimitedGoalDisposition { + KeepActive, + ClearActive, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -37,17 +61,21 @@ impl GoalAccountingState { token_usage_at_turn_start: &TokenUsage, ) { let turn_id = turn_id.into(); - self.inner().turns.insert( + let mut inner = self.inner(); + inner.current_turn_id = Some(turn_id.clone()); + inner.turns.insert( turn_id, - GoalTurnAccounting { - token_delta: 0, - last_accounted_token_usage: token_usage_at_turn_start.clone(), - account_tokens: !matches!(collaboration_mode, ModeKind::Plan), - stopped: false, - }, + GoalTurnAccounting::new( + token_usage_at_turn_start.clone(), + !matches!(collaboration_mode, ModeKind::Plan), + ), ); } + pub(crate) fn current_turn_id(&self) -> Option { + self.inner().current_turn_id.clone() + } + pub(crate) fn record_token_usage( &self, turn_id: impl Into, @@ -56,28 +84,107 @@ impl GoalAccountingState { let turn_id = turn_id.into(); let mut inner = self.inner(); let turn = inner.turns.get_mut(&turn_id)?; - if turn.stopped || !turn.account_tokens { + turn.current_token_usage = total_usage.clone(); + if !turn.account_tokens { return None; } - let delta = - token_delta_since_last_accounting(&turn.last_accounted_token_usage, total_usage); - turn.last_accounted_token_usage = total_usage.clone(); + let delta = turn.token_delta_since_last_accounting(); if delta <= 0 { return None; } - turn.token_delta = turn.token_delta.saturating_add(delta); - let turn_delta = turn.token_delta; - inner.unflushed_token_delta = inner.unflushed_token_delta.saturating_add(delta); Some(RecordedTokenDelta { - turn_delta, - thread_unflushed_delta: inner.unflushed_token_delta, + turn_delta: delta, + thread_unflushed_delta: inner.thread_unflushed_token_delta(), }) } - pub(crate) fn stop_turn(&self, turn_id: &str) { - if let Some(turn) = self.inner().turns.get_mut(turn_id) { - turn.stopped = true; + pub(crate) fn mark_turn_goal_active(&self, turn_id: &str, goal_id: impl Into) { + let mut inner = self.inner(); + let goal_id = goal_id.into(); + if let Some(turn) = inner.turns.get_mut(turn_id) { + turn.active_goal_id = Some(goal_id.clone()); + if inner.current_turn_id.as_deref() == Some(turn_id) { + inner.wall_clock.mark_active_goal(goal_id); + } + } + } + + pub(crate) fn mark_current_turn_goal_active( + &self, + goal_id: impl Into, + ) -> Option { + let mut inner = self.inner(); + let turn_id = inner.current_turn_id.clone()?; + let goal_id = goal_id.into(); + let turn = inner.turns.get_mut(turn_id.as_str())?; + turn.active_goal_id = Some(goal_id.clone()); + turn.reset_baseline_to_current(); + inner.wall_clock.mark_active_goal(goal_id); + Some(turn_id) + } + + pub(crate) fn clear_current_turn_goal(&self) -> Option { + let mut inner = self.inner(); + let turn_id = inner.current_turn_id.clone()?; + if let Some(turn) = inner.turns.get_mut(turn_id.as_str()) { + turn.active_goal_id = None; + } + inner.wall_clock.clear_active_goal(); + Some(turn_id) + } + + pub(crate) fn progress_snapshot(&self, turn_id: &str) -> Option { + let inner = self.inner(); + let turn = inner.turns.get(turn_id)?; + if !turn.account_tokens { + return None; + } + let expected_goal_id = turn.active_goal_id()?; + let token_delta = turn.token_delta_since_last_accounting(); + let time_delta_seconds = + if inner.wall_clock.active_goal_id.as_deref() == Some(expected_goal_id.as_str()) { + inner.wall_clock.time_delta_since_last_accounting() + } else { + 0 + }; + if time_delta_seconds == 0 && token_delta <= 0 { + return None; + } + Some(GoalProgressSnapshot { + current_token_usage: turn.current_token_usage.clone(), + expected_goal_id, + time_delta_seconds, + token_delta, + }) + } + + pub(crate) fn mark_progress_accounted_for_status( + &self, + turn_id: &str, + snapshot: &GoalProgressSnapshot, + status: ThreadGoalStatus, + budget_limited_goal_disposition: BudgetLimitedGoalDisposition, + ) { + let clear_active_goal = should_clear_active_goal(status, budget_limited_goal_disposition); + let mut inner = self.inner(); + if let Some(turn) = inner.turns.get_mut(turn_id) { + turn.last_accounted_token_usage = snapshot.current_token_usage.clone(); + if clear_active_goal { + turn.active_goal_id = None; + } + } + inner.wall_clock.mark_accounted(snapshot.time_delta_seconds); + if clear_active_goal { + inner.wall_clock.clear_active_goal(); + } + } + + pub(crate) fn finish_turn(&self, turn_id: &str) { + let mut inner = self.inner(); + inner.turns.remove(turn_id); + if inner.current_turn_id.as_deref() == Some(turn_id) { + inner.current_turn_id = None; } } @@ -107,3 +214,108 @@ pub(crate) fn goal_token_delta_for_usage(usage: &TokenUsage) -> i64 { .saturating_sub(usage.cached_input_tokens) .saturating_add(usage.output_tokens.max(0)) } + +impl Default for GoalAccountingInner { + fn default() -> Self { + Self { + current_turn_id: None, + turns: HashMap::new(), + wall_clock: GoalWallClockAccounting::new(), + } + } +} + +impl GoalAccountingInner { + fn thread_unflushed_token_delta(&self) -> i64 { + self.turns + .values() + .filter(|turn| turn.account_tokens) + .fold(0_i64, |total, turn| { + total.saturating_add(turn.token_delta_since_last_accounting().max(0)) + }) + } +} + +impl GoalTurnAccounting { + fn new(current_token_usage: TokenUsage, account_tokens: bool) -> Self { + Self { + last_accounted_token_usage: current_token_usage.clone(), + current_token_usage, + active_goal_id: None, + account_tokens, + } + } + + fn active_goal_id(&self) -> Option { + self.active_goal_id.clone() + } + + fn reset_baseline_to_current(&mut self) { + self.last_accounted_token_usage = self.current_token_usage.clone(); + } + + fn token_delta_since_last_accounting(&self) -> i64 { + token_delta_since_last_accounting( + &self.last_accounted_token_usage, + &self.current_token_usage, + ) + } +} + +impl GoalWallClockAccounting { + fn new() -> Self { + Self { + last_accounted_at: Instant::now(), + active_goal_id: None, + } + } + + fn time_delta_since_last_accounting(&self) -> i64 { + i64::try_from(self.last_accounted_at.elapsed().as_secs()).unwrap_or(i64::MAX) + } + + fn mark_accounted(&mut self, accounted_seconds: i64) { + if accounted_seconds <= 0 { + return; + } + let advance = Duration::from_secs(u64::try_from(accounted_seconds).unwrap_or(u64::MAX)); + self.last_accounted_at = self + .last_accounted_at + .checked_add(advance) + .unwrap_or_else(Instant::now); + } + + fn reset_baseline(&mut self) { + self.last_accounted_at = Instant::now(); + } + + fn mark_active_goal(&mut self, goal_id: impl Into) { + let goal_id = goal_id.into(); + if self.active_goal_id.as_deref() != Some(goal_id.as_str()) { + self.reset_baseline(); + self.active_goal_id = Some(goal_id); + } + } + + fn clear_active_goal(&mut self) { + self.active_goal_id = None; + self.reset_baseline(); + } +} + +fn should_clear_active_goal( + status: ThreadGoalStatus, + budget_limited_goal_disposition: BudgetLimitedGoalDisposition, +) -> bool { + match status { + ThreadGoalStatus::Active => false, + ThreadGoalStatus::BudgetLimited => matches!( + budget_limited_goal_disposition, + BudgetLimitedGoalDisposition::ClearActive + ), + ThreadGoalStatus::Paused + | ThreadGoalStatus::Blocked + | ThreadGoalStatus::UsageLimited + | ThreadGoalStatus::Complete => true, + } +} diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index 6d8eb42f0e..61114bf57e 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -18,13 +18,15 @@ use codex_extension_api::TurnLifecycleContributor; use codex_extension_api::TurnStartInput; use codex_extension_api::TurnStopInput; use codex_protocol::ThreadId; +use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::TokenUsageInfo; -use codex_protocol::protocol::TurnAbortReason; +use crate::accounting::BudgetLimitedGoalDisposition; use crate::accounting::GoalAccountingState; use crate::events::GoalEventEmitter; use crate::spec::UPDATE_GOAL_TOOL_NAME; use crate::tool::GoalToolExecutor; +use crate::tool::protocol_goal_from_state; #[derive(Clone, Debug)] pub struct GoalExtensionConfig { @@ -108,11 +110,39 @@ where return; } - accounting_state(input.thread_store).start_turn( + let accounting = accounting_state(input.thread_store); + accounting.start_turn( input.turn_id, input.collaboration_mode.mode, input.token_usage_at_turn_start, ); + if matches!( + input.collaboration_mode.mode, + codex_protocol::config_types::ModeKind::Plan + ) { + accounting.clear_current_turn_goal(); + return; + } + let Ok(thread_id) = ThreadId::from_string(input.thread_store.level_id()) else { + return; + }; + let Ok(goal) = self + .state_dbs + .thread_goals() + .get_thread_goal(thread_id) + .await + else { + return; + }; + if let Some(goal) = goal + && matches!( + goal.status, + codex_state::ThreadGoalStatus::Active + | codex_state::ThreadGoalStatus::BudgetLimited + ) + { + accounting.mark_turn_goal_active(input.turn_id, goal.goal_id); + } } async fn on_turn_stop(&self, input: TurnStopInput<'_>) { @@ -120,13 +150,16 @@ where return; } - // TODO: this should flush wall-clock and any unflushed token usage to - // persisted goal storage, emit ThreadGoalUpdated, and optionally inject - // budget-limit steering through a host event/input capability. - // TODO: the host also needs an idle/next-turn wake capability so an - // active goal can enqueue continuation context after the turn is fully - // cleared, only when there is no pending user or mailbox work. - accounting_state(input.thread_store).stop_turn(input.turn_store.level_id()); + let turn_id = input.turn_store.level_id(); + self.account_active_goal_progress( + input.thread_store, + turn_id, + &format!("{turn_id}:turn-stop"), + codex_state::GoalAccountingMode::ActiveOnly, + BudgetLimitedGoalDisposition::ClearActive, + ) + .await; + accounting_state(input.thread_store).finish_turn(turn_id); } async fn on_turn_abort(&self, input: TurnAbortInput<'_>) { @@ -134,11 +167,16 @@ where return; } - accounting_state(input.thread_store).stop_turn(input.turn_store.level_id()); - if input.reason == TurnAbortReason::Interrupted { - // TODO: interrupted turns should pause the active goal via persisted - // goal storage and emit ThreadGoalUpdated with turn_id None. - } + let turn_id = input.turn_store.level_id(); + self.account_active_goal_progress( + input.thread_store, + turn_id, + &format!("{turn_id}:turn-abort"), + codex_state::GoalAccountingMode::ActiveOnly, + BudgetLimitedGoalDisposition::ClearActive, + ) + .await; + accounting_state(input.thread_store).finish_turn(turn_id); } } @@ -163,11 +201,6 @@ where else { return; }; - - // TODO: TokenUsageContributor needs a host goal storage capability so - // this recorded delta can be committed to the active persisted goal. - // It also needs an event/input capability to emit ThreadGoalUpdated and - // inject budget-limit steering when accounting changes goal status. } } @@ -177,15 +210,21 @@ where { fn on_tool_finish<'a>(&'a self, input: ToolFinishInput<'a>) -> ToolLifecycleFuture<'a> { Box::pin(async move { - let _should_count_for_goal_progress = goal_enabled(input.thread_store) + let should_count_for_goal_progress = goal_enabled(input.thread_store) && tool_attempt_counts_for_goal_progress(input.outcome) && !(input.tool_name.namespace.is_none() && input.tool_name.name == UPDATE_GOAL_TOOL_NAME); - - // TODO: commit active goal progress through host goal storage and emit - // ThreadGoalUpdated when the persisted goal changes. This replaces - // GoalRuntimeEvent::ToolCompleted once the goal extension owns runtime - // accounting. + if !should_count_for_goal_progress { + return; + } + self.account_active_goal_progress( + input.thread_store, + input.turn_id, + input.call_id, + codex_state::GoalAccountingMode::ActiveOnly, + BudgetLimitedGoalDisposition::KeepActive, + ) + .await; }) } } @@ -216,16 +255,19 @@ where Arc::new(GoalToolExecutor::get( thread_id, Arc::clone(&self.state_dbs), + accounting_state(thread_store), self.event_emitter.clone(), )), Arc::new(GoalToolExecutor::create( thread_id, Arc::clone(&self.state_dbs), + accounting_state(thread_store), self.event_emitter.clone(), )), Arc::new(GoalToolExecutor::update( thread_id, Arc::clone(&self.state_dbs), + accounting_state(thread_store), self.event_emitter.clone(), )), ] @@ -275,3 +317,50 @@ fn tool_attempt_counts_for_goal_progress(outcome: ToolCallOutcome) -> bool { | ToolCallOutcome::Aborted => false, } } + +impl GoalExtension { + async fn account_active_goal_progress( + &self, + thread_store: &ExtensionData, + turn_id: &str, + event_id: &str, + mode: codex_state::GoalAccountingMode, + budget_limited_goal_disposition: BudgetLimitedGoalDisposition, + ) -> Option { + let Ok(thread_id) = ThreadId::from_string(thread_store.level_id()) else { + return None; + }; + let accounting = accounting_state(thread_store); + let snapshot = accounting.progress_snapshot(turn_id)?; + let outcome = self + .state_dbs + .thread_goals() + .account_thread_goal_usage( + thread_id, + snapshot.time_delta_seconds, + snapshot.token_delta, + mode, + Some(snapshot.expected_goal_id.as_str()), + ) + .await + .ok()?; + match outcome { + codex_state::GoalAccountingOutcome::Updated(goal) => { + accounting.mark_progress_accounted_for_status( + turn_id, + &snapshot, + goal.status, + budget_limited_goal_disposition, + ); + let goal = protocol_goal_from_state(goal); + self.event_emitter.thread_goal_updated( + event_id.to_string(), + Some(turn_id.to_string()), + goal.clone(), + ); + Some(goal) + } + codex_state::GoalAccountingOutcome::Unchanged(_) => None, + } + } +} diff --git a/codex-rs/ext/goal/src/spec.rs b/codex-rs/ext/goal/src/spec.rs index e127846ab5..70e89e4fbb 100644 --- a/codex-rs/ext/goal/src/spec.rs +++ b/codex-rs/ext/goal/src/spec.rs @@ -60,9 +60,9 @@ pub fn create_update_goal_tool() -> ToolSpec { let properties = BTreeMap::from([( "status".to_string(), JsonSchema::string_enum( - vec![json!("complete")], + vec![json!("complete"), json!("blocked")], Some( - "Required. Set to complete only when the objective is achieved and no required work remains." + "Required. Set to complete only when the objective is achieved and no required work remains. Set to blocked only when the goal cannot currently proceed without a user decision, missing dependency, or external unblock." .to_string(), ), ), @@ -71,8 +71,9 @@ pub fn create_update_goal_tool() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: UPDATE_GOAL_TOOL_NAME.to_string(), description: r#"Update the existing goal. -Use this tool only to mark the goal achieved. +Use this tool only to mark the goal achieved or blocked. Set status to `complete` only when the objective has actually been achieved and no required work remains. +Set status to `blocked` only when the goal cannot currently proceed until something external changes. Do not mark a goal complete merely because its budget is nearly exhausted or because you are stopping work. You cannot use this tool to pause, resume, or budget-limit a goal; those status changes are controlled by the user or system. When marking a budgeted goal achieved with status `complete`, report the final token usage from the tool result to the user."# diff --git a/codex-rs/ext/goal/src/tool.rs b/codex-rs/ext/goal/src/tool.rs index 96b160f938..fa3b7d5089 100644 --- a/codex-rs/ext/goal/src/tool.rs +++ b/codex-rs/ext/goal/src/tool.rs @@ -15,6 +15,8 @@ use codex_protocol::protocol::validate_thread_goal_objective; use serde::Deserialize; use serde::Serialize; +use crate::accounting::BudgetLimitedGoalDisposition; +use crate::accounting::GoalAccountingState; use crate::events::GoalEventEmitter; use crate::spec::CREATE_GOAL_TOOL_NAME; use crate::spec::GET_GOAL_TOOL_NAME; @@ -28,6 +30,7 @@ pub(crate) struct GoalToolExecutor { kind: GoalToolKind, thread_id: ThreadId, state_db: Arc, + accounting_state: Arc, event_emitter: GoalEventEmitter, } @@ -69,12 +72,14 @@ impl GoalToolExecutor { pub(crate) fn get( thread_id: ThreadId, state_db: Arc, + accounting_state: Arc, event_emitter: GoalEventEmitter, ) -> Self { Self { kind: GoalToolKind::Get, thread_id, state_db, + accounting_state, event_emitter, } } @@ -82,12 +87,14 @@ impl GoalToolExecutor { pub(crate) fn create( thread_id: ThreadId, state_db: Arc, + accounting_state: Arc, event_emitter: GoalEventEmitter, ) -> Self { Self { kind: GoalToolKind::Create, thread_id, state_db, + accounting_state, event_emitter, } } @@ -95,12 +102,14 @@ impl GoalToolExecutor { pub(crate) fn update( thread_id: ThreadId, state_db: Arc, + accounting_state: Arc, event_emitter: GoalEventEmitter, ) -> Self { Self { kind: GoalToolKind::Update, thread_id, state_db, + accounting_state, event_emitter, } } @@ -179,8 +188,11 @@ impl GoalToolExecutor { ) })?; fill_empty_thread_preview_if_possible(self.state_db.as_ref(), self.thread_id, &goal).await; + let turn_id = self + .accounting_state + .mark_current_turn_goal_active(goal.goal_id.clone()); let goal = protocol_goal_from_state(goal); - self.emit_goal_updated_from_tool_call(&invocation, goal.clone()); + self.emit_goal_updated_from_tool_call(&invocation, turn_id, goal.clone()); goal_response(Some(goal), CompletionBudgetReport::Omit) } @@ -189,15 +201,29 @@ impl GoalToolExecutor { invocation: ToolCall, ) -> Result, FunctionCallError> { let args: UpdateGoalArgs = parse_arguments(invocation.function_arguments()?)?; - if args.status != ThreadGoalStatus::Complete { + if !matches!( + args.status, + ThreadGoalStatus::Complete | ThreadGoalStatus::Blocked + ) { return Err(FunctionCallError::RespondToModel( - "update_goal can only mark the existing goal complete; pause, resume, and budget-limited status changes are controlled by the user or system" + "update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system" .to_string(), )); } - // TODO: update_goal needs a host callback before completion to flush - // final active-turn accounting with budget steering suppressed. + self.account_active_goal_progress( + match args.status { + ThreadGoalStatus::Complete => codex_state::GoalAccountingMode::ActiveOrComplete, + ThreadGoalStatus::Blocked => codex_state::GoalAccountingMode::ActiveOrStopped, + ThreadGoalStatus::Active + | ThreadGoalStatus::Paused + | ThreadGoalStatus::UsageLimited + | ThreadGoalStatus::BudgetLimited => unreachable!("status validated above"), + }, + invocation.call_id.as_str(), + BudgetLimitedGoalDisposition::ClearActive, + ) + .await?; let goal = self .state_db .thread_goals() @@ -205,14 +231,14 @@ impl GoalToolExecutor { self.thread_id, codex_state::GoalUpdate { objective: None, - status: Some(codex_state::ThreadGoalStatus::Complete), + status: Some(state_status_from_protocol(args.status)), token_budget: None, expected_goal_id: None, }, ) .await .map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to complete goal: {err}")) + FunctionCallError::RespondToModel(format!("failed to update goal: {err}")) })? .map(protocol_goal_from_state) .ok_or_else(|| { @@ -220,19 +246,72 @@ impl GoalToolExecutor { "cannot update goal because this thread has no goal".to_string(), ) })?; - self.emit_goal_updated_from_tool_call(&invocation, goal.clone()); - goal_response(Some(goal), CompletionBudgetReport::Include) + let turn_id = self.accounting_state.clear_current_turn_goal(); + self.emit_goal_updated_from_tool_call(&invocation, turn_id, goal.clone()); + goal_response( + Some(goal), + if args.status == ThreadGoalStatus::Complete { + CompletionBudgetReport::Include + } else { + CompletionBudgetReport::Omit + }, + ) } - fn emit_goal_updated_from_tool_call(&self, invocation: &ToolCall, goal: ThreadGoal) { - // TODO: ToolCall should expose the current turn submission id so goal - // tool events can set ThreadGoalUpdatedEvent.turn_id exactly as core - // does today. Until then, correlate the event with the tool call id. - self.event_emitter.thread_goal_updated( - invocation.call_id.clone(), - /*turn_id*/ None, - goal, - ); + fn emit_goal_updated_from_tool_call( + &self, + invocation: &ToolCall, + turn_id: Option, + goal: ThreadGoal, + ) { + self.event_emitter + .thread_goal_updated(invocation.call_id.clone(), turn_id, goal); + } + + async fn account_active_goal_progress( + &self, + mode: codex_state::GoalAccountingMode, + event_id: &str, + budget_limited_goal_disposition: BudgetLimitedGoalDisposition, + ) -> Result, FunctionCallError> { + let Some(turn_id) = self.accounting_state.current_turn_id() else { + return Ok(None); + }; + let Some(snapshot) = self.accounting_state.progress_snapshot(turn_id.as_str()) else { + return Ok(None); + }; + let outcome = self + .state_db + .thread_goals() + .account_thread_goal_usage( + self.thread_id, + snapshot.time_delta_seconds, + snapshot.token_delta, + mode, + Some(snapshot.expected_goal_id.as_str()), + ) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to account goal progress: {err}")) + })?; + Ok(match outcome { + codex_state::GoalAccountingOutcome::Updated(goal) => { + self.accounting_state.mark_progress_accounted_for_status( + turn_id.as_str(), + &snapshot, + goal.status, + budget_limited_goal_disposition, + ); + let goal = protocol_goal_from_state(goal); + self.event_emitter.thread_goal_updated( + event_id.to_string(), + Some(turn_id), + goal.clone(), + ); + Some(goal) + } + codex_state::GoalAccountingOutcome::Unchanged(_) => None, + }) } } @@ -298,7 +377,7 @@ async fn fill_empty_thread_preview_if_possible( } } -fn protocol_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal { +pub(crate) fn protocol_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal { ThreadGoal { thread_id: goal.thread_id, objective: goal.objective, @@ -322,6 +401,17 @@ fn protocol_status_from_state(status: codex_state::ThreadGoalStatus) -> ThreadGo } } +fn state_status_from_protocol(status: ThreadGoalStatus) -> codex_state::ThreadGoalStatus { + match status { + ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active, + ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused, + ThreadGoalStatus::Blocked => codex_state::ThreadGoalStatus::Blocked, + ThreadGoalStatus::UsageLimited => codex_state::ThreadGoalStatus::UsageLimited, + ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited, + ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete, + } +} + fn completion_budget_report(goal: &ThreadGoal) -> Option { if goal.token_budget.is_none() && goal.time_used_seconds <= 0 { None diff --git a/codex-rs/ext/goal/tests/goal_extension_backend.rs b/codex-rs/ext/goal/tests/goal_extension_backend.rs index a79c69c633..d04dbe6021 100644 --- a/codex-rs/ext/goal/tests/goal_extension_backend.rs +++ b/codex-rs/ext/goal/tests/goal_extension_backend.rs @@ -1,16 +1,32 @@ use std::sync::Arc; +use std::sync::Mutex; +use std::sync::PoisonError; use codex_extension_api::ExtensionData; +use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::FunctionCallError; use codex_extension_api::ThreadStartInput; use codex_extension_api::ToolCall; +use codex_extension_api::ToolCallOutcome; +use codex_extension_api::ToolCallSource; use codex_extension_api::ToolExecutor; +use codex_extension_api::ToolFinishInput; +use codex_extension_api::ToolName; use codex_extension_api::ToolPayload; +use codex_extension_api::TurnStartInput; +use codex_extension_api::TurnStopInput; use codex_goal_extension::install_with_backend; use codex_protocol::ThreadId; -use codex_protocol::ToolName; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadGoalStatus; +use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TokenUsageInfo; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; @@ -70,7 +86,8 @@ async fn installed_goal_tools_reject_duplicate_goal_creation() -> anyhow::Result let runtime = test_runtime().await?; let thread_id = test_thread_id()?; seed_thread_metadata(runtime.as_ref(), thread_id).await?; - let tools = installed_tools(runtime, thread_id).await; + let harness = GoalExtensionHarness::new(runtime, thread_id).await?; + let tools = harness.tools(); let create_tool = tool_by_name(&tools, "create_goal"); let first = tool_call( @@ -100,6 +117,273 @@ async fn installed_goal_tools_reject_duplicate_goal_creation() -> anyhow::Result Ok(()) } +#[tokio::test] +async fn create_goal_resets_baseline_before_turn_stop_accounting() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + seed_thread_metadata(runtime.as_ref(), thread_id).await?; + let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?; + harness + .start_turn( + "turn-1", + &token_usage( + /*input_tokens*/ 100, /*cached_input_tokens*/ 10, + /*output_tokens*/ 30, /*reasoning_output_tokens*/ 5, + /*total_tokens*/ 135, + ), + ) + .await; + harness + .record_token_usage( + "turn-1", + &token_usage( + /*input_tokens*/ 120, /*cached_input_tokens*/ 14, + /*output_tokens*/ 42, /*reasoning_output_tokens*/ 8, + /*total_tokens*/ 162, + ), + ) + .await; + + let tools = harness.tools(); + let create_tool = tool_by_name(&tools, "create_goal"); + create_tool + .handle(tool_call( + "create_goal", + "call-create-goal", + json!({ "objective": "ship goal extension backend" }), + )) + .await?; + + harness + .record_token_usage( + "turn-1", + &token_usage( + /*input_tokens*/ 127, /*cached_input_tokens*/ 16, + /*output_tokens*/ 52, /*reasoning_output_tokens*/ 10, + /*total_tokens*/ 189, + ), + ) + .await; + harness.stop_turn("turn-1").await; + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(15, goal.tokens_used); + assert_eq!(ThreadGoalStatus::Active, protocol_status(goal.status)); + Ok(()) +} + +#[tokio::test] +async fn tool_finish_accounts_active_goal_progress_and_emits_event() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + seed_thread_metadata(runtime.as_ref(), thread_id).await?; + let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?; + harness.start_turn("turn-1", &TokenUsage::default()).await; + + let tools = harness.tools(); + let create_tool = tool_by_name(&tools, "create_goal"); + create_tool + .handle(tool_call( + "create_goal", + "call-create-goal", + json!({ "objective": "ship goal extension backend" }), + )) + .await?; + harness.sink.clear(); + + harness + .record_token_usage( + "turn-1", + &token_usage( + /*input_tokens*/ 20, /*cached_input_tokens*/ 5, /*output_tokens*/ 8, + /*reasoning_output_tokens*/ 2, /*total_tokens*/ 30, + ), + ) + .await; + harness + .notify_tool_finish("turn-1", "call-shell", "shell") + .await; + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(23, goal.tokens_used); + + assert_eq!( + vec![CapturedGoalEvent { + event_id: "call-shell".to_string(), + turn_id: Some("turn-1".to_string()), + status: ThreadGoalStatus::Active, + tokens_used: 23, + }], + harness.sink.goal_events() + ); + Ok(()) +} + +#[tokio::test] +async fn budget_limited_goal_keeps_accruing_until_turn_stop() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + seed_thread_metadata(runtime.as_ref(), thread_id).await?; + let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?; + harness.start_turn("turn-1", &TokenUsage::default()).await; + + let tools = harness.tools(); + let create_tool = tool_by_name(&tools, "create_goal"); + create_tool + .handle(tool_call( + "create_goal", + "call-create-goal", + json!({ + "objective": "ship goal extension backend", + "token_budget": 25, + }), + )) + .await?; + harness.sink.clear(); + + harness + .record_token_usage( + "turn-1", + &token_usage( + /*input_tokens*/ 20, /*cached_input_tokens*/ 5, + /*output_tokens*/ 10, /*reasoning_output_tokens*/ 0, + /*total_tokens*/ 30, + ), + ) + .await; + harness + .notify_tool_finish("turn-1", "call-shell", "shell") + .await; + harness + .record_token_usage( + "turn-1", + &token_usage( + /*input_tokens*/ 24, /*cached_input_tokens*/ 5, + /*output_tokens*/ 16, /*reasoning_output_tokens*/ 0, + /*total_tokens*/ 40, + ), + ) + .await; + harness.stop_turn("turn-1").await; + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(35, goal.tokens_used); + assert_eq!(codex_state::ThreadGoalStatus::BudgetLimited, goal.status); + + assert_eq!( + vec![ + CapturedGoalEvent { + event_id: "call-shell".to_string(), + turn_id: Some("turn-1".to_string()), + status: ThreadGoalStatus::BudgetLimited, + tokens_used: 25, + }, + CapturedGoalEvent { + event_id: "turn-1:turn-stop".to_string(), + turn_id: Some("turn-1".to_string()), + status: ThreadGoalStatus::BudgetLimited, + tokens_used: 35, + }, + ], + harness.sink.goal_events() + ); + Ok(()) +} + +#[tokio::test] +async fn update_goal_can_block_and_accounts_final_progress() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + seed_thread_metadata(runtime.as_ref(), thread_id).await?; + let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?; + harness.start_turn("turn-1", &TokenUsage::default()).await; + + let tools = harness.tools(); + let create_tool = tool_by_name(&tools, "create_goal"); + create_tool + .handle(tool_call( + "create_goal", + "call-create-goal", + json!({ "objective": "ship goal extension backend" }), + )) + .await?; + harness.sink.clear(); + + harness + .record_token_usage( + "turn-1", + &token_usage( + /*input_tokens*/ 20, /*cached_input_tokens*/ 5, /*output_tokens*/ 8, + /*reasoning_output_tokens*/ 2, /*total_tokens*/ 30, + ), + ) + .await; + let update_tool = tool_by_name(&tools, "update_goal"); + let invocation = tool_call( + "update_goal", + "call-update-goal", + json!({ "status": "blocked" }), + ); + let output = update_tool.handle(invocation.clone()).await?; + let result = output.code_mode_result(&invocation.payload); + + assert_eq!( + result, + json!({ + "goal": { + "threadId": thread_id, + "objective": "ship goal extension backend", + "status": "blocked", + "tokensUsed": 23, + "timeUsedSeconds": 0, + "createdAt": result["goal"]["createdAt"], + "updatedAt": result["goal"]["updatedAt"], + }, + "remainingTokens": serde_json::Value::Null, + "completionBudgetReport": serde_json::Value::Null, + }) + ); + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(23, goal.tokens_used); + assert_eq!(codex_state::ThreadGoalStatus::Blocked, goal.status); + + assert_eq!( + vec![ + CapturedGoalEvent { + event_id: "call-update-goal".to_string(), + turn_id: Some("turn-1".to_string()), + status: ThreadGoalStatus::Active, + tokens_used: 23, + }, + CapturedGoalEvent { + event_id: "call-update-goal".to_string(), + turn_id: Some("turn-1".to_string()), + status: ThreadGoalStatus::Blocked, + tokens_used: 23, + }, + ], + harness.sink.goal_events() + ); + Ok(()) +} + async fn installed_tools( runtime: Arc, thread_id: ThreadId, @@ -126,6 +410,118 @@ async fn installed_tools( .collect() } +struct GoalExtensionHarness { + registry: codex_extension_api::ExtensionRegistry<()>, + session_store: ExtensionData, + thread_store: ExtensionData, + sink: Arc, +} + +impl GoalExtensionHarness { + async fn new( + runtime: Arc, + thread_id: ThreadId, + ) -> anyhow::Result { + let sink = Arc::new(RecordingEventSink::default()); + let mut builder = ExtensionRegistryBuilder::<()>::with_event_sink(sink.clone()); + install_with_backend(&mut builder, runtime, |_| true); + let registry = builder.build(); + let session_store = ExtensionData::new("session-1"); + let thread_store = ExtensionData::new(thread_id.to_string()); + for contributor in registry.thread_lifecycle_contributors() { + contributor + .on_thread_start(ThreadStartInput { + config: &(), + session_store: &session_store, + thread_store: &thread_store, + }) + .await; + } + Ok(Self { + registry, + session_store, + thread_store, + sink, + }) + } + + fn tools(&self) -> Vec>> { + self.registry + .tool_contributors() + .iter() + .flat_map(|contributor| contributor.tools(&self.session_store, &self.thread_store)) + .collect() + } + + async fn start_turn(&self, turn_id: &str, usage: &TokenUsage) { + let turn_store = ExtensionData::new(turn_id); + let collaboration_mode = default_collaboration_mode(); + for contributor in self.registry.turn_lifecycle_contributors() { + contributor + .on_turn_start(TurnStartInput { + turn_id, + collaboration_mode: &collaboration_mode, + token_usage_at_turn_start: usage, + session_store: &self.session_store, + thread_store: &self.thread_store, + turn_store: &turn_store, + }) + .await; + } + } + + async fn stop_turn(&self, turn_id: &str) { + let turn_store = ExtensionData::new(turn_id); + for contributor in self.registry.turn_lifecycle_contributors() { + contributor + .on_turn_stop(TurnStopInput { + session_store: &self.session_store, + thread_store: &self.thread_store, + turn_store: &turn_store, + }) + .await; + } + } + + async fn record_token_usage(&self, turn_id: &str, usage: &TokenUsage) { + let turn_store = ExtensionData::new(turn_id); + let token_usage = TokenUsageInfo { + total_token_usage: usage.clone(), + last_token_usage: TokenUsage::default(), + model_context_window: None, + }; + for contributor in self.registry.token_usage_contributors() { + contributor + .on_token_usage( + &self.session_store, + &self.thread_store, + &turn_store, + &token_usage, + ) + .await; + } + } + + async fn notify_tool_finish(&self, turn_id: &str, call_id: &str, tool_name: &str) { + let turn_store = ExtensionData::new(turn_id); + let tool_name = codex_extension_api::ToolName::plain(tool_name); + for contributor in self.registry.tool_lifecycle_contributors() { + contributor + .on_tool_finish(ToolFinishInput { + session_store: &self.session_store, + thread_store: &self.thread_store, + turn_store: &turn_store, + turn_id, + call_id, + tool_name: &tool_name, + source: ToolCallSource::Direct, + outcome: ToolCallOutcome::Completed { success: true }, + }) + .await; + } + } +} + fn tool_by_name<'a>( tools: &'a [Arc>], name: &str, @@ -139,7 +535,7 @@ fn tool_by_name<'a>( fn tool_call(tool_name: &str, call_id: &str, arguments: serde_json::Value) -> ToolCall { ToolCall { call_id: call_id.to_string(), - tool_name: ToolName::plain(tool_name), + tool_name: codex_extension_api::ToolName::plain(tool_name), payload: ToolPayload::Function { arguments: arguments.to_string(), }, @@ -169,3 +565,85 @@ async fn seed_thread_metadata( ); runtime.upsert_thread(&builder.build("test-provider")).await } + +#[derive(Debug, Default)] +struct RecordingEventSink { + events: Mutex>, +} + +impl RecordingEventSink { + fn goal_events(&self) -> Vec { + self.events() + .iter() + .filter_map(|event| match &event.msg { + EventMsg::ThreadGoalUpdated(updated) => Some(CapturedGoalEvent { + event_id: event.id.clone(), + turn_id: updated.turn_id.clone(), + status: updated.goal.status, + tokens_used: updated.goal.tokens_used, + }), + _ => None, + }) + .collect() + } + + fn clear(&self) { + self.events().clear(); + } + + fn events(&self) -> std::sync::MutexGuard<'_, Vec> { + self.events.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl ExtensionEventSink for RecordingEventSink { + fn emit(&self, event: Event) { + self.events().push(event); + } +} + +#[derive(Debug, PartialEq, Eq)] +struct CapturedGoalEvent { + event_id: String, + turn_id: Option, + status: ThreadGoalStatus, + tokens_used: i64, +} + +fn default_collaboration_mode() -> CollaborationMode { + CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: "gpt-5".to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + } +} + +fn token_usage( + input_tokens: i64, + cached_input_tokens: i64, + output_tokens: i64, + reasoning_output_tokens: i64, + total_tokens: i64, +) -> TokenUsage { + TokenUsage { + input_tokens, + cached_input_tokens, + output_tokens, + reasoning_output_tokens, + total_tokens, + } +} + +fn protocol_status(status: codex_state::ThreadGoalStatus) -> ThreadGoalStatus { + match status { + codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active, + codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused, + codex_state::ThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked, + codex_state::ThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited, + codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited, + codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete, + } +} From c0f7e1b99fce210e1e0dfc97160d79454c04a987 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 20 May 2026 10:00:33 -0700 Subject: [PATCH 41/63] [2 of 2] Start fresh TUI thread in background (#23176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why After the terminal-probe work in #23175, fresh-session startup still waits for `thread/start` before the chat input can become usable. The chat widget already has the machinery to hold early submissions until a session is configured, so fresh `thread/start` does not need to stay on the input-ready hot path. Refs #16335. ## What This PR starts fresh app-server threads in a background task, reports completion through a startup app event, and attaches the primary session once `thread/start` returns. Resume and fork startup paths remain synchronous. ## Benchmark In the local pty startup benchmark, this PR's pre-optimization base branch, #23175, measured about 152ms median from launch to accepted chat input. The stacked result measured about 66ms median, for an approximate additional savings of 85-95ms. For broader context, the original `main` baseline before either startup optimization was about 250.5ms median. We also measured Codex 0.117.0 on the same machine at about 64.6ms median, so the stacked branch is back in the old-startup-time range. ## Stack 1. [#23175: [1 of 2] Optimize TUI startup terminal probes](https://github.com/openai/codex/pull/23175) — base PR 2. [#23176: [2 of 2] Start fresh TUI thread in background](https://github.com/openai/codex/pull/23176) — this PR ## Verification - `cargo test -p codex-tui` --- codex-rs/tui/src/app.rs | 35 +++++- codex-rs/tui/src/app/event_dispatch.rs | 4 + codex-rs/tui/src/app/session_lifecycle.rs | 38 ++++++ codex-rs/tui/src/app/side.rs | 6 +- codex-rs/tui/src/app/test_support.rs | 1 + codex-rs/tui/src/app/tests.rs | 2 + codex-rs/tui/src/app/tests/startup.rs | 118 ++++++++++++++++++ codex-rs/tui/src/app_event.rs | 6 + codex-rs/tui/src/app_server_session.rs | 26 +++- codex-rs/tui/src/bottom_pane/chat_composer.rs | 92 +++++++++++++- codex-rs/tui/src/bottom_pane/mod.rs | 4 + codex-rs/tui/src/chatwidget/input_flow.rs | 5 + codex-rs/tui/src/chatwidget/session_flow.rs | 2 + 13 files changed, 329 insertions(+), 10 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3164f16acb..0288c9c593 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -543,6 +543,7 @@ pub(crate) struct App { primary_session_configured: Option, pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, + pending_startup_thread_start: bool, // Serialize plugin enablement writes per plugin so stale completions cannot // overwrite a newer toggle, even if the plugin is toggled from different // cwd contexts. @@ -575,6 +576,27 @@ async fn resolve_runtime_model_provider_base_url(provider: &ModelProviderInfo) - } } +fn spawn_startup_thread_start( + app_server: &AppServerSession, + config: Config, + app_event_tx: AppEventSender, +) { + let request_handle = app_server.request_handle(); + let thread_params_mode = app_server.thread_params_mode(); + let remote_cwd_override = app_server.remote_cwd_override().map(Path::to_path_buf); + tokio::spawn(async move { + let result = crate::app_server_session::start_thread_with_request_handle( + request_handle, + config, + thread_params_mode, + remote_cwd_override, + ) + .await + .map_err(|err| format!("{err:#}")); + app_event_tx.send(AppEvent::StartupThreadStarted { result }); + }); +} + #[derive(Debug, Clone, PartialEq, Eq)] enum ActiveTurnSteerRace { Missing, @@ -778,10 +800,14 @@ impl App { &initial_images, ); let thread_and_widget_started_at = Instant::now(); + let pending_startup_thread_start = matches!( + &session_selection, + SessionSelection::StartFresh | SessionSelection::Exit + ); let (mut chat_widget, initial_started_thread) = match session_selection { SessionSelection::StartFresh | SessionSelection::Exit => { - let started = app_server.start_thread(&config).await?; - // Only count a startup tooltip once the fresh thread can actually render it. + spawn_startup_thread_start(&app_server, config.clone(), app_event_tx.clone()); + // Count a startup tooltip once the initial chat widget can render it. let startup_tooltip_override = prepare_startup_tooltip_override(&mut config, &available_models, is_first_run) .await; @@ -811,7 +837,9 @@ impl App { .clone(), session_telemetry: session_telemetry.clone(), }; - (ChatWidget::new_with_app_event(init), Some(started)) + let mut chat_widget = ChatWidget::new_with_app_event(init); + chat_widget.set_queue_submissions_until_session_configured(/*queue*/ true); + (chat_widget, None) } SessionSelection::Resume(target_session) => { let resumed = app_server @@ -956,6 +984,7 @@ See the Codex keymap documentation for supported actions and examples." primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + pending_startup_thread_start, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), }; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 68bd759e2f..c1cd816b0d 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -23,6 +23,10 @@ impl App { ) .await; } + AppEvent::StartupThreadStarted { result } => { + self.handle_startup_thread_started(app_server, result) + .await?; + } AppEvent::ClearUi => { self.clear_terminal_ui(tui, /*redraw_header*/ false)?; self.reset_app_ui_state_after_clear(); diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 6ea53d8b3c..6c87894434 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -418,10 +418,48 @@ impl App { self.primary_session_configured = None; self.pending_primary_events.clear(); self.pending_app_server_requests.clear(); + self.pending_startup_thread_start = false; self.chat_widget.set_pending_thread_approvals(Vec::new()); self.sync_active_agent_label(); } + pub(super) async fn handle_startup_thread_started( + &mut self, + app_server: &mut AppServerSession, + result: Result, + ) -> Result<()> { + if !self.pending_startup_thread_start { + if let Ok(started) = result { + let thread_id = started.session.thread_id; + if let Err(err) = app_server.thread_unsubscribe(thread_id).await { + tracing::warn!( + thread_id = %thread_id, + "failed to unsubscribe stale startup thread: {err}" + ); + } + self.discard_thread_local_state(thread_id).await; + } + return Ok(()); + } + + self.pending_startup_thread_start = false; + self.chat_widget + .set_queue_submissions_until_session_configured(/*queue*/ false); + match result { + Ok(started) => { + self.enqueue_primary_thread_session(started.session, started.turns) + .await?; + self.chat_widget.maybe_send_next_queued_input(); + } + Err(err) => { + return Err(color_eyre::eyre::eyre!( + "Failed to start a fresh session through the app server: {err}" + )); + } + } + Ok(()) + } + pub(super) async fn start_fresh_session_with_summary_hint( &mut self, tui: &mut tui::Tui, diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index 044cb9b391..492639f139 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -369,15 +369,15 @@ impl App { self.chat_widget.add_error_message(message); return false; } - self.discard_side_thread_local(thread_id).await; + self.discard_thread_local_state(thread_id).await; true } pub(super) async fn discard_closed_side_thread(&mut self, thread_id: ThreadId) { - self.discard_side_thread_local(thread_id).await; + self.discard_thread_local_state(thread_id).await; } - async fn discard_side_thread_local(&mut self, thread_id: ThreadId) { + pub(super) async fn discard_thread_local_state(&mut self, thread_id: ThreadId) { self.abort_thread_event_listener(thread_id); self.thread_event_channels.remove(&thread_id); self.side_threads.remove(&thread_id); diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 6b6c424210..10c234a903 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -60,6 +60,7 @@ pub(super) async fn make_test_app() -> App { primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + pending_startup_thread_start: false, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index bddf5e544c..8d52500af6 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3882,6 +3882,7 @@ async fn make_test_app() -> App { primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + pending_startup_thread_start: false, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), } @@ -3945,6 +3946,7 @@ async fn make_test_app_with_channels() -> ( primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + pending_startup_thread_start: false, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), }, diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index c0aa1b61b7..51d6622e16 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -1,4 +1,7 @@ use super::*; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; #[test] @@ -132,6 +135,121 @@ fn startup_waiting_gate_not_applied_for_resume_or_fork_session_selection() { ); } +#[tokio::test] +async fn startup_thread_started_submits_queued_startup_input() { + let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; + app.pending_startup_thread_start = true; + app.chat_widget + .set_queue_submissions_until_session_configured(/*queue*/ true); + app.chat_widget + .apply_external_edit("queued before startup completes".to_string()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["queued before startup completes".to_string()] + ); + + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await + .expect("embedded app server"); + let thread_id = ThreadId::new(); + app.handle_startup_thread_started( + &mut app_server, + Ok(AppServerStartedThread { + session: test_thread_session(thread_id, test_path_buf("/tmp/project")), + turns: Vec::new(), + }), + ) + .await + .expect("startup thread should attach"); + + match next_user_turn_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued before startup completes".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued startup input submission, got {other:?}"), + } +} + +#[tokio::test] +async fn startup_thread_start_failure_returns_error() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.pending_startup_thread_start = true; + + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await + .expect("embedded app server"); + let err = app + .handle_startup_thread_started(&mut app_server, Err("boom".to_string())) + .await + .expect_err("startup thread failure should exit instead of leaving chat unconfigured"); + + assert!( + err.to_string() + .contains("Failed to start a fresh session through the app server: boom") + ); + assert!(!app.pending_startup_thread_start); + assert_eq!(app.primary_thread_id, None); +} + +#[test] +fn stale_startup_thread_started_removes_local_routing_state() -> Result<()> { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .thread_stack_size(8 * 1024 * 1024) + .enable_all() + .build()? + .block_on(async { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let primary_thread_id = ThreadId::new(); + let stale_thread_id = ThreadId::new(); + app.primary_thread_id = Some(primary_thread_id); + app.thread_event_channels.insert( + primary_thread_id, + ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY), + ); + app.activate_thread_channel(primary_thread_id).await; + app.thread_event_channels.insert( + stale_thread_id, + ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY), + ); + app.agent_navigation.upsert( + stale_thread_id, + /*agent_nickname*/ None, + /*agent_role*/ None, + /*is_closed*/ false, + ); + assert!(app.thread_event_channels.contains_key(&stale_thread_id)); + assert!(app.agent_navigation.get(&stale_thread_id).is_some()); + + app.handle_startup_thread_started( + &mut app_server, + Ok(AppServerStartedThread { + session: test_thread_session(stale_thread_id, test_path_buf("/tmp/project")), + turns: Vec::new(), + }), + ) + .await?; + + assert!(!app.thread_event_channels.contains_key(&stale_thread_id)); + assert_eq!(app.agent_navigation.get(&stale_thread_id), None); + assert_eq!(app.active_thread_id, Some(primary_thread_id)); + Ok(()) + }) +} + #[tokio::test] async fn ignore_same_thread_resume_reports_noop_for_current_thread() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 07a5b51175..a85ee5a70c 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -33,6 +33,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; use crate::app_command::AppCommand; +use crate::app_server_session::AppServerStartedThread; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::TerminalTitleItem; @@ -180,6 +181,11 @@ pub(crate) enum AppEvent { /// Start a new session. NewSession, + /// Result of the fresh startup thread that is attached after the input UI is live. + StartupThreadStarted { + result: Result, + }, + /// Clear the terminal UI (screen + scrollback), start a fresh session, and keep the /// previous chat resumable. ClearUi, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 1bcb4a5597..51029a6dee 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -119,6 +119,7 @@ use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use std::collections::HashMap; use std::path::PathBuf; +use uuid::Uuid; fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report { color_eyre::eyre::eyre!("{context}: {err}") @@ -167,6 +168,7 @@ impl ThreadParamsMode { } } +#[derive(Debug)] pub(crate) struct AppServerStartedThread { pub(crate) session: ThreadSessionState, pub(crate) turns: Vec, @@ -337,6 +339,7 @@ impl AppServerSession { self.client.next_event().await } + #[cfg(test)] pub(crate) async fn start_thread(&mut self, config: &Config) -> Result { self.start_thread_with_session_start_source(config, /*session_start_source*/ None) .await @@ -427,7 +430,7 @@ impl AppServerSession { Ok(started) } - fn thread_params_mode(&self) -> ThreadParamsMode { + pub(crate) fn thread_params_mode(&self) -> ThreadParamsMode { self.thread_params_mode } @@ -1002,6 +1005,27 @@ impl AppServerSession { } } +pub(crate) async fn start_thread_with_request_handle( + request_handle: AppServerRequestHandle, + config: Config, + thread_params_mode: ThreadParamsMode, + remote_cwd_override: Option, +) -> Result { + let response: ThreadStartResponse = request_handle + .request_typed(ClientRequest::ThreadStart { + request_id: RequestId::String(format!("startup-thread-start-{}", Uuid::new_v4())), + params: thread_start_params_from_config( + &config, + thread_params_mode, + remote_cwd_override.as_deref(), + /*session_start_source*/ None, + ), + }) + .await + .map_err(|err| bootstrap_request_error("thread/start failed during TUI bootstrap", err))?; + started_thread_from_start_response(response, &config, thread_params_mode).await +} + fn thread_realtime_start_params( thread_id: ThreadId, transport: Option, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index ef870c6776..a3413dd3be 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -355,6 +355,7 @@ pub(crate) struct ChatComposer { attachments: AttachmentState, placeholder_text: String, is_task_running: bool, + queue_submissions: bool, /// Slash-command draft staged for local recall after application-level dispatch. /// /// This slot is intentionally separate from `ChatComposerHistory` so inline slash commands can @@ -524,6 +525,7 @@ impl ChatComposer { attachments: AttachmentState::default(), placeholder_text, is_task_running: false, + queue_submissions: false, pending_slash_command_history: None, #[cfg(not(target_os = "linux"))] next_element_id: 0, @@ -2817,6 +2819,9 @@ impl ChatComposer { now: Instant, ) -> (InputResult, bool) { if should_queue { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { + self.handle_paste(pasted); + } let raw_text = self.draft.textarea.text(); let defer_slash_validation = self.should_parse_as_slash_on_dequeue_from_raw_text(raw_text); @@ -3227,13 +3232,13 @@ impl ChatComposer { self.footer.mode = reset_mode_after_activity(self.footer.mode); } if self.queue_keys.is_pressed(key_event) - && (self.is_task_running || !self.is_bang_shell_command()) + && (self.is_task_running || self.queue_submissions || !self.is_bang_shell_command()) { - return self.handle_submission(self.is_task_running); + return self.handle_submission(self.is_task_running || self.queue_submissions); } if self.submit_keys.is_pressed(key_event) { - return self.handle_submission(/*should_queue*/ false); + return self.handle_submission(self.queue_submissions); } if let KeyEvent { @@ -4143,6 +4148,10 @@ impl ChatComposer { self.is_task_running = running; } + pub(crate) fn set_queue_submissions(&mut self, queue_submissions: bool) { + self.queue_submissions = queue_submissions; + } + pub(crate) fn set_context_window(&mut self, percent: Option, used_tokens: Option) { if self.footer.context_window_percent == percent && self.footer.context_window_used_tokens == used_tokens @@ -7064,6 +7073,49 @@ mod tests { assert_eq!(composer.draft.textarea.text(), "hi\nthere"); } + /// Behavior: startup-pending submissions are queued immediately, so Enter should flush any + /// buffered burst text into that queued message instead of turning into a draft newline. + #[test] + fn queued_submission_flushes_ascii_burst_instead_of_inserting_newline() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let mut now = Instant::now(); + let step = Duration::from_millis(1); + for ch in ['h', 'i'] { + let _ = composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), + now, + ); + now += step; + } + assert!(composer.is_in_paste_burst()); + + let (result, _) = composer.handle_submission_with_time(/*should_queue*/ true, now); + + assert_eq!( + result, + InputResult::Queued { + text: "hi".to_string(), + text_elements: Vec::new(), + action: QueuedInputAction::Plain, + } + ); + assert!(composer.draft.textarea.text().is_empty()); + assert!(!composer.is_in_paste_burst()); + } + /// Behavior: even if Enter suppression would normally be active for a burst, Enter should /// still dispatch a built-in slash command when the first line begins with `/`. #[test] @@ -8029,6 +8081,40 @@ mod tests { assert!(found_error, "expected error history cell to be sent"); } + #[test] + fn enter_queues_when_queue_submissions_is_enabled() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_queue_submissions(/*queue_submissions*/ true); + composer + .draft + .textarea + .set_text_clearing_elements("queued before session"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + result, + InputResult::Queued { + text: "queued before session".to_string(), + text_elements: Vec::new(), + action: QueuedInputAction::Plain, + } + ); + } + #[test] fn tab_queues_slash_led_prompts_while_task_running_without_validation() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 5aeb2a4cfd..65770b61ae 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -999,6 +999,10 @@ impl BottomPane { } } + pub(crate) fn set_queue_submissions(&mut self, queue_submissions: bool) { + self.composer.set_queue_submissions(queue_submissions); + } + /// Hide the status indicator while leaving task-running state untouched. pub(crate) fn hide_status_indicator(&mut self) { if self.status.take().is_some() { diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index 704d5dcac6..dbc52269a3 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -72,6 +72,11 @@ impl ChatWidget { self.queue_user_message_with_options(user_message, QueuedInputAction::Plain); } + pub(crate) fn set_queue_submissions_until_session_configured(&mut self, queue: bool) { + self.bottom_pane + .set_queue_submissions(queue && !self.is_session_configured()); + } + pub(super) fn queue_user_message_with_options( &mut self, user_message: UserMessage, diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 2b8beefdf8..9837eaa6ea 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -20,6 +20,8 @@ impl ChatWidget { self.session_network_proxy = session.network_proxy.clone(); let previous_thread_id = self.thread_id; self.thread_id = Some(session.thread_id); + self.bottom_pane + .set_queue_submissions(/*queue_submissions*/ false); if previous_thread_id != self.thread_id { self.review.recent_auto_review_denials = RecentAutoReviewDenials::default(); } From 050a2e26687827d396b9fc30b588d3070cf3b067 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 20 May 2026 14:30:19 -0300 Subject: [PATCH 42/63] fix(app-server): speed up shutdown (#23578) ## Why Pressing `Ctrl+C` or `Ctrl+D` in the TUI could make Codex pause during shutdown when app-server background work still held outbound sender clones. Shutdown tracing against the current `~/.codex` path found three relevant holders: - `SkillsWatcher` kept its event-loop task alive until the shutdown timeout path. - `AppServerAttestationProvider` retained a strong `Arc`, which could keep outbound teardown waiting after the processor task had exited. - A background `apps/list` task could still own an outbound sender when shutdown began, causing the in-process app-server runtime to wait for its outbound channel to close. ## What Changed - Give `SkillsWatcher` an explicit shutdown `CancellationToken` and cancel it from app-server teardown so its event loop drops the outbound sender promptly. - Change `AppServerAttestationProvider` to keep a `Weak` and return immediately when it can no longer be upgraded. - Give `AppsRequestProcessor` a shutdown `CancellationToken` and cancel in-flight background `apps/list` work during teardown. ## How to Test 1. Start Codex TUI from a real home configuration. 2. Press `Ctrl+C`. 3. Confirm Codex exits promptly instead of pausing during shutdown. 4. Repeat with `Ctrl+D` and confirm the same prompt exit path. Focused manual trace validation from the investigation: - Before the full fix, reproduced shutdown traces showed outbound teardown waiting on lingering owners, including `attestation.provider=1` and later `apps.list.task=1`. - After the fix, fresh real-home `Ctrl+D` traces showed `app_server.runtime.outbound_state_after_processor_join` with `owners=none`, `app_server.runtime.wait_outbound_handle = 0ms`, and total TUI app-server shutdown around `18ms`. Targeted validation: - `RUST_MIN_STACK=8388608 cargo test -p codex-app-server` --- codex-rs/app-server/src/attestation.rs | 9 ++++-- codex-rs/app-server/src/message_processor.rs | 7 +++++ codex-rs/app-server/src/request_processors.rs | 1 + .../src/request_processors/apps_processor.rs | 17 +++++++++-- codex-rs/app-server/src/skills_watcher.rs | 28 +++++++++++++++++-- 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/codex-rs/app-server/src/attestation.rs b/codex-rs/app-server/src/attestation.rs index 17bb10c38c..206c38cee1 100644 --- a/codex-rs/app-server/src/attestation.rs +++ b/codex-rs/app-server/src/attestation.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::sync::Weak; use axum::http::HeaderValue; use codex_app_server_protocol::AttestationGenerateParams; @@ -22,13 +23,13 @@ pub(crate) fn app_server_attestation_provider( thread_state_manager: ThreadStateManager, ) -> Arc { Arc::new(AppServerAttestationProvider { - outgoing, + outgoing: Arc::downgrade(&outgoing), thread_state_manager, }) } struct AppServerAttestationProvider { - outgoing: Arc, + outgoing: Weak, thread_state_manager: ThreadStateManager, } @@ -42,7 +43,9 @@ impl std::fmt::Debug for AppServerAttestationProvider { impl AttestationProvider for AppServerAttestationProvider { fn header_for_request(&self, context: AttestationContext) -> GenerateAttestationFuture<'_> { - let outgoing = self.outgoing.clone(); + let Some(outgoing) = self.outgoing.upgrade() else { + return Box::pin(async { None }); + }; let thread_state_manager = self.thread_state_manager.clone(); Box::pin(async move { request_attestation_header_value_with_timeout( diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index eb54307849..2a15de5666 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -85,6 +85,7 @@ use tokio::sync::broadcast; use tokio::sync::watch; use tokio::time::Duration; use tokio::time::timeout; +use tokio_util::sync::CancellationToken; use tracing::Instrument; const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); @@ -159,6 +160,7 @@ impl ExternalAuth for ExternalAuthRefreshBridge { pub(crate) struct MessageProcessor { outgoing: Arc, + skills_watcher: Arc, account_processor: AccountRequestProcessor, apps_processor: AppsRequestProcessor, catalog_processor: CatalogRequestProcessor, @@ -330,6 +332,7 @@ impl MessageProcessor { let thread_list_state_permit = Arc::new(Semaphore::new(/*permits*/ 1)); let workspace_settings_cache = Arc::new(workspace_settings::WorkspaceSettingsCache::default()); + let app_list_shutdown_token = CancellationToken::new(); let account_processor = AccountRequestProcessor::new( auth_manager.clone(), Arc::clone(&thread_manager), @@ -343,6 +346,7 @@ impl MessageProcessor { outgoing.clone(), config_manager.clone(), Arc::clone(&workspace_settings_cache), + app_list_shutdown_token, ); let catalog_processor = CatalogRequestProcessor::new( auth_manager.clone(), @@ -477,6 +481,7 @@ impl MessageProcessor { Self { outgoing, + skills_watcher, account_processor, apps_processor, catalog_processor, @@ -504,6 +509,8 @@ impl MessageProcessor { pub(crate) fn clear_runtime_references(&self) { self.account_processor.clear_external_auth(); + self.apps_processor.shutdown(); + self.skills_watcher.shutdown(); } pub(crate) async fn process_request( diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 168640c0eb..157c3c8805 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -429,6 +429,7 @@ use tokio::sync::broadcast; use tokio::sync::oneshot; use tokio::sync::watch; use tokio_util::sync::CancellationToken; +use tokio_util::sync::DropGuard; use tokio_util::task::TaskTracker; use toml::Value as TomlValue; use tracing::Instrument; diff --git a/codex-rs/app-server/src/request_processors/apps_processor.rs b/codex-rs/app-server/src/request_processors/apps_processor.rs index caf60a91a0..5a1c9ffe1c 100644 --- a/codex-rs/app-server/src/request_processors/apps_processor.rs +++ b/codex-rs/app-server/src/request_processors/apps_processor.rs @@ -1,12 +1,13 @@ use super::*; -#[derive(Clone)] pub(crate) struct AppsRequestProcessor { auth_manager: Arc, thread_manager: Arc, outgoing: Arc, config_manager: ConfigManager, workspace_settings_cache: Arc, + shutdown_token: CancellationToken, + _shutdown_drop_guard: DropGuard, } impl AppsRequestProcessor { @@ -16,13 +17,17 @@ impl AppsRequestProcessor { outgoing: Arc, config_manager: ConfigManager, workspace_settings_cache: Arc, + shutdown_token: CancellationToken, ) -> Self { + let shutdown_drop_guard = shutdown_token.clone().drop_guard(); Self { auth_manager, thread_manager, outgoing, config_manager, workspace_settings_cache, + shutdown_token, + _shutdown_drop_guard: shutdown_drop_guard, } } @@ -83,12 +88,20 @@ impl AppsRequestProcessor { let request = request_id.clone(); let outgoing = Arc::clone(&self.outgoing); let environment_manager = self.thread_manager.environment_manager(); + let shutdown_token = self.shutdown_token.child_token(); tokio::spawn(async move { - Self::apps_list_task(outgoing, request, params, config, environment_manager).await; + tokio::select! { + _ = shutdown_token.cancelled() => {} + _ = Self::apps_list_task(outgoing, request, params, config, environment_manager) => {} + } }); Ok(None) } + pub(crate) fn shutdown(&self) { + self.shutdown_token.cancel(); + } + async fn apps_list_task( outgoing: Arc, request_id: ConnectionRequestId, diff --git a/codex-rs/app-server/src/skills_watcher.rs b/codex-rs/app-server/src/skills_watcher.rs index d20628d386..9e34cdac8e 100644 --- a/codex-rs/app-server/src/skills_watcher.rs +++ b/codex-rs/app-server/src/skills_watcher.rs @@ -15,6 +15,8 @@ use codex_file_watcher::ThrottledWatchReceiver; use codex_file_watcher::WatchPath; use codex_file_watcher::WatchRegistration; use codex_protocol::protocol::TurnEnvironmentSelection; +use tokio_util::sync::CancellationToken; +use tokio_util::sync::DropGuard; use tracing::warn; #[cfg(not(test))] @@ -24,6 +26,8 @@ const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_millis(50); pub(crate) struct SkillsWatcher { subscriber: FileWatcherSubscriber, + shutdown_token: CancellationToken, + _shutdown_drop_guard: DropGuard, } impl SkillsWatcher { @@ -39,8 +43,18 @@ impl SkillsWatcher { } }; let (subscriber, rx) = file_watcher.add_subscriber(); - Self::spawn_event_loop(rx, skills_manager, outgoing); - Arc::new(Self { 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()); + Arc::new(Self { + subscriber, + shutdown_token, + _shutdown_drop_guard: shutdown_drop_guard, + }) + } + + pub(crate) fn shutdown(&self) { + self.shutdown_token.cancel(); } pub(crate) async fn register_thread_config( @@ -92,6 +106,7 @@ impl SkillsWatcher { rx: Receiver, skills_manager: Arc, outgoing: Arc, + shutdown_token: CancellationToken, ) { let mut rx = ThrottledWatchReceiver::new(rx, WATCHER_THROTTLE_INTERVAL); let Ok(handle) = tokio::runtime::Handle::try_current() else { @@ -99,7 +114,14 @@ impl SkillsWatcher { return; }; handle.spawn(async move { - while rx.recv().await.is_some() { + loop { + let event = tokio::select! { + _ = shutdown_token.cancelled() => break, + event = rx.recv() => event, + }; + if event.is_none() { + break; + } skills_manager.clear_cache(); outgoing .send_server_notification(ServerNotification::SkillsChanged( From 2b4898cc47fe1e991f1818ed9973d83993aa08a1 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 20 May 2026 10:30:46 -0700 Subject: [PATCH 43/63] windows-sandbox: add resolved permissions helper (#22896) ## Why The Windows sandbox migration away from the legacy `SandboxPolicy` abstraction needs a small local bridge before IPC and core wiring can move to `PermissionProfile`. Leaf helpers currently branch directly on `WorkspaceWrite`, which spreads legacy assumptions through path planning and token setup code. This PR introduces a Windows-local resolved permissions view so those helpers can ask Windows-specific questions about runtime filesystem/network permissions without matching on the legacy policy enum everywhere. ## What changed - Added `ResolvedWindowsSandboxPermissions` in `windows-sandbox-rs/src/resolved_permissions.rs`, with legacy `SandboxPolicy` constructors for the current call sites. - Moved `allow.rs` writable-root and read-only-subpath planning onto the resolved permissions type. - Preserved Windows `TEMP`/`TMP` writable-root behavior when the effective policy includes writable tmpdir access. - Avoided resolving Unix `:slash_tmp` or parent-process `TMPDIR` while computing Windows writable roots. - Reused the shared allow-path result for setup write-root gathering and routed network-block selection through the resolved abstraction. ## Verification - `cargo test -p codex-windows-sandbox` - `just fix -p codex-windows-sandbox` - GitHub CI restarted on the amended commit; Windows Bazel is the required signal for the Windows-only code paths. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22896). * #23715 * #23714 * #23167 * #22923 * #22918 * __->__ #22896 --- codex-rs/windows-sandbox-rs/src/allow.rs | 131 ++++++++++-------- codex-rs/windows-sandbox-rs/src/lib.rs | 2 + .../src/resolved_permissions.rs | 116 ++++++++++++++++ codex-rs/windows-sandbox-rs/src/setup.rs | 7 +- codex-rs/windows-sandbox-rs/src/spawn_prep.rs | 3 +- 5 files changed, 194 insertions(+), 65 deletions(-) create mode 100644 codex-rs/windows-sandbox-rs/src/resolved_permissions.rs diff --git a/codex-rs/windows-sandbox-rs/src/allow.rs b/codex-rs/windows-sandbox-rs/src/allow.rs index 5b6d0d617a..e82cf8cd62 100644 --- a/codex-rs/windows-sandbox-rs/src/allow.rs +++ b/codex-rs/windows-sandbox-rs/src/allow.rs @@ -1,4 +1,5 @@ use crate::policy::SandboxPolicy; +use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; use dunce::canonicalize; use std::collections::HashMap; use std::collections::HashSet; @@ -11,9 +12,19 @@ pub struct AllowDenyPaths { pub deny: HashSet, } -pub fn compute_allow_paths( +pub(crate) fn compute_allow_paths( policy: &SandboxPolicy, - policy_cwd: &Path, + _policy_cwd: &Path, + command_cwd: &Path, + env_map: &HashMap, +) -> AllowDenyPaths { + let permissions = + ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd(policy, command_cwd); + compute_allow_paths_for_permissions(&permissions, command_cwd, env_map) +} + +pub(crate) fn compute_allow_paths_for_permissions( + permissions: &ResolvedWindowsSandboxPermissions, command_cwd: &Path, env_map: &HashMap, ) -> AllowDenyPaths { @@ -30,65 +41,15 @@ pub fn compute_allow_paths( deny.insert(p); } }; - let include_tmp_env_vars = matches!( - policy, - SandboxPolicy::WorkspaceWrite { - exclude_tmpdir_env_var: false, - .. - } - ); - if matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) { - let add_writable_root = - |root: PathBuf, - policy_cwd: &Path, - add_allow: &mut dyn FnMut(PathBuf), - add_deny: &mut dyn FnMut(PathBuf)| { - let candidate = if root.is_absolute() { - root - } else { - policy_cwd.join(root) - }; - let canonical = canonicalize(&candidate).unwrap_or(candidate); - add_allow(canonical.clone()); - - for protected_subdir in [".git", ".codex", ".agents"] { - let protected_entry = canonical.join(protected_subdir); - if protected_entry.exists() { - add_deny(protected_entry); - } - } - }; - - add_writable_root( - command_cwd.to_path_buf(), - policy_cwd, - &mut add_allow_path, - &mut add_deny_path, - ); - - if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = policy { - for root in writable_roots { - add_writable_root( - root.clone().into(), - policy_cwd, - &mut add_allow_path, - &mut add_deny_path, - ); - } - } - } - if include_tmp_env_vars { - for key in ["TEMP", "TMP"] { - if let Some(v) = env_map.get(key) { - let abs = PathBuf::from(v); - add_allow_path(abs); - } else if let Ok(v) = std::env::var(key) { - let abs = PathBuf::from(v); - add_allow_path(abs); - } + for writable_root in permissions.writable_roots_for_cwd(command_cwd, env_map) { + let canonical = canonicalize(&writable_root.root).unwrap_or(writable_root.root); + add_allow_path(canonical); + for read_only_subpath in writable_root.read_only_subpaths { + add_deny_path(read_only_subpath); } } + AllowDenyPaths { allow, deny } } @@ -146,6 +107,7 @@ mod tests { }; let mut env_map = HashMap::new(); env_map.insert("TEMP".into(), temp_dir.to_string_lossy().to_string()); + env_map.insert("TMP".into(), temp_dir.to_string_lossy().to_string()); let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &env_map); @@ -162,6 +124,59 @@ mod tests { assert!(paths.deny.is_empty(), "no deny paths expected"); } + #[test] + fn includes_tmp_env_vars_when_requested() { + let tmp = TempDir::new().expect("tempdir"); + let command_cwd = tmp.path().join("workspace"); + let temp_dir = tmp.path().join("temp"); + let _ = fs::create_dir_all(&command_cwd); + let _ = fs::create_dir_all(&temp_dir); + + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + let mut env_map = HashMap::new(); + env_map.insert("TEMP".into(), temp_dir.to_string_lossy().to_string()); + env_map.insert("TMP".into(), temp_dir.to_string_lossy().to_string()); + + let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &env_map); + + let expected_allow: HashSet = [ + dunce::canonicalize(&command_cwd).unwrap(), + dunce::canonicalize(&temp_dir).unwrap(), + ] + .into_iter() + .collect(); + + assert_eq!(expected_allow, paths.allow); + assert!(paths.deny.is_empty(), "no deny paths expected"); + } + + #[test] + fn ignores_unix_slash_tmp_for_windows_allow_roots() { + let tmp = TempDir::new().expect("tempdir"); + let command_cwd = tmp.path().join("workspace"); + let _ = fs::create_dir_all(&command_cwd); + + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: false, + }; + + let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &HashMap::new()); + let expected_allow: HashSet = [dunce::canonicalize(&command_cwd).unwrap()] + .into_iter() + .collect(); + + assert_eq!(expected_allow, paths.allow); + assert!(paths.deny.is_empty(), "no deny paths expected"); + } + #[test] fn denies_git_dir_inside_writable_root() { let tmp = TempDir::new().expect("tempdir"); diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index da2a2d3634..5accd0be36 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -38,6 +38,8 @@ mod policy; #[cfg(target_os = "windows")] mod process; #[cfg(target_os = "windows")] +mod resolved_permissions; +#[cfg(target_os = "windows")] mod token; #[cfg(target_os = "windows")] mod wfp; diff --git a/codex-rs/windows-sandbox-rs/src/resolved_permissions.rs b/codex-rs/windows-sandbox-rs/src/resolved_permissions.rs new file mode 100644 index 0000000000..6b2daa56e0 --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/resolved_permissions.rs @@ -0,0 +1,116 @@ +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; + +/// Windows-local view of the runtime permission profile. +/// +/// Most Windows sandbox code needs resolved runtime permissions plus a few +/// Windows-specific path conventions, not the user/config-facing +/// `PermissionProfile` enum itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ResolvedWindowsSandboxPermissions { + file_system: FileSystemSandboxPolicy, + network: NetworkSandboxPolicy, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WindowsWritableRoot { + pub(crate) root: PathBuf, + pub(crate) read_only_subpaths: Vec, +} + +impl ResolvedWindowsSandboxPermissions { + pub(crate) fn from_legacy_policy(policy: &SandboxPolicy) -> Self { + Self { + file_system: FileSystemSandboxPolicy::from(policy), + network: NetworkSandboxPolicy::from(policy), + } + } + + pub(crate) fn from_legacy_policy_for_cwd(policy: &SandboxPolicy, cwd: &Path) -> Self { + Self { + file_system: FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(policy, cwd), + network: NetworkSandboxPolicy::from(policy), + } + } + + pub(crate) fn should_apply_network_block(&self) -> bool { + !self.network.is_enabled() + } + + pub(crate) fn writable_roots_for_cwd( + &self, + cwd: &Path, + env_map: &HashMap, + ) -> Vec { + let mut file_system = self.file_system.clone(); + file_system + .entries + .retain(|FileSystemSandboxEntry { path, .. }| { + !matches!( + path, + FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Tmpdir + | codex_protocol::permissions::FileSystemSpecialPath::SlashTmp, + } + ) + }); + + let mut roots = file_system + .get_writable_roots_with_cwd(cwd) + .into_iter() + .map(|root| WindowsWritableRoot { + root: root.root.into_path_buf(), + read_only_subpaths: root + .read_only_subpaths + .into_iter() + .map(AbsolutePathBuf::into_path_buf) + .collect(), + }) + .collect::>(); + + if self.has_writable_tmpdir_entry() { + roots.extend(windows_temp_env_roots(env_map).into_iter().map(|root| { + WindowsWritableRoot { + root, + read_only_subpaths: Vec::new(), + } + })); + } + + roots + } + + fn has_writable_tmpdir_entry(&self) -> bool { + self.file_system + .entries + .iter() + .any(|FileSystemSandboxEntry { path, access }| { + matches!( + path, + FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Tmpdir, + } + ) && access.can_write() + }) + } +} + +fn windows_temp_env_roots(env_map: &HashMap) -> Vec { + ["TEMP", "TMP"] + .into_iter() + .filter_map(|key| { + env_map + .get(key) + .map(|value| PathBuf::from(value.as_str())) + .or_else(|| std::env::var_os(key).map(PathBuf::from)) + }) + .filter(|path| path.is_absolute()) + .collect() +} diff --git a/codex-rs/windows-sandbox-rs/src/setup.rs b/codex-rs/windows-sandbox-rs/src/setup.rs index 0de2c6e029..6f4dd86019 100644 --- a/codex-rs/windows-sandbox-rs/src/setup.rs +++ b/codex-rs/windows-sandbox-rs/src/setup.rs @@ -395,14 +395,9 @@ pub(crate) fn gather_write_roots( command_cwd: &Path, env_map: &HashMap, ) -> Vec { - let mut roots: Vec = Vec::new(); - // Always include the command CWD for workspace-write. - if matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) { - roots.push(command_cwd.to_path_buf()); - } let AllowDenyPaths { allow, .. } = compute_allow_paths(policy, policy_cwd, command_cwd, env_map); - roots.extend(allow); + let roots: Vec = allow.into_iter().collect(); let mut dedup: HashSet = HashSet::new(); let mut out: Vec = Vec::new(); for r in canonical_existing(&roots) { diff --git a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs index b27fa822fa..8df1ba804f 100644 --- a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs +++ b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs @@ -19,6 +19,7 @@ use crate::logging::log_start; use crate::path_normalization::canonicalize_path; use crate::policy::SandboxPolicy; use crate::policy::parse_policy; +use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; use crate::sandbox_utils::ensure_codex_home_exists; use crate::sandbox_utils::inject_git_safe_directory; use crate::setup::effective_write_roots_for_setup; @@ -73,7 +74,7 @@ pub(crate) struct LegacyAclSids<'a> { } pub(crate) fn should_apply_network_block(policy: &SandboxPolicy) -> bool { - !policy.has_full_network_access() + ResolvedWindowsSandboxPermissions::from_legacy_policy(policy).should_apply_network_block() } fn prepare_spawn_context_common( From 771a4e74ac319c3d8379c62c7caa3aec1ad53382 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 20 May 2026 11:03:20 -0700 Subject: [PATCH 44/63] Add thread/settings/update app-server API (#23502) ## Why App-server clients need a way to update a thread's next-turn settings without starting a turn, adding transcript content, or waiting for turn lifecycle events. This gives settings UI a direct path for durable thread settings while clients observe the eventual effective state through a notification. This is a simplified rework of PR https://github.com/openai/codex/pull/22509. In particular, it changes the `thread/settings/update` api to return immediately rather than waiting and returning the effective (updated) thread settings. This makes the new api consistent with `turn/start` and greatly reduces the complexity of the implementation relative to the earlier attempt. ## What Changed - Adds experimental `thread/settings/update` with partial-update request fields and an empty acknowledgment response. - Adds experimental `thread/settings/updated`, carrying full effective `ThreadSettings` and scoped by `threadId` to subscribed clients for the affected thread. - Shares durable settings validation with `turn/start`, including `sandboxPolicy` plus `permissions` rejection and `serviceTier: null` clearing. - Emits the same settings notification when `turn/start` overrides change the stored effective thread settings. - Regenerates app-server protocol schema fixtures and updates `app-server/README.md`. --- codex-rs/app-server-client/src/lib.rs | 1 + .../schema/json/ServerNotification.json | 381 ++++++++++++++++ .../codex_app_server_protocol.schemas.json | 118 +++++ .../codex_app_server_protocol.v2.schemas.json | 118 +++++ .../v2/ThreadSettingsUpdatedNotification.json | 381 ++++++++++++++++ .../schema/typescript/ServerNotification.ts | 3 +- .../schema/typescript/v2/ThreadSettings.ts | 14 + .../v2/ThreadSettingsUpdatedNotification.ts | 6 + .../schema/typescript/v2/index.ts | 2 + .../src/protocol/common.rs | 43 ++ .../src/protocol/v2/tests.rs | 71 +++ .../src/protocol/v2/thread.rs | 89 ++++ codex-rs/app-server/README.md | 2 + .../app-server/src/bespoke_event_handling.rs | 20 + codex-rs/app-server/src/in_process.rs | 5 +- codex-rs/app-server/src/message_processor.rs | 5 + codex-rs/app-server/src/request_processors.rs | 7 + .../request_processors/thread_lifecycle.rs | 9 +- .../src/request_processors/thread_summary.rs | 52 ++- .../src/request_processors/turn_processor.rs | 410 +++++++++++------- codex-rs/app-server/src/thread_state.rs | 64 +++ .../app-server/tests/common/mcp_process.rs | 10 + .../tests/suite/v2/experimental_api.rs | 35 ++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../tests/suite/v2/thread_settings_update.rs | 400 +++++++++++++++++ .../tui/src/app/app_server_event_targets.rs | 3 + codex-rs/tui/src/chatwidget/protocol.rs | 1 + 27 files changed, 2092 insertions(+), 159 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettingsUpdatedNotification.ts create mode 100644 codex-rs/app-server/tests/suite/v2/thread_settings_update.rs diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index c7f5b9889a..7c31170779 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -176,6 +176,7 @@ pub(crate) fn server_notification_requires_delivery(notification: &ServerNotific matches!( notification, ServerNotification::TurnCompleted(_) + | ServerNotification::ThreadSettingsUpdated(_) | ServerNotification::ItemCompleted(_) | ServerNotification::AgentMessageDelta(_) | ServerNotification::PlanDelta(_) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b9058aa1ce..2a36d66c1d 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -64,6 +64,26 @@ }, "type": "object" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "AdditionalFileSystemPermissions": { "properties": { "entries": { @@ -415,6 +435,65 @@ ], "type": "object" }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ], + "type": "string" + }, + "AskForApproval": { + "oneOf": [ + { + "enum": [ + "untrusted", + "on-failure", + "on-request", + "never" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "granular": { + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "mcp_elicitations", + "rules", + "sandbox_approval" + ], + "type": "object" + } + }, + "required": [ + "granular" + ], + "title": "GranularAskForApproval", + "type": "object" + } + ] + }, "AuthMode": { "description": "Authentication mode for OpenAI-backed providers.", "oneOf": [ @@ -658,6 +737,22 @@ ], "type": "string" }, + "CollaborationMode": { + "description": "Collaboration mode for a Codex session.", + "properties": { + "mode": { + "$ref": "#/definitions/ModeKind" + }, + "settings": { + "$ref": "#/definitions/Settings" + } + }, + "required": [ + "mode", + "settings" + ], + "type": "object" + }, "CommandAction": { "oneOf": [ { @@ -2258,6 +2353,14 @@ } ] }, + "ModeKind": { + "description": "Initial collaboration mode to use when the TUI starts.", + "enum": [ + "plan", + "default" + ], + "type": "string" + }, "ModelRerouteReason": { "enum": [ "highRiskCyberActivity" @@ -2319,6 +2422,13 @@ ], "type": "object" }, + "NetworkAccess": { + "enum": [ + "restricted", + "enabled" + ], + "type": "string" + }, "NetworkApprovalProtocol": { "enum": [ "http", @@ -2402,6 +2512,14 @@ } ] }, + "Personality": { + "enum": [ + "none", + "friendly", + "pragmatic" + ], + "type": "string" + }, "PlanDeltaNotification": { "description": "EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should not assume concatenated deltas match the completed plan item content.", "properties": { @@ -2655,6 +2773,26 @@ ], "type": "string" }, + "ReasoningSummary": { + "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", + "oneOf": [ + { + "enum": [ + "auto", + "concise", + "detailed" + ], + "type": "string" + }, + { + "description": "Option to disable reasoning summaries.", + "enum": [ + "none" + ], + "type": "string" + } + ] + }, "ReasoningSummaryPartAddedNotification": { "properties": { "itemId": { @@ -2807,6 +2945,105 @@ }, "type": "object" }, + "SandboxPolicy": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "dangerFullAccess" + ], + "title": "DangerFullAccessSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DangerFullAccessSandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "readOnly" + ], + "title": "ReadOnlySandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ReadOnlySandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "allOf": [ + { + "$ref": "#/definitions/NetworkAccess" + } + ], + "default": "restricted" + }, + "type": { + "enum": [ + "externalSandbox" + ], + "title": "ExternalSandboxSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ExternalSandboxSandboxPolicy", + "type": "object" + }, + { + "properties": { + "excludeSlashTmp": { + "default": false, + "type": "boolean" + }, + "excludeTmpdirEnvVar": { + "default": false, + "type": "boolean" + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "workspaceWrite" + ], + "title": "WorkspaceWriteSandboxPolicyType", + "type": "string" + }, + "writableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "type" + ], + "title": "WorkspaceWriteSandboxPolicy", + "type": "object" + } + ] + }, "ServerRequestResolvedNotification": { "properties": { "requestId": { @@ -2862,6 +3099,34 @@ } ] }, + "Settings": { + "description": "Settings for a collaboration mode.", + "properties": { + "developer_instructions": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "reasoning_effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "model" + ], + "type": "object" + }, "SkillsChangedNotification": { "description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.", "type": "object" @@ -4148,6 +4413,102 @@ ], "type": "object" }, + "ThreadSettings": { + "properties": { + "activePermissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/ActivePermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/ApprovalsReviewer" + }, + "collaborationMode": { + "$ref": "#/definitions/CollaborationMode" + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "sandboxPolicy": { + "$ref": "#/definitions/SandboxPolicy" + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "collaborationMode", + "cwd", + "model", + "modelProvider", + "sandboxPolicy" + ], + "type": "object" + }, + "ThreadSettingsUpdatedNotification": { + "properties": { + "threadId": { + "type": "string" + }, + "threadSettings": { + "$ref": "#/definitions/ThreadSettings" + } + }, + "required": [ + "threadId", + "threadSettings" + ], + "type": "object" + }, "ThreadSource": { "enum": [ "user", @@ -5089,6 +5450,26 @@ "title": "Thread/goal/clearedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/settings/updated" + ], + "title": "Thread/settings/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSettingsUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/settings/updatedNotification", + "type": "object" + }, { "properties": { "method": { 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 f6e5a46c95..ee48d1e259 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 @@ -4055,6 +4055,26 @@ "title": "Thread/goal/clearedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/settings/updated" + ], + "title": "Thread/settings/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadSettingsUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/settings/updatedNotification", + "type": "object" + }, { "properties": { "method": { @@ -17234,6 +17254,104 @@ "title": "ThreadSetNameResponse", "type": "object" }, + "ThreadSettings": { + "properties": { + "activePermissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ActivePermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalPolicy": { + "$ref": "#/definitions/v2/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/v2/ApprovalsReviewer" + }, + "collaborationMode": { + "$ref": "#/definitions/v2/CollaborationMode" + }, + "cwd": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "effort": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/v2/Personality" + }, + { + "type": "null" + } + ] + }, + "sandboxPolicy": { + "$ref": "#/definitions/v2/SandboxPolicy" + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ReasoningSummary" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "collaborationMode", + "cwd", + "model", + "modelProvider", + "sandboxPolicy" + ], + "type": "object" + }, + "ThreadSettingsUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "threadSettings": { + "$ref": "#/definitions/v2/ThreadSettings" + } + }, + "required": [ + "threadId", + "threadSettings" + ], + "title": "ThreadSettingsUpdatedNotification", + "type": "object" + }, "ThreadShellCommandParams": { "$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 c4e93338a1..871cd81777 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 @@ -11407,6 +11407,26 @@ "title": "Thread/goal/clearedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/settings/updated" + ], + "title": "Thread/settings/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSettingsUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/settings/updatedNotification", + "type": "object" + }, { "properties": { "method": { @@ -15058,6 +15078,104 @@ "title": "ThreadSetNameResponse", "type": "object" }, + "ThreadSettings": { + "properties": { + "activePermissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/ActivePermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/ApprovalsReviewer" + }, + "collaborationMode": { + "$ref": "#/definitions/CollaborationMode" + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "sandboxPolicy": { + "$ref": "#/definitions/SandboxPolicy" + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "collaborationMode", + "cwd", + "model", + "modelProvider", + "sandboxPolicy" + ], + "type": "object" + }, + "ThreadSettingsUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "threadSettings": { + "$ref": "#/definitions/ThreadSettings" + } + }, + "required": [ + "threadId", + "threadSettings" + ], + "title": "ThreadSettingsUpdatedNotification", + "type": "object" + }, "ThreadShellCommandParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json new file mode 100644 index 0000000000..d016e28311 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json @@ -0,0 +1,381 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ], + "type": "string" + }, + "AskForApproval": { + "oneOf": [ + { + "enum": [ + "untrusted", + "on-failure", + "on-request", + "never" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "granular": { + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "mcp_elicitations", + "rules", + "sandbox_approval" + ], + "type": "object" + } + }, + "required": [ + "granular" + ], + "title": "GranularAskForApproval", + "type": "object" + } + ] + }, + "CollaborationMode": { + "description": "Collaboration mode for a Codex session.", + "properties": { + "mode": { + "$ref": "#/definitions/ModeKind" + }, + "settings": { + "$ref": "#/definitions/Settings" + } + }, + "required": [ + "mode", + "settings" + ], + "type": "object" + }, + "ModeKind": { + "description": "Initial collaboration mode to use when the TUI starts.", + "enum": [ + "plan", + "default" + ], + "type": "string" + }, + "NetworkAccess": { + "enum": [ + "restricted", + "enabled" + ], + "type": "string" + }, + "Personality": { + "enum": [ + "none", + "friendly", + "pragmatic" + ], + "type": "string" + }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, + "ReasoningSummary": { + "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", + "oneOf": [ + { + "enum": [ + "auto", + "concise", + "detailed" + ], + "type": "string" + }, + { + "description": "Option to disable reasoning summaries.", + "enum": [ + "none" + ], + "type": "string" + } + ] + }, + "SandboxPolicy": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "dangerFullAccess" + ], + "title": "DangerFullAccessSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DangerFullAccessSandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "readOnly" + ], + "title": "ReadOnlySandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ReadOnlySandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "allOf": [ + { + "$ref": "#/definitions/NetworkAccess" + } + ], + "default": "restricted" + }, + "type": { + "enum": [ + "externalSandbox" + ], + "title": "ExternalSandboxSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ExternalSandboxSandboxPolicy", + "type": "object" + }, + { + "properties": { + "excludeSlashTmp": { + "default": false, + "type": "boolean" + }, + "excludeTmpdirEnvVar": { + "default": false, + "type": "boolean" + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "workspaceWrite" + ], + "title": "WorkspaceWriteSandboxPolicyType", + "type": "string" + }, + "writableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "type" + ], + "title": "WorkspaceWriteSandboxPolicy", + "type": "object" + } + ] + }, + "Settings": { + "description": "Settings for a collaboration mode.", + "properties": { + "developer_instructions": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "reasoning_effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "model" + ], + "type": "object" + }, + "ThreadSettings": { + "properties": { + "activePermissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/ActivePermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/ApprovalsReviewer" + }, + "collaborationMode": { + "$ref": "#/definitions/CollaborationMode" + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "sandboxPolicy": { + "$ref": "#/definitions/SandboxPolicy" + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "collaborationMode", + "cwd", + "model", + "modelProvider", + "sandboxPolicy" + ], + "type": "object" + } + }, + "properties": { + "threadId": { + "type": "string" + }, + "threadSettings": { + "$ref": "#/definitions/ThreadSettings" + } + }, + "required": [ + "threadId", + "threadSettings" + ], + "title": "ThreadSettingsUpdatedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index f4dd0e1864..3ed710efc0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -54,6 +54,7 @@ import type { ThreadRealtimeSdpNotification } from "./v2/ThreadRealtimeSdpNotifi import type { ThreadRealtimeStartedNotification } from "./v2/ThreadRealtimeStartedNotification"; import type { ThreadRealtimeTranscriptDeltaNotification } from "./v2/ThreadRealtimeTranscriptDeltaNotification"; import type { ThreadRealtimeTranscriptDoneNotification } from "./v2/ThreadRealtimeTranscriptDoneNotification"; +import type { ThreadSettingsUpdatedNotification } from "./v2/ThreadSettingsUpdatedNotification"; import type { ThreadStartedNotification } from "./v2/ThreadStartedNotification"; import type { ThreadStatusChangedNotification } from "./v2/ThreadStatusChangedNotification"; import type { ThreadTokenUsageUpdatedNotification } from "./v2/ThreadTokenUsageUpdatedNotification"; @@ -69,4 +70,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts new file mode 100644 index 0000000000..bcfd0ad86c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts @@ -0,0 +1,14 @@ +// 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 { CollaborationMode } from "../CollaborationMode"; +import type { Personality } from "../Personality"; +import type { ReasoningEffort } from "../ReasoningEffort"; +import type { ReasoningSummary } from "../ReasoningSummary"; +import type { ActivePermissionProfile } from "./ActivePermissionProfile"; +import type { ApprovalsReviewer } from "./ApprovalsReviewer"; +import type { AskForApproval } from "./AskForApproval"; +import type { SandboxPolicy } from "./SandboxPolicy"; + +export type ThreadSettings = { cwd: AbsolutePathBuf, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, sandboxPolicy: SandboxPolicy, activePermissionProfile: ActivePermissionProfile | null, model: string, modelProvider: string, serviceTier: string | null, effort: ReasoningEffort | null, summary: ReasoningSummary | null, collaborationMode: CollaborationMode, personality: Personality | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettingsUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettingsUpdatedNotification.ts new file mode 100644 index 0000000000..964811ca69 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettingsUpdatedNotification.ts @@ -0,0 +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 { ThreadSettings } from "./ThreadSettings"; + +export type ThreadSettingsUpdatedNotification = { threadId: string, threadSettings: ThreadSettings, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index d9815baec6..4e9080ccdf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -397,6 +397,8 @@ export type { ThreadRollbackParams } from "./ThreadRollbackParams"; export type { ThreadRollbackResponse } from "./ThreadRollbackResponse"; export type { ThreadSetNameParams } from "./ThreadSetNameParams"; export type { ThreadSetNameResponse } from "./ThreadSetNameResponse"; +export type { ThreadSettings } from "./ThreadSettings"; +export type { ThreadSettingsUpdatedNotification } from "./ThreadSettingsUpdatedNotification"; export type { ThreadShellCommandParams } from "./ThreadShellCommandParams"; export type { ThreadShellCommandResponse } from "./ThreadShellCommandResponse"; export type { ThreadSortKey } from "./ThreadSortKey"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 4adcf5f592..1533449138 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -517,6 +517,13 @@ client_request_definitions! { serialization: thread_id(params.thread_id), response: v2::ThreadMetadataUpdateResponse, }, + #[experimental("thread/settings/update")] + ThreadSettingsUpdate => "thread/settings/update" { + params: v2::ThreadSettingsUpdateParams, + inspect_params: true, + serialization: thread_id(params.thread_id), + response: v2::ThreadSettingsUpdateResponse, + }, #[experimental("thread/memoryMode/set")] ThreadMemoryModeSet => "thread/memoryMode/set" { params: v2::ThreadMemoryModeSetParams, @@ -1470,6 +1477,8 @@ server_notification_definitions! { ThreadGoalUpdated => "thread/goal/updated" (v2::ThreadGoalUpdatedNotification), #[experimental("thread/goal/cleared")] ThreadGoalCleared => "thread/goal/cleared" (v2::ThreadGoalClearedNotification), + #[experimental("thread/settings/updated")] + ThreadSettingsUpdated => "thread/settings/updated" (v2::ThreadSettingsUpdatedNotification), ThreadTokenUsageUpdated => "thread/tokenUsage/updated" (v2::ThreadTokenUsageUpdatedNotification), TurnStarted => "turn/started" (v2::TurnStartedNotification), HookStarted => "hook/started" (v2::HookStartedNotification), @@ -3094,6 +3103,40 @@ mod tests { ); } + #[test] + fn thread_settings_updated_notification_is_marked_experimental() { + let notification = + ServerNotification::ThreadSettingsUpdated(v2::ThreadSettingsUpdatedNotification { + thread_id: "thr_123".to_string(), + thread_settings: v2::ThreadSettings { + cwd: absolute_path("/tmp/repo"), + approval_policy: v2::AskForApproval::Never, + approvals_reviewer: v2::ApprovalsReviewer::User, + sandbox_policy: v2::SandboxPolicy::DangerFullAccess, + active_permission_profile: None, + model: "gpt-5.4".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + effort: None, + summary: None, + collaboration_mode: codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: "gpt-5.4".to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }, + personality: None, + }, + }); + + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¬ification), + Some("thread/settings/updated") + ); + } + #[test] fn thread_realtime_started_notification_is_marked_experimental() { let notification = 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 09a8b609be..a7cec1d36d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -3580,6 +3580,77 @@ fn turn_start_params_preserve_explicit_null_service_tier() { assert_eq!(serialized_without_override.get("serviceTier"), None); } +#[test] +fn thread_settings_update_params_preserve_explicit_null_service_tier() { + let params: ThreadSettingsUpdateParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "serviceTier": null + })) + .expect("params should deserialize"); + assert_eq!(params.service_tier, Some(None)); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("serviceTier"), + Some(&serde_json::Value::Null) + ); + + let without_override = ThreadSettingsUpdateParams { + thread_id: "thread_123".to_string(), + service_tier: None, + ..Default::default() + }; + let serialized_without_override = + serde_json::to_value(&without_override).expect("params should serialize"); + assert_eq!(serialized_without_override.get("serviceTier"), None); +} + +#[test] +fn thread_settings_update_params_preserve_field_level_experimental_gates() { + let permissions = ThreadSettingsUpdateParams { + thread_id: "thread_123".to_string(), + permissions: Some(":workspace".to_string()), + ..Default::default() + }; + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&permissions), + Some("thread/settings/update.permissions") + ); + + let granular_approval = ThreadSettingsUpdateParams { + thread_id: "thread_123".to_string(), + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: true, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + ..Default::default() + }; + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&granular_approval), + Some("askForApproval.granular") + ); + + let collaboration_mode = ThreadSettingsUpdateParams { + thread_id: "thread_123".to_string(), + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Plan, + settings: codex_protocol::config_types::Settings { + model: "mock-model".to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }; + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&collaboration_mode), + Some("thread/settings/update.collaborationMode") + ); +} + #[test] fn turn_start_params_round_trip_environments() { let cwd = test_absolute_path(); 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 059bef05f2..35dbca288a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -11,7 +11,9 @@ use super::TurnEnvironmentParams; use super::TurnItemsView; use super::shared::v2_enum_from_core; use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus; @@ -219,6 +221,93 @@ pub struct ThreadStartResponse { pub reasoning_effort: Option, } +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSettingsUpdateParams { + pub thread_id: String, + /// Override the working directory for subsequent turns. + #[ts(optional = nullable)] + pub cwd: Option, + /// Override the approval policy for subsequent turns. + #[experimental(nested)] + #[ts(optional = nullable)] + pub approval_policy: Option, + /// Override where approval requests are routed for subsequent turns. + #[ts(optional = nullable)] + pub approvals_reviewer: Option, + /// Override the sandbox policy for subsequent turns. + #[ts(optional = nullable)] + pub sandbox_policy: Option, + /// Select a named permissions profile id for subsequent turns. Cannot be + /// combined with `sandboxPolicy`. + #[experimental("thread/settings/update.permissions")] + #[ts(optional = nullable)] + pub permissions: Option, + /// Override the model for subsequent turns. + #[ts(optional = nullable)] + pub model: Option, + /// Override the service tier for subsequent turns. `null` clears the + /// current service tier; omission leaves it unchanged. + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, + /// Override the reasoning effort for subsequent turns. + #[ts(optional = nullable)] + pub effort: Option, + /// Override the reasoning summary for subsequent turns. + #[ts(optional = nullable)] + pub summary: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode for subsequent turns. + /// + /// For `collaboration_mode.settings.developer_instructions`, `null` means + /// "use the built-in instructions for the selected mode". + #[experimental("thread/settings/update.collaborationMode")] + #[ts(optional = nullable)] + pub collaboration_mode: Option, + /// Override the personality for subsequent turns. + #[ts(optional = nullable)] + pub personality: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSettingsUpdateResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSettings { + pub cwd: AbsolutePathBuf, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_policy: SandboxPolicy, + pub active_permission_profile: Option, + pub model: String, + pub model_provider: String, + pub service_tier: Option, + pub effort: Option, + pub summary: Option, + pub collaboration_mode: CollaborationMode, + pub personality: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSettingsUpdatedNotification { + pub thread_id: String, + pub thread_settings: ThreadSettings, +} + #[derive( Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, )] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a3a7910884..8ce55410a1 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -140,6 +140,7 @@ Example with notification opt-out: - `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`. - `thread/turns/items/list` — experimental; reserved for paging full items for one turn. The API shape is present, but app-server currently returns an unsupported-method JSON-RPC error. - `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`. +- `thread/settings/update` — experimental; queue a partial update to a loaded thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings. - `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success. - `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success. - `thread/goal/set` — create or update the single persisted goal for a materialized thread; returns the current goal and emits `thread/goal/updated`. @@ -147,6 +148,7 @@ Example with notification opt-out: - `thread/goal/clear` — clear the current persisted goal for a materialized thread; returns whether a goal was removed and emits `thread/goal/cleared` when state changes. - `thread/goal/updated` — notification emitted whenever a thread goal changes; includes the full current goal. - `thread/goal/cleared` — notification emitted whenever a thread goal is removed. +- `thread/settings/updated` — experimental notification emitted to subscribed clients when a loaded thread’s effective next-turn settings change; includes `threadId` and the full `threadSettings`. - `thread/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`). - `thread/archive` — move a thread’s rollout file into the archived directory and attempt to move any spawned descendant thread rollout files; returns `{}` on success and emits `thread/archived` for each archived thread. - `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server keeps the thread loaded and unloads it only after it has had no subscribers and no thread activity for 30 minutes, then emits `thread/closed`. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 5b137c1091..efe6c69a32 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -4,6 +4,7 @@ use crate::outgoing_message::ClientRequestResult; use crate::outgoing_message::ThreadScopedOutgoingMessageSender; use crate::request_processors::populate_thread_turns_from_history; use crate::request_processors::thread_from_stored_thread; +use crate::request_processors::thread_settings_from_core_snapshot; use crate::server_request_error::is_turn_transition_server_request_error; use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; @@ -60,6 +61,7 @@ use codex_app_server_protocol::ThreadRealtimeStartedNotification; use codex_app_server_protocol::ThreadRealtimeTranscriptDeltaNotification; use codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification; use codex_app_server_protocol::ThreadRollbackResponse; +use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; @@ -1200,6 +1202,24 @@ pub(crate) async fn apply_bespoke_event_handling( )) .await; } + EventMsg::ThreadSettingsApplied(thread_settings_event) => { + let thread_settings = + thread_settings_from_core_snapshot(thread_settings_event.thread_settings); + let changed = { + let mut state = thread_state.lock().await; + state.note_thread_settings(thread_settings.clone()) + }; + if changed { + outgoing + .send_server_notification(ServerNotification::ThreadSettingsUpdated( + ThreadSettingsUpdatedNotification { + thread_id: conversation_id.to_string(), + thread_settings, + }, + )) + .await; + } + } EventMsg::TurnDiff(turn_diff_event) => { handle_turn_diff(conversation_id, &event_turn_id, turn_diff_event, &outgoing).await; } diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index c75c2d5ad1..f7072d0fa1 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -102,7 +102,10 @@ pub const DEFAULT_IN_PROCESS_CHANNEL_CAPACITY: usize = CHANNEL_CAPACITY; type PendingClientRequestResponse = std::result::Result; fn server_notification_requires_delivery(notification: &ServerNotification) -> bool { - matches!(notification, ServerNotification::TurnCompleted(_)) + matches!( + notification, + ServerNotification::TurnCompleted(_) | ServerNotification::ThreadSettingsUpdated(_) + ) } /// Input needed to start an in-process app-server runtime. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 2a15de5666..9c10a33408 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1040,6 +1040,11 @@ impl MessageProcessor { ClientRequest::ThreadMetadataUpdate { params, .. } => { self.thread_processor.thread_metadata_update(params).await } + ClientRequest::ThreadSettingsUpdate { params, .. } => { + self.turn_processor + .thread_settings_update(&request_id, params) + .await + } ClientRequest::ThreadMemoryModeSet { params, .. } => { self.thread_processor.thread_memory_mode_set(params).await } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 157c3c8805..838dd8adf4 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -216,6 +216,9 @@ use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadSetNameResponse; +use codex_app_server_protocol::ThreadSettings; +use codex_app_server_protocol::ThreadSettingsUpdateParams; +use codex_app_server_protocol::ThreadSettingsUpdateResponse; use codex_app_server_protocol::ThreadShellCommandParams; use codex_app_server_protocol::ThreadShellCommandResponse; use codex_app_server_protocol::ThreadSortKey; @@ -353,6 +356,7 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::dynamic_tools::DynamicToolSpec as CoreDynamicToolSpec; @@ -364,6 +368,7 @@ use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort; #[cfg(test)] use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AgentStatus; @@ -517,6 +522,8 @@ pub(crate) use self::thread_processor::thread_from_stored_thread; pub(crate) use self::thread_summary::read_summary_from_rollout; #[cfg(test)] pub(crate) use self::thread_summary::summary_to_thread; +pub(crate) use self::thread_summary::thread_settings_from_config_snapshot; +pub(crate) use self::thread_summary::thread_settings_from_core_snapshot; pub(crate) fn build_api_turns_from_rollout_items(items: &[RolloutItem]) -> Vec { let mut builder = ThreadHistoryBuilder::new(); diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index c4d5fa6c96..985baac91a 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -237,12 +237,19 @@ pub(super) async fn ensure_listener_task_running( &environments, ) .await; + let thread_settings_baseline = + thread_settings_from_config_snapshot(&conversation.config_snapshot().await); let (mut listener_command_rx, listener_generation) = { let mut thread_state = thread_state.lock().await; if thread_state.listener_matches(&conversation) { return Ok(()); } - thread_state.set_listener(cancel_tx, &conversation, watch_registration) + thread_state.set_listener( + cancel_tx, + &conversation, + watch_registration, + thread_settings_baseline, + ) }; let ListenerTaskContext { outgoing, 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 fea49b1637..0a6c1bfb0f 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -169,13 +169,13 @@ pub(super) fn with_thread_spawn_agent_metadata( } } -pub(super) fn thread_response_active_permission_profile( +pub(crate) fn thread_response_active_permission_profile( active_permission_profile: Option, ) -> Option { active_permission_profile.map(Into::into) } -pub(super) fn thread_response_sandbox_policy( +pub(crate) fn thread_response_sandbox_policy( permission_profile: &codex_protocol::models::PermissionProfile, cwd: &Path, ) -> codex_app_server_protocol::SandboxPolicy { @@ -189,6 +189,54 @@ pub(super) fn thread_response_sandbox_policy( sandbox_policy.into() } +pub(crate) fn thread_settings_from_config_snapshot( + config_snapshot: &ThreadConfigSnapshot, +) -> ThreadSettings { + ThreadSettings { + cwd: config_snapshot.cwd.clone(), + approval_policy: config_snapshot.approval_policy.into(), + approvals_reviewer: config_snapshot.approvals_reviewer.into(), + sandbox_policy: thread_response_sandbox_policy( + &config_snapshot.permission_profile, + config_snapshot.cwd.as_path(), + ), + active_permission_profile: thread_response_active_permission_profile( + config_snapshot.active_permission_profile.clone(), + ), + model: config_snapshot.model.clone(), + model_provider: config_snapshot.model_provider_id.clone(), + service_tier: config_snapshot.service_tier.clone(), + effort: config_snapshot.reasoning_effort, + summary: config_snapshot.reasoning_summary, + collaboration_mode: config_snapshot.collaboration_mode.clone(), + personality: config_snapshot.personality, + } +} + +pub(crate) fn thread_settings_from_core_snapshot( + snapshot: codex_protocol::protocol::ThreadSettingsSnapshot, +) -> ThreadSettings { + ThreadSettings { + sandbox_policy: thread_response_sandbox_policy( + &snapshot.permission_profile, + snapshot.cwd.as_path(), + ), + cwd: snapshot.cwd, + approval_policy: snapshot.approval_policy.into(), + approvals_reviewer: snapshot.approvals_reviewer.into(), + active_permission_profile: thread_response_active_permission_profile( + snapshot.active_permission_profile, + ), + model: snapshot.model, + model_provider: snapshot.model_provider_id, + service_tier: snapshot.service_tier, + effort: snapshot.reasoning_effort, + summary: snapshot.reasoning_summary, + collaboration_mode: snapshot.collaboration_mode, + personality: snapshot.personality, + } +} + #[cfg(test)] fn parse_datetime(timestamp: Option<&str>) -> Option> { timestamp.and_then(|ts| { 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 71715e5079..ff2825be67 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -30,6 +30,22 @@ fn resolve_runtime_workspace_roots( resolved_roots } +struct ThreadSettingsBuildParams { + method: &'static str, + cwd: Option, + runtime_workspace_roots: Option>, + approval_policy: Option, + approvals_reviewer: Option, + sandbox_policy: Option, + permissions: Option, + model: Option, + service_tier: Option>, + effort: Option, + summary: Option, + collaboration_mode: Option, + personality: Option, +} + impl TurnRequestProcessor { #[allow(clippy::too_many_arguments)] pub(crate) fn new( @@ -88,6 +104,16 @@ impl TurnRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn thread_settings_update( + &self, + request_id: &ConnectionRequestId, + params: ThreadSettingsUpdateParams, + ) -> Result, JSONRPCErrorError> { + self.thread_settings_update_inner(request_id, params) + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn turn_steer( &self, request_id: &ConnectionRequestId, @@ -199,7 +225,7 @@ impl TurnRequestProcessor { Ok((thread_id, thread)) } - fn normalize_turn_start_collaboration_mode( + fn normalize_collaboration_mode( &self, mut collaboration_mode: CollaborationMode, ) -> CollaborationMode { @@ -357,9 +383,6 @@ impl TurnRequestProcessor { self.track_error_response(&request_id, error, /*error_type*/ None); })?; - let collaboration_mode = params - .collaboration_mode - .map(|mode| self.normalize_turn_start_collaboration_mode(mode)); let environment_selections = self.parse_environment_selections(params.environments)?; // Map v2 input items to core input items. @@ -369,156 +392,26 @@ impl TurnRequestProcessor { .map(V2UserInput::into_core) .collect(); let turn_has_input = !mapped_items.is_empty(); - let runtime_workspace_roots_request = params.runtime_workspace_roots.clone(); - let snapshot = if params.permissions.is_some() || runtime_workspace_roots_request.is_some() - { - Some(thread.config_snapshot().await) - } else { - None - }; - - let has_any_overrides = params.cwd.is_some() - || runtime_workspace_roots_request.is_some() - || params.approval_policy.is_some() - || params.approvals_reviewer.is_some() - || params.sandbox_policy.is_some() - || params.permissions.is_some() - || params.model.is_some() - || params.service_tier.is_some() - || params.effort.is_some() - || params.summary.is_some() - || collaboration_mode.is_some() - || params.personality.is_some(); - - if params.sandbox_policy.is_some() && params.permissions.is_some() { - return Err(invalid_request( - "`permissions` cannot be combined with `sandboxPolicy`", - )); - } - - let cwd = params.cwd; - let runtime_workspace_roots = if let Some(workspace_roots) = - runtime_workspace_roots_request.clone() - { - let Some(snapshot) = snapshot.as_ref() else { - return Err(internal_error( - "turn/start runtime workspace roots missing thread snapshot", - )); - }; - let base_cwd = cwd - .as_ref() - .map(|cwd| AbsolutePathBuf::resolve_path_against_base(cwd, snapshot.cwd.as_path())) - .unwrap_or_else(|| snapshot.cwd.clone()); - Some(resolve_runtime_workspace_roots(workspace_roots, &base_cwd)) - } else { - None - }; - let approval_policy = params.approval_policy.map(AskForApproval::to_core); - let approvals_reviewer = params - .approvals_reviewer - .map(codex_app_server_protocol::ApprovalsReviewer::to_core); - let sandbox_policy = params.sandbox_policy.map(|p| p.to_core()); - let (permission_profile, active_permission_profile, profile_workspace_roots) = - if let Some(permissions) = params.permissions { - let Some(snapshot) = snapshot.as_ref() else { - return Err(internal_error( - "turn/start permission selection missing thread snapshot", - )); - }; - let overrides = ConfigOverrides { - cwd: cwd.clone(), - workspace_roots: Some(runtime_workspace_roots_request.clone().unwrap_or_else( - || { - snapshot - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect() - }, - )), - default_permissions: Some(permissions), - codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), - main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), - ..Default::default() - }; - let config = self - .config_manager - .load_for_cwd( - /*request_overrides*/ None, - overrides, - Some(snapshot.cwd.to_path_buf()), - ) - .await - .map_err(|err| config_load_error(&err))?; - // Startup config is allowed to fall back when requirements - // disallow a configured profile. An explicit turn request - // is different: reject it before accepting user input. - if let Some(warning) = config.startup_warnings.iter().find(|warning| { - warning.contains("Configured value for `permission_profile` is disallowed") - }) { - return Err(invalid_request(format!( - "invalid thread settings override: {warning}" - ))); - } - ( - Some(config.permissions.permission_profile().clone()), - config.permissions.active_permission_profile(), - Some(config.permissions.profile_workspace_roots().to_vec()), - ) - } else { - (None, None, None) - }; - let model = params.model; - let effort = params.effort.map(Some); - let summary = params.summary; - let service_tier = params.service_tier; - let personality = params.personality; - - // If any overrides are provided, validate them synchronously so the - // request can fail before accepting user input. The actual update is - // still queued together with the input below to preserve submission order. - if has_any_overrides { - thread - .preview_thread_settings_overrides(CodexThreadSettingsOverrides { - cwd: cwd.clone(), - workspace_roots: runtime_workspace_roots.clone(), - approval_policy, - approvals_reviewer, - sandbox_policy: sandbox_policy.clone(), - permission_profile: permission_profile.clone(), - active_permission_profile: active_permission_profile.clone(), - profile_workspace_roots: profile_workspace_roots.clone(), - windows_sandbox_level: None, - model: model.clone(), - effort, - summary, - service_tier: service_tier.clone(), - collaboration_mode: collaboration_mode.clone(), - personality, - }) - .await - .map_err(|err| { - invalid_request(format!("invalid thread settings override: {err}")) - })?; - } - - let thread_settings = codex_protocol::protocol::ThreadSettingsOverrides { - cwd, - workspace_roots: runtime_workspace_roots, - profile_workspace_roots, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - active_permission_profile, - windows_sandbox_level: None, - model, - effort, - summary, - service_tier, - collaboration_mode, - personality, - }; + let thread_settings = self + .build_thread_settings_overrides( + thread.as_ref(), + ThreadSettingsBuildParams { + method: "turn/start", + cwd: params.cwd, + runtime_workspace_roots: params.runtime_workspace_roots, + approval_policy: params.approval_policy, + approvals_reviewer: params.approvals_reviewer, + sandbox_policy: params.sandbox_policy, + permissions: params.permissions, + model: params.model, + service_tier: params.service_tier, + effort: params.effort, + summary: params.summary, + collaboration_mode: params.collaboration_mode, + personality: params.personality, + }, + ) + .await?; // Start the turn by submitting the user input. Return its submission id as turn_id. let turn_op = Op::UserInput { @@ -566,6 +459,215 @@ impl TurnRequestProcessor { Ok(TurnStartResponse { turn }) } + async fn build_thread_settings_overrides( + &self, + thread: &CodexThread, + params: ThreadSettingsBuildParams, + ) -> Result { + let ThreadSettingsBuildParams { + method, + cwd, + runtime_workspace_roots, + approval_policy, + approvals_reviewer, + sandbox_policy, + permissions, + model, + service_tier, + effort, + summary, + collaboration_mode, + personality, + } = params; + + if sandbox_policy.is_some() && permissions.is_some() { + return Err(invalid_request( + "`permissions` cannot be combined with `sandboxPolicy`", + )); + } + + let collaboration_mode = + collaboration_mode.map(|mode| self.normalize_collaboration_mode(mode)); + let runtime_workspace_roots_request = runtime_workspace_roots; + // `thread/settings/update` only acknowledges that the update was queued. + // Clients that send dependent partial updates should wait for + // `thread/settings/updated` or combine the fields in one request. + let snapshot = if permissions.is_some() || runtime_workspace_roots_request.is_some() { + Some(thread.config_snapshot().await) + } else { + None + }; + + let has_any_overrides = cwd.is_some() + || runtime_workspace_roots_request.is_some() + || approval_policy.is_some() + || approvals_reviewer.is_some() + || sandbox_policy.is_some() + || permissions.is_some() + || model.is_some() + || service_tier.is_some() + || effort.is_some() + || summary.is_some() + || collaboration_mode.is_some() + || personality.is_some(); + + let runtime_workspace_roots = if let Some(workspace_roots) = + runtime_workspace_roots_request.clone() + { + let Some(snapshot) = snapshot.as_ref() else { + return Err(internal_error(format!( + "{method} runtime workspace roots missing thread snapshot" + ))); + }; + let base_cwd = cwd + .as_ref() + .map(|cwd| AbsolutePathBuf::resolve_path_against_base(cwd, snapshot.cwd.as_path())) + .unwrap_or_else(|| snapshot.cwd.clone()); + Some(resolve_runtime_workspace_roots(workspace_roots, &base_cwd)) + } else { + None + }; + let approval_policy = + approval_policy.map(codex_app_server_protocol::AskForApproval::to_core); + let approvals_reviewer = + approvals_reviewer.map(codex_app_server_protocol::ApprovalsReviewer::to_core); + let sandbox_policy = sandbox_policy.map(|policy| policy.to_core()); + let (permission_profile, active_permission_profile, profile_workspace_roots) = + if let Some(permissions) = permissions { + let Some(snapshot) = snapshot.as_ref() else { + return Err(internal_error(format!( + "{method} permission selection missing thread snapshot" + ))); + }; + let overrides = ConfigOverrides { + cwd: cwd.clone(), + workspace_roots: Some(runtime_workspace_roots_request.clone().unwrap_or_else( + || { + snapshot + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect() + }, + )), + default_permissions: Some(permissions), + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), + ..Default::default() + }; + let config = self + .config_manager + .load_for_cwd( + /*request_overrides*/ None, + overrides, + Some(snapshot.cwd.to_path_buf()), + ) + .await + .map_err(|err| config_load_error(&err))?; + // Startup config is allowed to fall back when requirements + // disallow a configured profile. An explicit settings update + // is different: reject it before accepting the request. + if let Some(warning) = config.startup_warnings.iter().find(|warning| { + warning.contains("Configured value for `permission_profile` is disallowed") + }) { + return Err(invalid_request(format!( + "invalid thread settings override: {warning}" + ))); + } + ( + Some(config.permissions.permission_profile().clone()), + config.permissions.active_permission_profile(), + Some(config.permissions.profile_workspace_roots().to_vec()), + ) + } else { + (None, None, None) + }; + let effort = effort.map(Some); + + if has_any_overrides { + thread + .preview_thread_settings_overrides(CodexThreadSettingsOverrides { + cwd: cwd.clone(), + workspace_roots: runtime_workspace_roots.clone(), + approval_policy, + approvals_reviewer, + sandbox_policy: sandbox_policy.clone(), + permission_profile: permission_profile.clone(), + active_permission_profile: active_permission_profile.clone(), + profile_workspace_roots: profile_workspace_roots.clone(), + windows_sandbox_level: None, + model: model.clone(), + effort, + summary, + service_tier: service_tier.clone(), + collaboration_mode: collaboration_mode.clone(), + personality, + }) + .await + .map_err(|err| { + invalid_request(format!("invalid thread settings override: {err}")) + })?; + } + + Ok(codex_protocol::protocol::ThreadSettingsOverrides { + cwd, + workspace_roots: runtime_workspace_roots, + profile_workspace_roots, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + active_permission_profile, + windows_sandbox_level: None, + model, + effort, + summary, + service_tier, + collaboration_mode, + personality, + }) + } + + async fn thread_settings_update_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadSettingsUpdateParams, + ) -> Result { + let (_, thread) = self.load_thread(¶ms.thread_id).await?; + let thread_settings = self + .build_thread_settings_overrides( + thread.as_ref(), + ThreadSettingsBuildParams { + method: "thread/settings/update", + cwd: params.cwd, + runtime_workspace_roots: None, + approval_policy: params.approval_policy, + approvals_reviewer: params.approvals_reviewer, + sandbox_policy: params.sandbox_policy, + permissions: params.permissions, + model: params.model, + service_tier: params.service_tier, + effort: params.effort, + summary: params.summary, + collaboration_mode: params.collaboration_mode, + personality: params.personality, + }, + ) + .await?; + + if thread_settings != codex_protocol::protocol::ThreadSettingsOverrides::default() { + self.submit_core_op( + request_id, + thread.as_ref(), + Op::ThreadSettings { thread_settings }, + ) + .await + .map_err(|err| internal_error(format!("failed to update thread settings: {err}")))?; + } + + Ok(ThreadSettingsUpdateResponse {}) + } + async fn thread_inject_items_response_inner( &self, params: ThreadInjectItemsParams, diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 32dfcc325d..7f864265b2 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -3,6 +3,7 @@ use crate::outgoing_message::ConnectionRequestId; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadGoal; use codex_app_server_protocol::ThreadHistoryBuilder; +use codex_app_server_protocol::ThreadSettings; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError; use codex_core::CodexThread; @@ -76,6 +77,7 @@ pub(crate) struct ThreadState { pub(crate) cancel_tx: Option>, pub(crate) experimental_raw_events: bool, pub(crate) listener_generation: u64, + last_thread_settings: Option, listener_command_tx: Option>, current_turn_history: ThreadHistoryBuilder, listener_thread: Option>, @@ -95,11 +97,13 @@ impl ThreadState { cancel_tx: oneshot::Sender<()>, conversation: &Arc, watch_registration: WatchRegistration, + thread_settings_baseline: ThreadSettings, ) -> (mpsc::UnboundedReceiver, u64) { if let Some(previous) = self.cancel_tx.replace(cancel_tx) { let _ = previous.send(()); } self.listener_generation = self.listener_generation.wrapping_add(1); + self.last_thread_settings = Some(thread_settings_baseline); let (listener_command_tx, listener_command_rx) = mpsc::unbounded_channel(); self.listener_command_tx = Some(listener_command_tx); self.listener_thread = Some(Arc::downgrade(conversation)); @@ -143,6 +147,12 @@ impl ThreadState { self.current_turn_history.reset(); } } + + pub(crate) fn note_thread_settings(&mut self, thread_settings: ThreadSettings) -> bool { + let changed = self.last_thread_settings.as_ref() != Some(&thread_settings); + self.last_thread_settings = Some(thread_settings); + changed + } } pub(crate) async fn resolve_server_request_on_thread_listener( @@ -177,6 +187,60 @@ pub(crate) async fn resolve_server_request_on_thread_listener( } } +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::ApprovalsReviewer; + use codex_app_server_protocol::AskForApproval; + use codex_app_server_protocol::SandboxPolicy; + use codex_protocol::config_types::CollaborationMode; + use codex_protocol::config_types::ModeKind; + use codex_protocol::config_types::Settings; + use pretty_assertions::assert_eq; + + #[test] + fn note_thread_settings_reports_only_effective_changes() { + let mut state = ThreadState::default(); + let initial = thread_settings("mock-model"); + let updated = thread_settings("mock-model-2"); + + let results = vec![ + state.note_thread_settings(initial.clone()), + state.note_thread_settings(initial), + state.note_thread_settings(updated.clone()), + state.note_thread_settings(updated), + ]; + + assert_eq!(results, vec![true, false, true, false]); + } + + fn thread_settings(model: &str) -> ThreadSettings { + ThreadSettings { + cwd: AbsolutePathBuf::from_absolute_path("/tmp").expect("absolute path"), + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::ReadOnly { + network_access: false, + }, + active_permission_profile: None, + model: model.to_string(), + model_provider: "mock_provider".to_string(), + service_tier: None, + effort: None, + summary: None, + collaboration_mode: CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: model.to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }, + personality: None, + } + } +} + struct ThreadEntry { state: Arc>, connection_ids: HashSet, diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 115764f822..f9c24c226e 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -89,6 +89,7 @@ use codex_app_server_protocol::ThreadRealtimeStopParams; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadSetNameParams; +use codex_app_server_protocol::ThreadSettingsUpdateParams; use codex_app_server_protocol::ThreadShellCommandParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadTurnsItemsListParams; @@ -444,6 +445,15 @@ impl McpProcess { self.send_request("thread/metadata/update", params).await } + /// Send a `thread/settings/update` JSON-RPC request. + pub async fn send_thread_settings_update_request( + &mut self, + params: ThreadSettingsUpdateParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/settings/update", params).await + } + /// Send a `thread/unsubscribe` JSON-RPC request. pub async fn send_thread_unsubscribe_request( &mut self, 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 4096e3d96f..45b2e45e3e 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -15,6 +15,7 @@ use codex_app_server_protocol::ThreadMemoryMode; use codex_app_server_protocol::ThreadMemoryModeSetParams; use codex_app_server_protocol::ThreadRealtimeStartParams; use codex_app_server_protocol::ThreadRealtimeStartTransport; +use codex_app_server_protocol::ThreadSettingsUpdateParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_protocol::protocol::RealtimeOutputModality; @@ -129,6 +130,40 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result Ok(()) } +#[tokio::test] +async fn thread_settings_update_requires_experimental_api_capability() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + let init = mcp + .initialize_with_capabilities( + default_client_info(), + Some(InitializeCapabilities { + experimental_api: false, + request_attestation: false, + opt_out_notification_methods: None, + }), + ) + .await?; + let JSONRPCMessage::Response(_) = init else { + anyhow::bail!("expected initialize response, got {init:?}"); + }; + + let request_id = mcp + .send_thread_settings_update_request(ThreadSettingsUpdateParams { + thread_id: "thr_123".to_string(), + ..Default::default() + }) + .await?; + let error = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_experimental_capability_error(error, "thread/settings/update"); + Ok(()) +} + #[tokio::test] async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 1c81fe1ed6..92e0f126f3 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -58,6 +58,7 @@ mod thread_name_websocket; mod thread_read; mod thread_resume; mod thread_rollback; +mod thread_settings_update; mod thread_shell_command; mod thread_start; mod thread_status; diff --git a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs new file mode 100644 index 0000000000..1bc3d74274 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs @@ -0,0 +1,400 @@ +use anyhow::Context; +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml; +use app_test_support::write_models_cache; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxPolicy; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadSettingsUpdateParams; +use codex_app_server_protocol::ThreadSettingsUpdateResponse; +use codex_app_server_protocol::ThreadSettingsUpdatedNotification; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_core::test_support::all_model_presets; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use serde_json::Value; +use std::collections::BTreeMap; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn thread_settings_update_emits_notification_and_updates_future_turns() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_final_assistant_message_sse_response("done")?, + ]) + .await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + write_models_cache(codex_home.path())?; + let (model_id, service_tier_id) = service_tier_model_and_tier_id()?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let thread = start_thread(&mut mcp).await?.thread; + + send_thread_settings_update( + &mut mcp, + ThreadSettingsUpdateParams { + thread_id: thread.id.clone(), + model: Some(model_id.clone()), + service_tier: Some(Some(service_tier_id.clone())), + ..Default::default() + }, + ) + .await?; + assert!( + received_response_bodies(&server).await?.is_empty(), + "settings-only update should not start a model request" + ); + + start_text_turn(&mut mcp, thread.id.clone()).await?; + + let updated = read_thread_settings_updated(&mut mcp).await?; + assert_eq!(updated.thread_id, thread.id); + assert_eq!(updated.thread_settings.model, model_id); + assert_eq!( + updated.thread_settings.service_tier.as_deref(), + Some(service_tier_id.as_str()) + ); + + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let read = read_thread_with_turns(&mut mcp, &thread.id).await?; + assert_eq!(read.thread.turns.len(), 1); + + let request_bodies = received_response_bodies(&server).await?; + assert!( + request_bodies.iter().any(|body| { + body.get("model").and_then(Value::as_str) == Some(model_id.as_str()) + && body.get("service_tier").and_then(Value::as_str) + == Some(service_tier_id.as_str()) + }), + "future turn did not use updated model/service tier: {request_bodies:#?}" + ); + Ok(()) +} + +#[tokio::test] +async fn thread_settings_update_while_turn_is_active_emits_notification() -> Result<()> { + let server = responses::start_mock_server().await; + let first_response = + responses::sse_response(create_final_assistant_message_sse_response("first done")?) + .set_delay(Duration::from_secs(2)); + let _requests = responses::mount_response_sequence(&server, vec![first_response]).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let thread = start_thread(&mut mcp).await?.thread; + start_text_turn(&mut mcp, thread.id.clone()).await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + + send_thread_settings_update( + &mut mcp, + ThreadSettingsUpdateParams { + thread_id: thread.id.clone(), + model: Some("mock-model-4".to_string()), + ..Default::default() + }, + ) + .await?; + + let updated = read_thread_settings_updated(&mut mcp).await?; + assert_eq!(updated.thread_id, thread.id); + assert_eq!(updated.thread_settings.model, "mock-model-4"); + + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + Ok(()) +} + +#[tokio::test] +async fn thread_settings_update_clears_service_tier() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_final_assistant_message_sse_response("done")?, + ]) + .await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + write_models_cache(codex_home.path())?; + let (model_id, service_tier_id) = service_tier_model_and_tier_id()?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let thread = start_thread(&mut mcp).await?.thread; + + send_thread_settings_update( + &mut mcp, + ThreadSettingsUpdateParams { + thread_id: thread.id.clone(), + model: Some(model_id.clone()), + service_tier: Some(Some(service_tier_id.clone())), + ..Default::default() + }, + ) + .await?; + + let set_updated = read_thread_settings_updated(&mut mcp).await?; + assert_eq!(set_updated.thread_id, thread.id); + assert_eq!( + set_updated.thread_settings.service_tier.as_deref(), + Some(service_tier_id.as_str()) + ); + + send_thread_settings_update( + &mut mcp, + ThreadSettingsUpdateParams { + thread_id: thread.id.clone(), + service_tier: Some(None), + ..Default::default() + }, + ) + .await?; + + let clear_updated = read_thread_settings_updated(&mut mcp).await?; + assert_eq!(clear_updated.thread_id, thread.id); + assert_eq!(clear_updated.thread_settings.model, model_id); + assert_eq!(clear_updated.thread_settings.service_tier, None); + + start_text_turn(&mut mcp, thread.id).await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request_bodies = received_response_bodies(&server).await?; + assert!( + request_bodies.iter().any(|body| { + body.get("model").and_then(Value::as_str) == Some(model_id.as_str()) + && body + .as_object() + .is_some_and(|object| !object.contains_key("service_tier")) + }), + "future turn did not clear service tier: {request_bodies:#?}" + ); + Ok(()) +} + +#[tokio::test] +async fn thread_settings_update_rejects_sandbox_policy_with_permissions() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let thread = start_thread(&mut mcp).await?.thread; + + let request_id = mcp + .send_thread_settings_update_request(ThreadSettingsUpdateParams { + thread_id: thread.id, + sandbox_policy: Some(SandboxPolicy::DangerFullAccess), + permissions: Some(":workspace".to_string()), + ..Default::default() + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!( + error.error.message, + "`permissions` cannot be combined with `sandboxPolicy`" + ); + Ok(()) +} + +#[tokio::test] +async fn turn_start_settings_override_emits_thread_settings_updated() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_final_assistant_message_sse_response("done")?, + ]) + .await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let thread = start_thread(&mut mcp).await?.thread; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("thread/started"), + ) + .await??; + + let turn_request_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model-3".to_string()), + ..Default::default() + }) + .await?; + let turn_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_request_id)), + ) + .await??; + let TurnStartResponse { turn } = to_response(turn_response)?; + assert!(!turn.id.is_empty()); + + let updated = read_thread_settings_updated(&mut mcp).await?; + assert_eq!(updated.thread_id, thread.id); + assert_eq!(updated.thread_settings.model, "mock-model-3"); + + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + Ok(()) +} + +async fn send_thread_settings_update( + mcp: &mut McpProcess, + params: ThreadSettingsUpdateParams, +) -> Result<()> { + let request_id = mcp.send_thread_settings_update_request(params).await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: ThreadSettingsUpdateResponse = to_response(response)?; + Ok(()) +} + +async fn start_text_turn(mcp: &mut McpProcess, thread_id: String) -> Result<()> { + let turn_request_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_request_id)), + ) + .await??; + let TurnStartResponse { turn } = to_response(turn_response)?; + assert!(!turn.id.is_empty()); + Ok(()) +} + +async fn start_thread(mcp: &mut McpProcess) -> Result { + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} + +async fn read_thread_with_turns( + mcp: &mut McpProcess, + thread_id: &str, +) -> Result { + let request_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread_id.to_string(), + include_turns: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} + +async fn read_thread_settings_updated( + mcp: &mut McpProcess, +) -> Result { + let notification: JSONRPCNotification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("thread/settings/updated"), + ) + .await??; + let params = notification + .params + .context("thread/settings/updated should include params")?; + Ok(serde_json::from_value(params)?) +} + +async fn received_response_bodies(server: &wiremock::MockServer) -> Result> { + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + let mut bodies = Vec::new(); + for request in requests { + if request.url.path().ends_with("/responses") { + bodies.push(request.body_json::()?); + } + } + Ok(bodies) +} + +fn service_tier_model_and_tier_id() -> Result<(String, String)> { + let model = all_model_presets() + .iter() + .find(|preset| preset.show_in_picker && !preset.service_tiers.is_empty()) + .context("bundled model catalog should include a picker model with service tiers")?; + Ok((model.id.clone(), model.service_tiers[0].id.clone())) +} + +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + write_mock_responses_config_toml( + codex_home, + server_uri, + &BTreeMap::default(), + /*auto_compact_limit*/ 200_000, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + ) +} diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index d535bf8e3d..63791e0d96 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -62,6 +62,9 @@ pub(super) fn server_notification_thread_target( ServerNotification::ThreadGoalCleared(notification) => { Some(notification.thread_id.as_str()) } + ServerNotification::ThreadSettingsUpdated(notification) => { + Some(notification.thread_id.as_str()) + } ServerNotification::TurnStarted(notification) => Some(notification.thread_id.as_str()), ServerNotification::HookStarted(notification) => Some(notification.thread_id.as_str()), ServerNotification::TurnCompleted(notification) => Some(notification.thread_id.as_str()), diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index f0e3efea0e..171b039150 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -217,6 +217,7 @@ impl ChatWidget { | ServerNotification::AccountRateLimitsUpdated(_) | ServerNotification::ThreadStarted(_) | ServerNotification::ThreadStatusChanged(_) + | ServerNotification::ThreadSettingsUpdated(_) | ServerNotification::ThreadArchived(_) | ServerNotification::ThreadUnarchived(_) | ServerNotification::RawResponseItemCompleted(_) From edc48e4612b457f2e6a32a007516fbde6fc5a832 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 20 May 2026 11:05:14 -0700 Subject: [PATCH 45/63] Sync TUI thread settings through app server (#23507) Builds on #23502. ## Why #23502 adds the app-server `thread/settings/update` API and matching `thread/settings/updated` notification. The TUI already lets users change thread-scoped settings such as model, reasoning effort, service tier, approvals, permissions, personality, and collaboration mode, but those updates need to flow through the app server so embedded and connected clients observe the same thread state. This is a rework (simplification) of PR https://github.com/openai/codex/pull/22510. It has the same functionality, but the underlying `thread/settings/update` api is now simpler in that it no longer returns the effective settings as a response. Now, clients receive the effective settings only through the `thread/settings/updated` notification. ## What Changed This updates the TUI to send `thread/settings/update` whenever those thread-scoped settings change and to treat the RPC response as the authoritative acknowledgement. It also routes `thread/settings/updated` notifications back into cached session state and the visible chat widget so active and inactive threads stay in sync after app-server-originated changes. The implementation is kept to the TUI layer: settings conversion and merge logic live under `codex-rs/tui/src/app/thread_settings.rs`, with dispatch/routing hooks in the existing app and chat widget paths. ## Verification I manually tested using `codex app-server --listen unix://` and then launching two copies of the TUI that use the same local app server. I then resumed the same thread on both and verified that changes like plan mode, fast mode, model, reasoning effort, etc. are reflected "live" in the second client when modified in the first and vice versa. --- codex-rs/tui/src/app.rs | 1 + .../tui/src/app/app_server_event_targets.rs | 48 +++ codex-rs/tui/src/app/config_persistence.rs | 2 + codex-rs/tui/src/app/event_dispatch.rs | 8 + codex-rs/tui/src/app/tests.rs | 329 ++++++++++++++++++ codex-rs/tui/src/app/thread_events.rs | 2 + codex-rs/tui/src/app/thread_routing.rs | 17 +- codex-rs/tui/src/app/thread_session_state.rs | 21 +- codex-rs/tui/src/app/thread_settings.rs | 208 +++++++++++ codex-rs/tui/src/app_server_session.rs | 80 +++++ codex-rs/tui/src/chatwidget.rs | 2 + codex-rs/tui/src/chatwidget/input_flow.rs | 2 +- codex-rs/tui/src/chatwidget/protocol.rs | 7 + codex-rs/tui/src/chatwidget/session_flow.rs | 26 +- codex-rs/tui/src/chatwidget/settings.rs | 140 +++++++- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 2 +- .../tui/src/chatwidget/tests/app_server.rs | 151 ++++++++ .../chatwidget/tests/composer_submission.rs | 18 + .../tui/src/chatwidget/tests/exec_flow.rs | 2 + .../src/chatwidget/tests/history_replay.rs | 20 ++ .../tui/src/chatwidget/tests/permissions.rs | 4 + .../tui/src/chatwidget/tests/plan_mode.rs | 4 + .../src/chatwidget/tests/status_and_layout.rs | 2 + codex-rs/tui/src/history_cell/tests.rs | 2 + codex-rs/tui/src/session_state.rs | 4 + 25 files changed, 1086 insertions(+), 16 deletions(-) create mode 100644 codex-rs/tui/src/app/thread_settings.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 0288c9c593..cbdbee0402 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -211,6 +211,7 @@ mod thread_events; mod thread_goal_actions; mod thread_routing; mod thread_session_state; +mod thread_settings; use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index 63791e0d96..0b2143b429 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -178,12 +178,46 @@ pub(super) fn server_notification_thread_target( mod tests { use super::ServerNotificationThreadTarget; use super::server_notification_thread_target; + use crate::test_support::PathBufExt; + use crate::test_support::test_path_buf; use codex_app_server_protocol::GuardianWarningNotification; use codex_app_server_protocol::ServerNotification; + use codex_app_server_protocol::ThreadSettings; + use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::WarningNotification; use codex_protocol::ThreadId; + use codex_protocol::config_types::CollaborationMode; + use codex_protocol::config_types::ModeKind; + use codex_protocol::config_types::Settings; + use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; + fn test_thread_settings() -> ThreadSettings { + ThreadSettings { + cwd: test_path_buf("/tmp/thread-settings").abs(), + approval_policy: codex_app_server_protocol::AskForApproval::Never, + approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User, + sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly { + network_access: false, + }, + active_permission_profile: None, + model: "gpt-5.4".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + effort: Some(ReasoningEffort::High), + summary: None, + collaboration_mode: CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: "gpt-5.4".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: None, + }, + }, + personality: None, + } + } + #[test] fn warning_notifications_without_threads_are_global() { let notification = ServerNotification::Warning(WarningNotification { @@ -221,4 +255,18 @@ mod tests { assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id)); } + + #[test] + fn thread_settings_updated_notifications_route_to_threads() { + let thread_id = ThreadId::new(); + let notification = + ServerNotification::ThreadSettingsUpdated(ThreadSettingsUpdatedNotification { + thread_id: thread_id.to_string(), + thread_settings: test_thread_settings(), + }); + + let target = server_notification_thread_target(¬ification); + + assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id)); + } } diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 5b2b5ab23a..1f594d64ce 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -692,6 +692,8 @@ mod tests { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index c1cd816b0d..ce0eb0ff93 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -760,12 +760,18 @@ impl App { } AppEvent::UpdateReasoningEffort(effort) => { self.on_update_reasoning_effort(effort); + self.sync_active_thread_reasoning_setting(app_server, effort) + .await; } AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); + self.sync_active_thread_model_setting(app_server, model) + .await; } AppEvent::UpdatePersonality(personality) => { self.on_update_personality(personality); + self.sync_active_thread_personality_setting(app_server, personality) + .await; } AppEvent::OpenRealtimeAudioDeviceSelection { kind } => { self.chat_widget.open_realtime_audio_device_selection(kind); @@ -1548,6 +1554,8 @@ impl App { AppEvent::UpdatePlanModeReasoningEffort(effort) => { self.config.plan_mode_reasoning_effort = effort; self.chat_widget.set_plan_mode_reasoning_effort(effort); + self.sync_active_thread_plan_mode_reasoning_setting(app_server) + .await; } AppEvent::PersistFullAccessWarningAcknowledged => { if let Err(err) = ConfigEditsBuilder::for_config(&self.config) diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 8d52500af6..0a2f382a8a 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -59,6 +59,8 @@ use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadClosedNotification; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadSettings; +use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; @@ -77,8 +79,10 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Settings; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; @@ -108,6 +112,29 @@ fn test_absolute_path(path: &str) -> AbsolutePathBuf { AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path") } +async fn next_thread_settings_updated( + app_server: &mut AppServerSession, + thread_id: ThreadId, +) -> ThreadSettingsUpdatedNotification { + for _ in 0..20 { + let event = time::timeout( + std::time::Duration::from_secs(/*secs*/ 2), + app_server.next_event(), + ) + .await + .expect("app-server should emit an event") + .expect("app-server event stream should remain open"); + if let codex_app_server_client::AppServerEvent::ServerNotification( + ServerNotification::ThreadSettingsUpdated(notification), + ) = event + && notification.thread_id == thread_id.to_string() + { + return notification; + } + } + panic!("expected ThreadSettingsUpdated for thread {thread_id}"); +} + #[tokio::test] async fn handle_mcp_inventory_result_clears_committed_loading_cell() { let mut app = make_test_app().await; @@ -3721,6 +3748,8 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::High), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), @@ -3972,6 +4001,8 @@ fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), @@ -4548,6 +4579,8 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), @@ -4612,6 +4645,8 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), @@ -4705,6 +4740,8 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), @@ -5105,6 +5142,8 @@ async fn new_session_requests_shutdown_for_previous_conversation() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), @@ -5206,6 +5245,294 @@ async fn interrupt_without_active_turn_is_treated_as_handled() { .await; } +#[tokio::test] +async fn override_turn_context_sends_thread_settings_update() { + Box::pin(async { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("thread/start should succeed"); + let thread_id = started.session.thread_id; + let initial_model = started.session.model.clone(); + let initial_effort = started.session.reasoning_effort; + app.enqueue_primary_thread_session(started.session, started.turns) + .await + .expect("primary thread should be registered"); + let service_tier = ServiceTier::Fast.request_value().to_string(); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: "gpt-5.4".to_string(), + reasoning_effort: Some(ReasoningEffortConfig::High), + developer_instructions: None, + }, + }; + let op = AppCommand::override_turn_context( + /*cwd*/ None, + Some(AskForApproval::OnRequest), + Some(ApprovalsReviewer::AutoReview), + Some(ActivePermissionProfile::new( + codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + )), + /*windows_sandbox_level*/ None, + Some("gpt-5.4".to_string()), + Some(Some(ReasoningEffortConfig::High)), + /*summary*/ None, + Some(Some(service_tier.clone())), + Some(collaboration_mode.clone()), + Some(Personality::Pragmatic), + ); + + let handled = app + .try_submit_active_thread_op_via_app_server(&mut app_server, thread_id, &op) + .await + .expect("settings update submission should not fail"); + + assert_eq!(handled, true); + assert_eq!( + app.primary_session_configured + .as_ref() + .expect("primary session") + .model, + initial_model, + "thread/settings/update response is only an ack; cached state changes on notification" + ); + + let notification = next_thread_settings_updated(&mut app_server, thread_id).await; + assert_eq!(notification.thread_settings.model, "gpt-5.4"); + assert_eq!( + notification.thread_settings.effort, + Some(ReasoningEffortConfig::High) + ); + assert_eq!( + notification.thread_settings.service_tier, + Some(service_tier.clone()) + ); + assert_eq!( + notification.thread_settings.approval_policy, + AskForApproval::OnRequest + ); + assert_eq!( + notification.thread_settings.approvals_reviewer.to_core(), + ApprovalsReviewer::AutoReview + ); + let notified_mode = ¬ification.thread_settings.collaboration_mode; + assert_eq!(notified_mode.mode, collaboration_mode.mode); + assert_eq!( + notified_mode.settings.model, + collaboration_mode.settings.model + ); + assert_eq!( + notified_mode.settings.reasoning_effort, + collaboration_mode.settings.reasoning_effort + ); + assert_eq!( + notification.thread_settings.personality, + Some(Personality::Pragmatic) + ); + + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerNotification( + ServerNotification::ThreadSettingsUpdated(notification), + ), + ) + .await; + let updated_session = app + .primary_session_configured + .as_ref() + .expect("primary session should be updated from notification"); + assert_eq!(updated_session.model, initial_model); + assert_eq!(updated_session.reasoning_effort, initial_effort); + let updated_mode = updated_session + .collaboration_mode + .as_deref() + .expect("collaboration mode should be cached"); + assert_eq!(updated_mode.mode, collaboration_mode.mode); + assert_eq!( + updated_mode.settings.model, + collaboration_mode.settings.model + ); + assert_eq!( + updated_mode.settings.reasoning_effort, + collaboration_mode.settings.reasoning_effort + ); + assert_eq!(updated_session.personality, Some(Personality::Pragmatic)); + assert_eq!(updated_session.service_tier, Some(service_tier)); + assert_eq!(updated_session.approval_policy, AskForApproval::OnRequest); + assert_eq!( + updated_session.approvals_reviewer, + ApprovalsReviewer::AutoReview + ); + assert_eq!( + updated_session + .active_permission_profile + .as_ref() + .expect("active profile") + .id, + codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE + ); + }) + .await; +} + +#[tokio::test] +async fn thread_setting_update_params_sync_model_and_default_reasoning() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + + app.chat_widget.set_model("gpt-5.4"); + let params = app + .active_thread_model_setting_update_params("gpt-5.4".to_string()) + .expect("active thread should produce update params"); + + assert_eq!(params.thread_id, thread_id.to_string()); + assert_eq!(params.model, Some("gpt-5.4".to_string())); + assert_eq!( + params + .collaboration_mode + .as_ref() + .expect("collaboration mode should sync with model") + .settings + .model, + "gpt-5.4" + ); + + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::Low)); + app.chat_widget + .set_collaboration_mask(CollaborationModeMask { + name: "Plan".to_string(), + mode: Some(ModeKind::Plan), + model: Some("gpt-plan".to_string()), + reasoning_effort: Some(Some(ReasoningEffortConfig::Medium)), + developer_instructions: None, + }); + app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); + + let params = app + .active_thread_reasoning_setting_update_params(Some(ReasoningEffortConfig::High)) + .expect("active thread should produce update params"); + + assert_eq!(params.thread_id, thread_id.to_string()); + assert_eq!(params.effort, Some(ReasoningEffortConfig::High)); + let collaboration_mode = params + .collaboration_mode + .expect("collaboration mode should sync with reasoning"); + assert_eq!(collaboration_mode.mode, ModeKind::Default); + assert_eq!( + collaboration_mode.settings.reasoning_effort, + Some(ReasoningEffortConfig::High) + ); +} + +#[tokio::test] +async fn inactive_thread_settings_notification_updates_cached_collaboration_mode() { + let mut app = make_test_app().await; + let primary_thread_id = ThreadId::new(); + let inactive_thread_id = ThreadId::new(); + let primary_session = test_thread_session(primary_thread_id, test_path_buf("/tmp/main")); + let inactive_session = test_thread_session(inactive_thread_id, test_path_buf("/tmp/inactive")); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: "gpt-plan".to_string(), + reasoning_effort: Some(ReasoningEffortConfig::High), + developer_instructions: Some("draft a plan first".to_string()), + }, + }; + + app.primary_thread_id = Some(primary_thread_id); + app.active_thread_id = Some(primary_thread_id); + app.primary_session_configured = Some(primary_session.clone()); + app.thread_event_channels.insert( + primary_thread_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + primary_session, + Vec::new(), + ), + ); + app.thread_event_channels.insert( + inactive_thread_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + inactive_session, + Vec::new(), + ), + ); + + let notification = ThreadSettingsUpdatedNotification { + thread_id: inactive_thread_id.to_string(), + thread_settings: ThreadSettings { + cwd: test_absolute_path("/tmp/thread-settings"), + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview, + sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly { + network_access: false, + }, + active_permission_profile: Some( + codex_app_server_protocol::ActivePermissionProfile::read_only(), + ), + model: "gpt-plan".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + effort: collaboration_mode.settings.reasoning_effort, + summary: None, + collaboration_mode: collaboration_mode.clone(), + personality: Some(Personality::Pragmatic), + }, + }; + app.enqueue_thread_notification( + inactive_thread_id, + ServerNotification::ThreadSettingsUpdated(notification), + ) + .await + .expect("settings notification should be cached"); + + let cached_session = app + .thread_event_channels + .get(&inactive_thread_id) + .expect("inactive thread channel") + .store + .lock() + .await + .session + .clone() + .expect("inactive session should remain cached"); + assert_eq!(cached_session.model, "gpt-test"); + assert_eq!(cached_session.personality, Some(Personality::Pragmatic)); + assert_eq!( + cached_session.collaboration_mode.as_deref(), + Some(&collaboration_mode) + ); + + app.chat_widget.handle_thread_session(cached_session); + assert_eq!( + app.chat_widget.active_collaboration_mode_kind(), + ModeKind::Plan + ); + assert_eq!(app.chat_widget.current_model(), "gpt-plan"); + assert_eq!( + app.chat_widget.current_collaboration_mode().model(), + "gpt-test" + ); + assert_eq!( + app.chat_widget.current_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); + assert_eq!( + app.chat_widget.config_ref().personality, + Some(Personality::Pragmatic) + ); +} + #[tokio::test] async fn clear_only_ui_reset_preserves_chat_session_state() { let mut app = make_test_app().await; @@ -5227,6 +5554,8 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 30f68dc640..b0c9e7e1be 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -355,6 +355,8 @@ mod tests { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index d270bb53ce..2c2baf512d 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -685,7 +685,11 @@ impl App { self.refresh_in_memory_config_from_disk().await?; Ok(true) } - AppCommand::OverrideTurnContext { .. } => Ok(true), + AppCommand::OverrideTurnContext { .. } => { + self.sync_override_turn_context_settings(app_server, thread_id, op) + .await; + Ok(true) + } AppCommand::ApproveGuardianDeniedAction { event } => { app_server .thread_approve_guardian_denied_action(thread_id, event) @@ -825,6 +829,17 @@ impl App { thread_id: ThreadId, notification: ServerNotification, ) -> Result<()> { + if matches!(notification, ServerNotification::ThreadSettingsUpdated(_)) + && self.primary_thread_id.is_some() + && self.primary_thread_id != Some(thread_id) + && !self.thread_event_channels.contains_key(&thread_id) + { + return Ok(()); + } + if let ServerNotification::ThreadSettingsUpdated(notification) = ¬ification { + self.apply_thread_settings_to_cached_session(thread_id, ¬ification.thread_settings) + .await; + } let inferred_session = self .infer_session_for_thread_notification(thread_id, ¬ification) .await; diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 4e7f0c6741..80aa54efbd 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -78,10 +78,16 @@ impl App { ) -> ThreadSessionState { let permission_profile = self.current_permission_profile(); let active_permission_profile = self.current_active_permission_profile(); - let mut session = self - .primary_session_configured - .clone() - .unwrap_or(ThreadSessionState { + let mut session = if let Some(mut session) = self.primary_session_configured.clone() { + if session.thread_id != thread_id { + // `thread/read` does not include thread settings, so do not carry + // thread-scoped state from the currently active session. + session.collaboration_mode = None; + session.personality = None; + } + session + } else { + ThreadSessionState { thread_id, forked_from_id: None, fork_parent_title: None, @@ -99,10 +105,13 @@ impl App { runtime_workspace_roots: self.config.workspace_roots.clone(), instruction_source_paths: Vec::new(), reasoning_effort: self.chat_widget.current_reasoning_effort(), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: thread.path.clone(), - }); + } + }; session.thread_id = thread_id; session.thread_name = thread.name.clone(); session.model_provider_id = thread.model_provider.clone(); @@ -178,6 +187,8 @@ mod tests { runtime_workspace_roots: vec![cwd.abs()], instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), diff --git a/codex-rs/tui/src/app/thread_settings.rs b/codex-rs/tui/src/app/thread_settings.rs new file mode 100644 index 0000000000..bf92827879 --- /dev/null +++ b/codex-rs/tui/src/app/thread_settings.rs @@ -0,0 +1,208 @@ +//! Thread settings sync between TUI-local state and app-server thread state. + +use super::App; +use crate::app_command::AppCommand; +use crate::app_server_session::AppServerSession; +use crate::session_state::ThreadSessionState; +use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; +use codex_app_server_protocol::ThreadSettings; +use codex_app_server_protocol::ThreadSettingsUpdateParams; +use codex_protocol::ThreadId; +use codex_protocol::config_types::ModeKind; +use codex_protocol::models::PermissionProfile; + +impl App { + pub(super) async fn sync_active_thread_model_setting( + &mut self, + app_server: &mut AppServerSession, + model: String, + ) { + let Some(params) = self.active_thread_model_setting_update_params(model) else { + return; + }; + self.send_thread_settings_update(app_server, params).await; + } + + pub(super) fn active_thread_model_setting_update_params( + &self, + model: String, + ) -> Option { + let thread_id = self.active_thread_id?; + Some(ThreadSettingsUpdateParams { + thread_id: thread_id.to_string(), + model: Some(model), + collaboration_mode: Some(self.chat_widget.effective_collaboration_mode()), + ..ThreadSettingsUpdateParams::default() + }) + } + + pub(super) async fn sync_active_thread_reasoning_setting( + &mut self, + app_server: &mut AppServerSession, + effort: Option, + ) { + let Some(params) = self.active_thread_reasoning_setting_update_params(effort) else { + return; + }; + self.send_thread_settings_update(app_server, params).await; + } + + pub(super) fn active_thread_reasoning_setting_update_params( + &self, + effort: Option, + ) -> Option { + let thread_id = self.active_thread_id?; + Some(ThreadSettingsUpdateParams { + thread_id: thread_id.to_string(), + effort, + collaboration_mode: Some(self.chat_widget.current_collaboration_mode().clone()), + ..ThreadSettingsUpdateParams::default() + }) + } + + pub(super) async fn sync_active_thread_plan_mode_reasoning_setting( + &mut self, + app_server: &mut AppServerSession, + ) { + let Some(thread_id) = self.active_thread_id else { + return; + }; + let params = ThreadSettingsUpdateParams { + thread_id: thread_id.to_string(), + collaboration_mode: Some(self.chat_widget.effective_collaboration_mode()), + ..ThreadSettingsUpdateParams::default() + }; + self.send_thread_settings_update(app_server, params).await; + } + + pub(super) async fn sync_active_thread_personality_setting( + &mut self, + app_server: &mut AppServerSession, + personality: codex_protocol::config_types::Personality, + ) { + let Some(thread_id) = self.active_thread_id else { + return; + }; + let params = ThreadSettingsUpdateParams { + thread_id: thread_id.to_string(), + personality: Some(personality), + ..ThreadSettingsUpdateParams::default() + }; + self.send_thread_settings_update(app_server, params).await; + } + + pub(super) async fn sync_override_turn_context_settings( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + op: &AppCommand, + ) { + let AppCommand::OverrideTurnContext { + cwd, + approval_policy, + approvals_reviewer, + active_permission_profile, + windows_sandbox_level: _, + model, + effort, + summary, + service_tier, + collaboration_mode, + personality, + } = op + else { + return; + }; + + let params = ThreadSettingsUpdateParams { + thread_id: thread_id.to_string(), + cwd: cwd.clone(), + approval_policy: *approval_policy, + approvals_reviewer: approvals_reviewer.map(AppServerApprovalsReviewer::from), + permissions: active_permission_profile + .as_ref() + .map(|profile| profile.id.clone()), + model: model.clone(), + effort: effort.unwrap_or_default(), + summary: *summary, + service_tier: service_tier.clone(), + collaboration_mode: collaboration_mode.clone(), + personality: *personality, + ..ThreadSettingsUpdateParams::default() + }; + self.send_thread_settings_update(app_server, params).await; + } + + pub(super) async fn apply_thread_settings_to_cached_session( + &mut self, + thread_id: ThreadId, + settings: &ThreadSettings, + ) { + if self.primary_thread_id == Some(thread_id) + && let Some(session) = self.primary_session_configured.as_mut() + { + apply_thread_settings_to_session(session, settings); + } + + if let Some(channel) = self.thread_event_channels.get(&thread_id) { + let mut store = channel.store.lock().await; + if let Some(session) = store.session.as_mut() { + apply_thread_settings_to_session(session, settings); + } + } + } + + async fn send_thread_settings_update( + &mut self, + app_server: &mut AppServerSession, + params: ThreadSettingsUpdateParams, + ) { + if !thread_settings_update_has_changes(¶ms) { + return; + } + if let Err(err) = app_server.thread_settings_update(params).await { + tracing::warn!("failed to update app-server thread settings from TUI: {err}"); + self.chat_widget + .add_error_message(format!("Failed to update thread settings: {err}")); + } + } +} + +fn apply_thread_settings_to_session(session: &mut ThreadSessionState, settings: &ThreadSettings) { + if settings.collaboration_mode.mode == ModeKind::Default { + session.model = settings.model.clone(); + session.reasoning_effort = settings.effort; + } + session.model_provider_id = settings.model_provider.clone(); + session.service_tier = settings.service_tier.clone(); + session.approval_policy = settings.approval_policy; + session.approvals_reviewer = settings.approvals_reviewer.to_core(); + session.permission_profile = PermissionProfile::from_legacy_sandbox_policy_for_cwd( + &settings.sandbox_policy.to_core(), + settings.cwd.as_path(), + ); + session.active_permission_profile = settings.active_permission_profile.clone().map(Into::into); + session.set_cwd_retargeting_implicit_runtime_workspace_root(settings.cwd.clone()); + session.personality = settings.personality; + let mut collaboration_mode = settings.collaboration_mode.clone(); + collaboration_mode + .settings + .model + .clone_from(&settings.model); + collaboration_mode.settings.reasoning_effort = settings.effort; + session.collaboration_mode = Some(Box::new(collaboration_mode)); +} + +fn thread_settings_update_has_changes(params: &ThreadSettingsUpdateParams) -> bool { + params.cwd.is_some() + || params.approval_policy.is_some() + || params.approvals_reviewer.is_some() + || params.sandbox_policy.is_some() + || params.permissions.is_some() + || params.model.is_some() + || params.service_tier.is_some() + || params.effort.is_some() + || params.summary.is_some() + || params.collaboration_mode.is_some() + || params.personality.is_some() +} diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 51029a6dee..9eca24e051 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -86,6 +86,8 @@ use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadSetNameResponse; +use codex_app_server_protocol::ThreadSettingsUpdateParams; +use codex_app_server_protocol::ThreadSettingsUpdateResponse; use codex_app_server_protocol::ThreadShellCommandParams; use codex_app_server_protocol::ThreadShellCommandResponse; use codex_app_server_protocol::ThreadSource; @@ -121,10 +123,20 @@ use std::collections::HashMap; use std::path::PathBuf; use uuid::Uuid; +const JSONRPC_INVALID_REQUEST: i64 = -32600; +const JSONRPC_METHOD_NOT_FOUND: i64 = -32601; +const THREAD_SETTINGS_UPDATE_METHOD: &str = "thread/settings/update"; + fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report { color_eyre::eyre::eyre!("{context}: {err}") } +fn is_thread_settings_update_unsupported(source: &JSONRPCErrorError) -> bool { + source.code == JSONRPC_METHOD_NOT_FOUND + || (source.code == JSONRPC_INVALID_REQUEST + && source.message.contains(THREAD_SETTINGS_UPDATE_METHOD)) +} + /// Data collected during the TUI bootstrap phase that the main event loop /// needs to configure the UI, telemetry, and initial rate-limit prefetch. /// @@ -151,6 +163,7 @@ pub(crate) struct AppServerSession { next_request_id: i64, remote_cwd_override: Option, thread_params_mode: ThreadParamsMode, + thread_settings_update_supported: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -191,6 +204,7 @@ impl AppServerSession { next_request_id: 1, remote_cwd_override: None, thread_params_mode, + thread_settings_update_supported: true, } } @@ -528,6 +542,39 @@ impl AppServerSession { .wrap_err("thread/metadata/update failed while syncing git branch") } + pub(crate) async fn thread_settings_update( + &mut self, + params: ThreadSettingsUpdateParams, + ) -> Result<()> { + if !self.thread_settings_update_supported { + return Ok(()); + } + let request_id = self.next_request_id(); + match self + .client + .request_typed::(ClientRequest::ThreadSettingsUpdate { + request_id, + params, + }) + .await + { + Ok(_) => Ok(()), + Err(TypedRequestError::Server { source, .. }) + if is_thread_settings_update_unsupported(&source) => + { + // Older remote app servers can reject this experimental method as + // method-not-found, experimental-capability-gated, or an unknown + // request variant. Treat those as a session-level capability + // downgrade so local TUI setting changes stay best-effort instead + // of showing an error every time the user changes model, effort, + // personality, or mode. + self.thread_settings_update_supported = false; + Ok(()) + } + Err(err) => Err(err).wrap_err("thread/settings/update failed in TUI"), + } + } + pub(crate) async fn thread_inject_items( &mut self, thread_id: ThreadId, @@ -1599,6 +1646,8 @@ async fn thread_session_state_from_thread_response( runtime_workspace_roots, instruction_source_paths, reasoning_effort, + collaboration_mode: None, + personality: config.personality, message_history: Some(MessageHistoryMetadata { log_id, entry_count, @@ -1700,6 +1749,37 @@ mod tests { ); } + #[test] + fn thread_settings_update_compat_detects_unsupported_errors() { + let cases = [ + (JSONRPC_METHOD_NOT_FOUND, "method not found", true), + ( + JSONRPC_INVALID_REQUEST, + "thread/settings/update requires experimentalApi capability", + true, + ), + ( + JSONRPC_INVALID_REQUEST, + "Invalid request: unknown variant `thread/settings/update`", + true, + ), + (JSONRPC_INVALID_REQUEST, "invalid thread id", false), + ]; + + for (code, message, expected) in cases { + let source = JSONRPCErrorError { + code, + data: None, + message: message.to_string(), + }; + assert_eq!( + is_thread_settings_update_unsupported(&source), + expected, + "{message}" + ); + } + } + #[tokio::test] async fn thread_start_params_include_cwd_for_embedded_sessions() { let temp_dir = tempfile::tempdir().expect("tempdir"); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 240b72ab9d..0ba974d88e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -115,6 +115,8 @@ use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadGoal as AppThreadGoal; use codex_app_server_protocol::ThreadGoalStatus as AppThreadGoalStatus; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadSettings; +use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ToolRequestUserInputParams; use codex_app_server_protocol::Turn; diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index dbc52269a3..3930224e04 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -178,7 +178,7 @@ impl ChatWidget { ); return; } - self.set_collaboration_mask(collaboration_mode); + self.set_collaboration_mask_from_user_action(collaboration_mode); let should_queue = self.is_plan_streaming_in_tui(); let user_message = UserMessage { text, diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index 171b039150..6a980e347c 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -51,6 +51,9 @@ impl ChatWidget { ServerNotification::ThreadGoalCleared(notification) => { self.on_thread_goal_cleared(notification.thread_id.as_str()); } + ServerNotification::ThreadSettingsUpdated(notification) => { + self.on_thread_settings_updated(notification); + } ServerNotification::TurnStarted(notification) => { self.turn_lifecycle.last_turn_id = Some(notification.turn.id); self.last_non_retry_error = None; @@ -247,6 +250,10 @@ impl ChatWidget { notification: TurnCompletedNotification, replay_kind: Option, ) { + // User-message dedupe only suppresses the app-server echo of a prompt + // this TUI already rendered locally. Once that turn ends, another + // client can submit the same text and it still needs its own user cell. + self.last_rendered_user_message_display = None; match notification.turn.status { TurnStatus::Completed => { self.last_non_retry_error = None; diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 9837eaa6ea..01a6b7d578 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -73,18 +73,31 @@ impl ChatWidget { } } self.config.approvals_reviewer = session.approvals_reviewer; + self.config.personality = session.personality; self.status_line_project_root_name_cache = None; let forked_from_id = session.forked_from_id; - let model_for_header = session.model.clone(); - self.session_header.set_model(&model_for_header); + let default_model = session.model.clone(); self.current_collaboration_mode = self.current_collaboration_mode.with_updates( - Some(model_for_header.clone()), + Some(default_model.clone()), Some(session.reasoning_effort), /*developer_instructions*/ None, ); - if let Some(mask) = self.active_collaboration_mask.as_mut() { - mask.model = Some(model_for_header.clone()); - mask.reasoning_effort = Some(session.reasoning_effort); + match session.collaboration_mode.as_deref() { + Some(collaboration_mode) => { + self.set_effective_collaboration_mode(collaboration_mode.clone()); + } + None => { + self.active_collaboration_mask = Self::initial_collaboration_mask( + &self.config, + self.model_catalog.as_ref(), + Some(&default_model), + ); + if let Some(mask) = self.active_collaboration_mask.as_mut() { + mask.reasoning_effort = Some(session.reasoning_effort); + } + self.update_collaboration_mode_indicator(); + self.refresh_plan_mode_nudge(); + } } self.refresh_model_display(); self.refresh_status_surfaces(); @@ -93,6 +106,7 @@ impl ChatWidget { self.sync_plugins_command_enabled(); self.sync_goal_command_enabled(); self.refresh_plugin_mentions(); + let model_for_header = self.current_model().to_string(); if display == SessionConfiguredDisplay::Normal { let startup_tooltip_override = self.startup_tooltip_override.take(); let show_fast_status = self diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 91abc728db..0434f7e386 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -1,6 +1,7 @@ //! Runtime settings state and model/collaboration coordination for `ChatWidget`. use super::*; +use crate::app_event::AppEvent; impl ChatWidget { /// Set the approval policy in the widget's config copy. @@ -345,6 +346,24 @@ impl ChatWidget { self.effective_reasoning_effort() } + pub(crate) fn on_thread_settings_updated( + &mut self, + notification: ThreadSettingsUpdatedNotification, + ) { + let Ok(thread_id) = ThreadId::from_string(¬ification.thread_id) else { + tracing::warn!( + thread_id = notification.thread_id, + "ignoring app-server ThreadSettingsUpdated with invalid thread_id" + ); + return; + }; + if self.thread_id != Some(thread_id) { + return; + } + + self.apply_thread_settings(notification.thread_settings); + } + #[cfg(test)] pub(crate) fn active_collaboration_mode_kind(&self) -> ModeKind { self.active_mode_kind() @@ -430,7 +449,7 @@ impl ChatWidget { .unwrap_or(current_effort) } - pub(super) fn effective_collaboration_mode(&self) -> CollaborationMode { + pub(crate) fn effective_collaboration_mode(&self) -> CollaborationMode { if !self.collaboration_modes_enabled() { return self.current_collaboration_mode.clone(); } @@ -462,6 +481,96 @@ impl ChatWidget { self.refresh_status_line(); } + fn apply_thread_settings(&mut self, mut settings: ThreadSettings) { + let cwd_changed = self.config.cwd != settings.cwd; + self.apply_thread_settings_cwd(settings.cwd.clone()); + self.config.model_provider_id = settings.model_provider.clone(); + self.set_service_tier(settings.service_tier.clone()); + self.set_approval_policy(settings.approval_policy); + self.set_approvals_reviewer(settings.approvals_reviewer.to_core()); + self.config.personality = settings.personality; + + let permission_profile = PermissionProfile::from_legacy_sandbox_policy_for_cwd( + &settings.sandbox_policy.to_core(), + settings.cwd.as_path(), + ); + let permission_snapshot = PermissionProfileSnapshot::from_session_snapshot( + permission_profile, + settings.active_permission_profile.take().map(Into::into), + ); + if let Err(err) = self + .config + .permissions + .set_permission_profile_from_session_snapshot(permission_snapshot.clone()) + { + tracing::warn!(%err, "failed to sync permissions from ThreadSettingsUpdated"); + if let Err(replace_err) = self + .config + .permissions + .replace_permission_profile_from_session_snapshot(permission_snapshot) + { + tracing::error!( + %replace_err, + "failed to replace permissions from ThreadSettingsUpdated after constraint fallback" + ); + } + } + + settings.collaboration_mode.settings.model = settings.model; + settings.collaboration_mode.settings.reasoning_effort = settings.effort; + self.set_effective_collaboration_mode(settings.collaboration_mode); + self.refresh_status_surfaces(); + self.sync_service_tier_commands(); + self.sync_personality_command_enabled(); + if cwd_changed { + self.refresh_skills_for_current_cwd(/*force_reload*/ true); + } + self.refresh_plugin_mentions(); + self.request_redraw(); + } + + fn apply_thread_settings_cwd(&mut self, cwd: AbsolutePathBuf) { + let previous_cwd = std::mem::replace(&mut self.config.cwd, cwd.clone()); + self.current_cwd = Some(cwd.to_path_buf()); + self.status_line_project_root_name_cache = None; + + if !self.config.workspace_roots.contains(&previous_cwd) { + return; + } + + let previous_roots = std::mem::take(&mut self.config.workspace_roots); + self.config.workspace_roots.push(cwd); + for root in previous_roots { + if root != previous_cwd && !self.config.workspace_roots.contains(&root) { + self.config.workspace_roots.push(root); + } + } + self.config + .permissions + .set_workspace_roots(self.config.workspace_roots.clone()); + } + + pub(super) fn set_effective_collaboration_mode(&mut self, mode: CollaborationMode) { + let mode_kind = mode.mode; + let settings = mode.settings; + if mode_kind == ModeKind::Default { + self.current_collaboration_mode = CollaborationMode { + mode: ModeKind::Default, + settings: settings.clone(), + }; + } + self.active_collaboration_mask = Some(CollaborationModeMask { + name: mode_kind.display_name().to_string(), + mode: Some(mode_kind), + model: Some(settings.model.clone()), + reasoning_effort: Some(settings.reasoning_effort), + developer_instructions: Some(settings.developer_instructions), + }); + self.update_collaboration_mode_indicator(); + self.refresh_plan_mode_nudge(); + self.refresh_model_dependent_surfaces(); + } + pub(super) fn model_display_name(&self) -> &str { let model = self.current_model(); if model.is_empty() { @@ -555,10 +664,15 @@ impl ChatWidget { self.model_catalog.as_ref(), self.active_collaboration_mask.as_ref(), ) { - self.set_collaboration_mask(next_mask); + self.set_collaboration_mask_from_user_action(next_mask); } } + pub(crate) fn set_collaboration_mask_from_user_action(&mut self, mask: CollaborationModeMask) { + self.set_collaboration_mask(mask); + self.submit_collaboration_mode_settings_update(); + } + /// Update the active collaboration mask. /// /// When collaboration modes are enabled and a preset is selected, @@ -609,4 +723,26 @@ impl ChatWidget { } self.request_redraw(); } + + fn submit_collaboration_mode_settings_update(&self) { + let Some(thread_id) = self.thread_id else { + return; + }; + self.app_event_tx.send(AppEvent::SubmitThreadOp { + thread_id, + op: AppCommand::override_turn_context( + /*cwd*/ None, + /*approval_policy*/ None, + /*approvals_reviewer*/ None, + /*active_permission_profile*/ None, + /*windows_sandbox_level*/ None, + /*model*/ None, + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + Some(self.effective_collaboration_mode()), + /*personality*/ None, + ), + }); + } } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index dcf0486822..aa9e5112f8 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -88,7 +88,7 @@ impl ChatWidget { return false; } if let Some(mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) { - self.set_collaboration_mask(mask); + self.set_collaboration_mask_from_user_action(mask); true } else { self.add_info_message( diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 0b5f989141..c8d45d2a59 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -1,6 +1,65 @@ use super::*; use pretty_assertions::assert_eq; +fn thread_settings_for_test( + model: &str, + thread_id: ThreadId, +) -> codex_app_server_protocol::ThreadSettingsUpdatedNotification { + codex_app_server_protocol::ThreadSettingsUpdatedNotification { + thread_id: thread_id.to_string(), + thread_settings: codex_app_server_protocol::ThreadSettings { + cwd: test_path_buf("/tmp/thread-settings").abs(), + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview, + sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly { + network_access: false, + }, + active_permission_profile: Some( + codex_app_server_protocol::ActivePermissionProfile::read_only(), + ), + model: model.to_string(), + model_provider: "openai".to_string(), + service_tier: Some(ServiceTier::Fast.request_value().to_string()), + effort: Some(ReasoningEffortConfig::High), + summary: None, + collaboration_mode: CollaborationMode { + mode: ModeKind::Plan, + settings: codex_protocol::config_types::Settings { + model: model.to_string(), + reasoning_effort: Some(ReasoningEffortConfig::High), + developer_instructions: None, + }, + }, + personality: Some(Personality::Pragmatic), + }, + } +} + +fn configured_thread_session(thread_id: ThreadId) -> crate::session_state::ThreadSessionState { + crate::session_state::ThreadSessionState { + thread_id, + forked_from_id: None, + fork_parent_title: None, + thread_name: None, + model: "gpt-5.3-codex".to_string(), + model_provider_id: "openai".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, + cwd: test_path_buf("/tmp/thread-settings").abs(), + runtime_workspace_roots: vec![test_path_buf("/tmp/thread-settings").abs()], + instruction_source_paths: Vec::new(), + reasoning_effort: None, + collaboration_mode: None, + personality: None, + message_history: None, + network_proxy: None, + rollout_path: None, + } +} + #[tokio::test] async fn invalid_url_elicitation_is_declined() { let (mut chat, _app_event_tx, mut rx, _op_rx) = make_chatwidget_manual_with_sender().await; @@ -38,6 +97,98 @@ async fn invalid_url_elicitation_is_declined() { ); } +#[tokio::test] +async fn thread_settings_updated_updates_visible_state_without_transcript() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + set_fast_mode_test_catalog(&mut chat); + let thread_id = ThreadId::new(); + chat.handle_thread_session(configured_thread_session(thread_id)); + let _ = drain_insert_history(&mut rx); + + chat.handle_server_notification( + ServerNotification::ThreadSettingsUpdated(thread_settings_for_test("gpt-5.4", thread_id)), + /*replay_kind*/ None, + ); + + assert_eq!(chat.current_model(), "gpt-5.4"); + assert_eq!( + chat.current_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); + assert_eq!( + chat.current_service_tier(), + Some(ServiceTier::Fast.request_value()) + ); + assert_eq!( + chat.config_ref().permissions.approval_policy.value(), + AskForApproval::OnRequest.to_core() + ); + assert_eq!( + chat.config_ref().approvals_reviewer, + ApprovalsReviewer::AutoReview + ); + assert_eq!( + chat.config_ref() + .permissions + .active_permission_profile() + .expect("active profile") + .id, + codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY + ); + assert_eq!(chat.config_ref().personality, Some(Personality::Pragmatic)); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); + assert!( + drain_insert_history(&mut rx).is_empty(), + "ThreadSettingsUpdated should not render transcript history" + ); + + chat.handle_server_notification( + ServerNotification::ThreadSettingsUpdated(thread_settings_for_test( + "gpt-5.2", + ThreadId::new(), + )), + /*replay_kind*/ None, + ); + + assert_eq!(chat.current_model(), "gpt-5.4"); +} + +#[tokio::test] +async fn thread_settings_updated_preserves_default_settings_for_plan_mode() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + let thread_id = ThreadId::new(); + let mut session = configured_thread_session(thread_id); + session.model = "gpt-default".to_string(); + session.reasoning_effort = Some(ReasoningEffortConfig::Low); + chat.handle_thread_session(session); + let _ = drain_insert_history(&mut rx); + let default_mode = chat.current_collaboration_mode().clone(); + + chat.handle_server_notification( + ServerNotification::ThreadSettingsUpdated(thread_settings_for_test("gpt-plan", thread_id)), + /*replay_kind*/ None, + ); + + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); + assert_eq!(chat.current_model(), "gpt-plan"); + assert_eq!( + chat.current_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); + assert_eq!(chat.current_collaboration_mode(), &default_mode); + + let default_mask = collaboration_modes::default_mask(chat.model_catalog.as_ref()) + .expect("expected default collaboration mode"); + chat.set_collaboration_mask(default_mask); + + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Default); + assert_eq!(chat.current_model(), "gpt-default"); + assert_eq!( + chat.current_reasoning_effort(), + Some(ReasoningEffortConfig::Low) + ); +} + #[tokio::test] async fn collab_spawn_end_shows_requested_model_and_effort() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 4cd4cf4509..70b5c49b0e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -30,6 +30,8 @@ async fn submission_preserves_text_elements_and_local_images() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -135,6 +137,8 @@ async fn submission_includes_configured_active_permission_profile() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -188,6 +192,8 @@ async fn submission_omits_active_permission_profile_for_legacy_snapshot() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -231,6 +237,8 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -327,6 +335,8 @@ async fn enter_with_only_remote_images_submits_user_turn() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -392,6 +402,8 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -431,6 +443,8 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -470,6 +484,8 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -512,6 +528,8 @@ async fn submission_prefers_selected_duplicate_skill_path() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index ffc8833777..8dbace4378 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -976,6 +976,8 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 5ba32ead56..a7ec121ded 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -31,6 +31,8 @@ async fn resumed_initial_messages_render_history() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -102,6 +104,8 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -172,6 +176,8 @@ async fn replayed_user_message_preserves_remote_image_urls() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -272,6 +278,8 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { runtime_workspace_roots: vec![expected_cwd.clone()], instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: None, @@ -343,6 +351,8 @@ async fn session_configured_preserves_profile_workspace_roots() { runtime_workspace_roots: session_runtime_workspace_roots.clone(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: None, @@ -388,6 +398,8 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: None, @@ -427,6 +439,8 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -483,6 +497,8 @@ async fn replayed_user_message_with_only_local_images_renders_history_cell() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -755,6 +771,8 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: None, @@ -801,6 +819,8 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: None, diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index d682d24d66..71483bd39a 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -584,6 +584,8 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), @@ -632,6 +634,8 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 091f92c3fd..5fc2fa2ce9 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1220,6 +1220,8 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -1407,6 +1409,8 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: None, 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 306a90d782..1d5793e82c 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -2493,6 +2493,8 @@ async fn session_configured_clears_goal_status_footer() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index b0b87cdd00..1307f9a828 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -451,6 +451,8 @@ fn session_configured_event(model: &str) -> ThreadSessionState { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), diff --git a/codex-rs/tui/src/session_state.rs b/codex-rs/tui/src/session_state.rs index c9d964d620..7988ba2264 100644 --- a/codex-rs/tui/src/session_state.rs +++ b/codex-rs/tui/src/session_state.rs @@ -7,6 +7,8 @@ use std::path::PathBuf; use codex_app_server_protocol::AskForApproval; use codex_protocol::ThreadId; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Personality; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; @@ -47,6 +49,8 @@ pub(crate) struct ThreadSessionState { pub(crate) runtime_workspace_roots: Vec, pub(crate) instruction_source_paths: Vec, pub(crate) reasoning_effort: Option, + pub(crate) collaboration_mode: Option>, + pub(crate) personality: Option, pub(crate) message_history: Option, pub(crate) network_proxy: Option, pub(crate) rollout_path: Option, From c5bd131567b39d0b70238b1b4290555e5978ddc2 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 20 May 2026 20:14:41 +0200 Subject: [PATCH 46/63] feat: add turn_id and truncation_policy to extension tool calls (#23666) ## Why Extension-owned tools currently receive a stripped `ToolCall` with only `call_id`, `tool_name`, and `payload`. That makes extension work that needs turn-local execution context awkward, especially web-search extension work that needs the active `truncation_policy` at tool invocation time. Reconstructing that value from config or `ExtensionData` would be indirect and could drift from the actual turn context, so the cleaner fix is to pass the needed turn metadata directly on the extension-facing invocation type. ## What changed - added `turn_id` and `truncation_policy` to `codex_tools::ToolCall` - populated those fields when core adapts `ToolInvocation` into an extension tool call - added a focused adapter test that verifies extension executors receive the forwarded turn metadata - updated the memories extension tests to construct the richer `ToolCall` - added the `codex-utils-output-truncation` dependency to `codex-tools` and refreshed lockfiles ## Testing - `cargo test -p codex-tools` - `cargo test -p codex-memories-extension` - `cargo test -p codex-core passes_turn_fields_to_extension_call` - `just bazel-lock-update` - `just bazel-lock-check` --- codex-rs/Cargo.lock | 1 + .../src/tools/handlers/extension_tools.rs | 66 +++++++++++++++++++ codex-rs/ext/memories/src/tests.rs | 9 +++ codex-rs/tools/Cargo.toml | 1 + codex-rs/tools/src/tool_call.rs | 3 + 5 files changed, 80 insertions(+) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 7ea7dce4d4..72d395c273 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3765,6 +3765,7 @@ dependencies = [ "codex-features", "codex-protocol", "codex-utils-absolute-path", + "codex-utils-output-truncation", "codex-utils-pty", "codex-utils-string", "pretty_assertions", diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 37ad2d65fd..dd2ac42ea4 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -88,8 +88,10 @@ impl CoreToolRuntime for ExtensionToolAdapter { fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { ExtensionToolCall { + turn_id: invocation.turn.sub_id.clone(), call_id: invocation.call_id.clone(), tool_name: invocation.tool_name.clone(), + truncation_policy: invocation.turn.truncation_policy, payload: invocation.payload.clone(), } } @@ -108,6 +110,7 @@ mod tests { use pretty_assertions::assert_eq; use serde_json::json; + use tokio::sync::Mutex; use super::ExtensionToolAdapter; use crate::tools::context::ToolCallSource; @@ -158,6 +161,27 @@ mod tests { } } + struct CapturingExtensionExecutor { + captured_call: Arc>>, + } + + #[async_trait::async_trait] + impl codex_extension_api::ToolExecutor for CapturingExtensionExecutor { + fn tool_name(&self) -> codex_tools::ToolName { + codex_tools::ToolName::plain("extension_echo") + } + + async fn handle( + &self, + call: codex_tools::ToolCall, + ) -> Result, codex_tools::FunctionCallError> { + *self.captured_call.lock().await = Some(call); + Ok(Box::new(codex_tools::JsonToolOutput::new( + json!({ "ok": true }), + ))) + } + } + #[tokio::test] async fn exposes_generic_hook_payloads() { let handler = ExtensionToolAdapter::new(Arc::new(StubExtensionExecutor)); @@ -193,4 +217,46 @@ mod tests { }) ); } + + #[tokio::test] + async fn passes_turn_fields_to_extension_call() { + let captured_call = Arc::new(Mutex::new(None)); + let handler = ExtensionToolAdapter::new(Arc::new(CapturingExtensionExecutor { + captured_call: Arc::clone(&captured_call), + })); + let (session, turn) = crate::session::tests::make_session_and_context().await; + let turn_id = turn.sub_id.clone(); + let truncation_policy = turn.truncation_policy; + let invocation = ToolInvocation { + session: session.into(), + turn: turn.into(), + cancellation_token: tokio_util::sync::CancellationToken::new(), + tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), + call_id: "call-extension".to_string(), + tool_name: codex_tools::ToolName::plain("extension_echo"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: json!({ "message": "hello" }).to_string(), + }, + }; + + crate::tools::registry::ToolExecutor::handle(&handler, invocation) + .await + .expect("extension call should succeed"); + + let captured_call = captured_call.lock().await.clone().expect("captured call"); + assert_eq!(captured_call.turn_id, turn_id); + assert_eq!(captured_call.call_id, "call-extension"); + assert_eq!( + captured_call.tool_name, + codex_tools::ToolName::plain("extension_echo") + ); + assert_eq!(captured_call.truncation_policy, truncation_policy); + match captured_call.payload { + ToolPayload::Function { arguments } => { + assert_eq!(arguments, json!({ "message": "hello" }).to_string()); + } + payload => panic!("expected function payload, got {payload:?}"), + } + } } diff --git a/codex-rs/ext/memories/src/tests.rs b/codex-rs/ext/memories/src/tests.rs index b732ca22c2..e88d7d8db9 100644 --- a/codex-rs/ext/memories/src/tests.rs +++ b/codex-rs/ext/memories/src/tests.rs @@ -13,6 +13,7 @@ use codex_tools::ToolOutput; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::PathExt; use codex_utils_absolute_path::test_support::test_path_buf; +use codex_utils_output_truncation::TruncationPolicy; use pretty_assertions::assert_eq; use serde_json::json; @@ -134,8 +135,10 @@ async fn read_tool_reads_memory_file() { let output = tool .handle(ToolCall { + turn_id: "turn-1".to_string(), call_id: "call-1".to_string(), tool_name: memory_tool_name(crate::READ_TOOL_NAME), + truncation_policy: TruncationPolicy::Bytes(1024), payload: payload.clone(), }) .await @@ -176,8 +179,10 @@ async fn search_tool_accepts_multiple_queries() { let output = tool .handle(ToolCall { + turn_id: "turn-1".to_string(), call_id: "call-1".to_string(), tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME), + truncation_policy: TruncationPolicy::Bytes(1024), payload: payload.clone(), }) .await @@ -244,8 +249,10 @@ async fn search_tool_accepts_windowed_all_match_mode() { let output = tool .handle(ToolCall { + turn_id: "turn-1".to_string(), call_id: "call-1".to_string(), tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME), + truncation_policy: TruncationPolicy::Bytes(1024), payload: payload.clone(), }) .await @@ -292,8 +299,10 @@ async fn search_tool_rejects_legacy_single_query() { let result = tool .handle(ToolCall { + turn_id: "turn-1".to_string(), call_id: "call-1".to_string(), tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME), + truncation_policy: TruncationPolicy::Bytes(1024), payload, }) .await; diff --git a/codex-rs/tools/Cargo.toml b/codex-rs/tools/Cargo.toml index e8d1134a6d..334ce79580 100644 --- a/codex-rs/tools/Cargo.toml +++ b/codex-rs/tools/Cargo.toml @@ -14,6 +14,7 @@ codex-code-mode = { workspace = true } codex-features = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } +codex-utils-output-truncation = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-string = { workspace = true } rmcp = { workspace = true, default-features = false, features = [ diff --git a/codex-rs/tools/src/tool_call.rs b/codex-rs/tools/src/tool_call.rs index f12a338cfa..f92c92f979 100644 --- a/codex-rs/tools/src/tool_call.rs +++ b/codex-rs/tools/src/tool_call.rs @@ -1,12 +1,15 @@ use crate::FunctionCallError; use crate::ToolName; use crate::ToolPayload; +use codex_utils_output_truncation::TruncationPolicy; // TODO: this is temporary and will disappear in the next PR (as we make codex-extension-api generic on Invocation. #[derive(Clone, Debug)] pub struct ToolCall { + pub turn_id: String, pub call_id: String, pub tool_name: ToolName, + pub truncation_policy: TruncationPolicy, pub payload: ToolPayload, } From 110b30d54577f94bd63110e2fdd00ee461440787 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 20 May 2026 11:20:11 -0700 Subject: [PATCH 47/63] install: consume Codex package archives (#23636) ## Summary Standalone installs should exercise the same canonical package archive layout that release builds produce, rather than unpacking npm platform packages and reconstructing a parallel install tree. This updates `install.sh` and `install.ps1` to prefer `codex-package-.tar.gz` plus `codex-package_SHA256SUMS` introduced in https://github.com/openai/codex/pull/23635, authenticate the checksum manifest against GitHub release metadata, verify the selected package archive against the authenticated manifest, and install the package archive directly. ## Compatibility Notes Package installs still leave a compatibility command at `current/codex` for managed daemon flows, while visible command shims point at `bin/codex` inside the package layout. Recent releases that predate package archives still publish per-platform npm artifacts, so both installers keep a legacy platform npm fallback for those versions and verify those archives against release metadata directly. Releases old enough to publish only the single root `codex-npm-.tgz` archive are intentionally out of scope. The installers fail clearly when neither package archives nor per-platform npm archives are present. On Windows, the runtime helper lookups now recognize package-layout installs where `codex.exe` runs from `bin/`, so `codex-command-runner.exe` and `codex-windows-sandbox-setup.exe` resolve from the top-level `codex-resources/` directory. The direct-sibling and older sibling-resource fallbacks are preserved. ## Test plan - `sh -n scripts/install/install.sh` - `bash -n scripts/install/install.sh` - `pwsh -NoProfile -Command '$tokens=$null; $errors=$null; $null = [System.Management.Automation.Language.Parser]::ParseFile("scripts/install/install.ps1", [ref]$tokens, [ref]$errors); if ($errors.Count) { $errors | Format-List *; exit 1 }'` - `HOME="$home_dir" CODEX_HOME="$tmp_dir/codex-home" CODEX_INSTALL_DIR="$bin_dir" PATH="$bin_dir:$PATH" sh scripts/install/install.sh --release 0.125.0` - Verified the 0.125.0 isolated install leaves the visible command pointed at `current/codex` and includes the legacy `codex-resources/rg` payload. - `cargo test -p codex-windows-sandbox` - `just fix -p codex-windows-sandbox` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/23636). * #23638 * #23637 * __->__ #23636 --- .../src/helper_materialization.rs | 57 ++++- codex-rs/windows-sandbox-rs/src/setup.rs | 68 ++++-- scripts/install/install.ps1 | 181 +++++++++++++--- scripts/install/install.sh | 199 +++++++++++++++--- 4 files changed, 426 insertions(+), 79 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs index 8f0b5cb83b..3540f4c463 100644 --- a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs +++ b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs @@ -2,6 +2,7 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use std::collections::HashMap; +use std::ffi::OsStr; use std::fs; use std::io::Write; use std::path::Path; @@ -15,6 +16,7 @@ use crate::logging::log_note; use crate::sandbox_bin_dir; const DEV_BUILD_VERSION_SENTINEL: &str = "0.0.0"; +const BIN_DIRNAME: &str = "bin"; const RESOURCES_DIRNAME: &str = "codex-resources"; #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -188,12 +190,21 @@ fn sibling_source_path(kind: HelperExecutable) -> Result { fn source_path_for_exe(exe: &Path, file_name: &str) -> Option { let dir = exe.parent()?; let direct_candidate = dir.join(file_name); - if direct_candidate.exists() { + if direct_candidate.is_file() { return Some(direct_candidate); } + if dir.file_name() == Some(OsStr::new(BIN_DIRNAME)) + && let Some(package_dir) = dir.parent() + { + let package_resource_candidate = package_dir.join(RESOURCES_DIRNAME).join(file_name); + if package_resource_candidate.is_file() { + return Some(package_resource_candidate); + } + } + let resource_candidate = dir.join(RESOURCES_DIRNAME).join(file_name); - resource_candidate.exists().then_some(resource_candidate) + resource_candidate.is_file().then_some(resource_candidate) } fn helper_destination_for_source( @@ -345,6 +356,7 @@ fn destination_is_fresh(source: &Path, destination: &Path) -> Result { #[cfg(test)] mod tests { + use super::BIN_DIRNAME; use super::CopyOutcome; use super::DEV_BUILD_VERSION_SENTINEL; use super::HelperExecutable; @@ -463,6 +475,47 @@ mod tests { assert_eq!(resolved, helper); } + #[test] + fn helper_source_lookup_checks_package_resource_dir_for_bin_exe() { + let tmp = TempDir::new().expect("tempdir"); + let package_dir = tmp.path().join("package"); + let bin_dir = package_dir.join(BIN_DIRNAME); + let resources_dir = package_dir.join(RESOURCES_DIRNAME); + fs::create_dir_all(&bin_dir).expect("create bin dir"); + fs::create_dir_all(&resources_dir).expect("create resources dir"); + let exe = bin_dir.join("codex.exe"); + let helper = resources_dir.join("codex-command-runner.exe"); + fs::write(&exe, b"codex").expect("write exe"); + fs::write(&helper, b"runner").expect("write helper"); + + let resolved = source_path_for_exe(&exe, /*file_name*/ "codex-command-runner.exe") + .expect("helper path"); + + assert_eq!(resolved, helper); + } + + #[test] + fn helper_source_lookup_prefers_package_resource_dir_over_bin_resource_dir() { + let tmp = TempDir::new().expect("tempdir"); + let package_dir = tmp.path().join("package"); + let bin_dir = package_dir.join(BIN_DIRNAME); + let package_resources_dir = package_dir.join(RESOURCES_DIRNAME); + let bin_resources_dir = bin_dir.join(RESOURCES_DIRNAME); + fs::create_dir_all(&package_resources_dir).expect("create package resources dir"); + fs::create_dir_all(&bin_resources_dir).expect("create bin resources dir"); + let exe = bin_dir.join("codex.exe"); + let package_helper = package_resources_dir.join("codex-command-runner.exe"); + let bin_helper = bin_resources_dir.join("codex-command-runner.exe"); + fs::write(&exe, b"codex").expect("write exe"); + fs::write(&package_helper, b"package runner").expect("write package helper"); + fs::write(&bin_helper, b"bin runner").expect("write bin helper"); + + let resolved = source_path_for_exe(&exe, /*file_name*/ "codex-command-runner.exe") + .expect("helper path"); + + assert_eq!(resolved, package_helper); + } + #[test] fn helper_source_lookup_prefers_direct_sibling_over_resource_dir() { let tmp = TempDir::new().expect("tempdir"); diff --git a/codex-rs/windows-sandbox-rs/src/setup.rs b/codex-rs/windows-sandbox-rs/src/setup.rs index 6f4dd86019..3da5933ee2 100644 --- a/codex-rs/windows-sandbox-rs/src/setup.rs +++ b/codex-rs/windows-sandbox-rs/src/setup.rs @@ -3,6 +3,7 @@ use serde::Serialize; use std::collections::BTreeSet; use std::collections::HashMap; use std::collections::HashSet; +use std::ffi::OsStr; use std::ffi::c_void; use std::os::windows::process::CommandExt; use std::path::Path; @@ -39,9 +40,11 @@ use windows_sys::Win32::Security::SECURITY_NT_AUTHORITY; pub const SETUP_VERSION: u32 = 5; pub const OFFLINE_USERNAME: &str = "CodexSandboxOffline"; pub const ONLINE_USERNAME: &str = "CodexSandboxOnline"; +const BIN_DIRNAME: &str = "bin"; const ERROR_CANCELLED: u32 = 1223; const SECURITY_BUILTIN_DOMAIN_RID: u32 = 0x0000_0020; const DOMAIN_ALIAS_RID_ADMINS: u32 = 0x0000_0220; +const RESOURCES_DIRNAME: &str = "codex-resources"; const USERPROFILE_ROOT_EXCLUSIONS: &[&str] = &[ ".ssh", ".tsh", @@ -579,26 +582,40 @@ fn quote_arg(arg: &str) -> String { fn find_setup_exe() -> PathBuf { if let Ok(exe) = std::env::current_exe() - && let Some(dir) = exe.parent() + && let Some(setup_exe) = find_setup_exe_for_current_exe(&exe) { - let candidate = dir.join("codex-windows-sandbox-setup.exe"); - if candidate.exists() { - return candidate; - } - - // Standalone installs keep Windows helper binaries under - // `codex-resources/` next to `codex.exe`, so elevation needs to probe - // that sibling folder before falling back to PATH. - let resource_candidate = dir - .join("codex-resources") - .join("codex-windows-sandbox-setup.exe"); - if resource_candidate.exists() { - return resource_candidate; - } + return setup_exe; } PathBuf::from("codex-windows-sandbox-setup.exe") } +fn find_setup_exe_for_current_exe(exe: &Path) -> Option { + let dir = exe.parent()?; + let candidate = dir.join("codex-windows-sandbox-setup.exe"); + if candidate.is_file() { + return Some(candidate); + } + + if dir.file_name() == Some(OsStr::new(BIN_DIRNAME)) + && let Some(package_dir) = dir.parent() + { + let package_resource_candidate = package_dir + .join(RESOURCES_DIRNAME) + .join("codex-windows-sandbox-setup.exe"); + if package_resource_candidate.is_file() { + return Some(package_resource_candidate); + } + } + + // Older standalone installs keep Windows helper binaries under + // `codex-resources/` next to `codex.exe`, so elevation still probes that + // sibling folder before falling back to PATH. + let resource_candidate = dir + .join(RESOURCES_DIRNAME) + .join("codex-windows-sandbox-setup.exe"); + resource_candidate.is_file().then_some(resource_candidate) +} + fn report_helper_failure( codex_home: &Path, cleared_report: bool, @@ -959,8 +976,11 @@ fn filter_sensitive_write_roots(mut roots: Vec, codex_home: &Path) -> V #[cfg(test)] mod tests { + use super::BIN_DIRNAME; + use super::RESOURCES_DIRNAME; use super::WINDOWS_PLATFORM_DEFAULT_READ_ROOTS; use super::build_payload_roots; + use super::find_setup_exe_for_current_exe; use super::gather_legacy_full_read_roots; use super::gather_read_roots; use super::loopback_proxy_port_from_url; @@ -1000,6 +1020,24 @@ mod tests { ); } + #[test] + fn setup_exe_lookup_checks_package_resource_dir_for_bin_exe() { + let tmp = TempDir::new().expect("tempdir"); + let package_dir = tmp.path().join("package"); + let bin_dir = package_dir.join(BIN_DIRNAME); + let resources_dir = package_dir.join(RESOURCES_DIRNAME); + fs::create_dir_all(&bin_dir).expect("create bin dir"); + fs::create_dir_all(&resources_dir).expect("create resources dir"); + let exe = bin_dir.join("codex.exe"); + let setup_exe = resources_dir.join("codex-windows-sandbox-setup.exe"); + fs::write(&exe, b"codex").expect("write exe"); + fs::write(&setup_exe, b"setup").expect("write setup"); + + let resolved = find_setup_exe_for_current_exe(&exe).expect("setup exe"); + + assert_eq!(resolved, setup_exe); + } + #[test] fn loopback_proxy_url_parsing_rejects_non_loopback_and_zero_port() { assert_eq!( diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index ed4a3e1a32..8ebc4fbb82 100644 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -55,7 +55,7 @@ function Normalize-Version { return $RawVersion } -function Get-ReleaseAssetMetadata { +function Find-ReleaseAssetMetadata { param( [string]$AssetName, [string]$ResolvedVersion @@ -64,7 +64,7 @@ function Get-ReleaseAssetMetadata { $release = Invoke-RestMethod -Uri "https://api.github.com/repos/openai/codex/releases/tags/rust-v$ResolvedVersion" $asset = $release.assets | Where-Object { $_.name -eq $AssetName } | Select-Object -First 1 if ($null -eq $asset) { - throw "Could not find release asset $AssetName for Codex $ResolvedVersion." + return $null } $digestMatch = [regex]::Match([string]$asset.digest, "^sha256:([0-9a-fA-F]{64})$") @@ -78,6 +78,20 @@ function Get-ReleaseAssetMetadata { } } +function Get-ReleaseAssetMetadata { + param( + [string]$AssetName, + [string]$ResolvedVersion + ) + + $metadata = Find-ReleaseAssetMetadata -AssetName $AssetName -ResolvedVersion $ResolvedVersion + if ($null -eq $metadata) { + throw "Could not find release asset $AssetName for Codex $ResolvedVersion." + } + + return $metadata +} + function Test-ArchiveDigest { param( [string]$ArchivePath, @@ -86,10 +100,27 @@ function Test-ArchiveDigest { $actualDigest = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualDigest -ne $ExpectedDigest) { - throw "Downloaded Codex archive checksum did not match release metadata. Expected $ExpectedDigest but got $actualDigest." + throw "Downloaded Codex archive checksum did not match expected digest. Expected $ExpectedDigest but got $actualDigest." } } +function Get-PackageArchiveDigest { + param( + [string]$ManifestPath, + [string]$AssetName + ) + + $escapedAssetName = [regex]::Escape($AssetName) + foreach ($line in Get-Content -LiteralPath $ManifestPath) { + $match = [regex]::Match($line, "^\s*([0-9a-fA-F]{64})\s+$escapedAssetName\s*$") + if ($match.Success) { + return $match.Groups[1].Value.ToLowerInvariant() + } + } + + throw "Could not find SHA-256 digest for $AssetName in codex-package_SHA256SUMS." +} + function Path-Contains { param( [string]$PathValue, @@ -190,6 +221,11 @@ function Get-CurrentInstalledVersion { [string]$StandaloneCurrentDir ) + $standaloneVersion = Get-VersionFromBinary -CodexPath (Join-Path $StandaloneCurrentDir "bin\codex.exe") + if (-not [string]::IsNullOrWhiteSpace($standaloneVersion)) { + return $standaloneVersion + } + $standaloneVersion = Get-VersionFromBinary -CodexPath (Join-Path $StandaloneCurrentDir "codex.exe") if (-not [string]::IsNullOrWhiteSpace($standaloneVersion)) { return $standaloneVersion @@ -449,14 +485,37 @@ function Ensure-Junction { throw "Refusing to replace file at $LinkPath with a junction." } -function Test-ReleaseIsComplete { +function Test-PackageContentsAreComplete { param( - [string]$ReleaseDir, - [string]$ExpectedVersion, - [string]$ExpectedTarget + [string]$PackageDir ) - if (-not (Test-Path -LiteralPath $ReleaseDir -PathType Container)) { + if (-not (Test-Path -LiteralPath $PackageDir -PathType Container)) { + return $false + } + + $expectedFiles = @( + "codex-package.json", + "bin\codex.exe", + "codex-path\rg.exe", + "codex-resources\codex-command-runner.exe", + "codex-resources\codex-windows-sandbox-setup.exe" + ) + foreach ($name in $expectedFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $PackageDir $name) -PathType Leaf)) { + return $false + } + } + + return $true +} + +function Test-LegacyPlatformNpmContentsAreComplete { + param( + [string]$PackageDir + ) + + if (-not (Test-Path -LiteralPath $PackageDir -PathType Container)) { return $false } @@ -467,11 +526,38 @@ function Test-ReleaseIsComplete { "codex-resources\rg.exe" ) foreach ($name in $expectedFiles) { - if (-not (Test-Path -LiteralPath (Join-Path $ReleaseDir $name) -PathType Leaf)) { + if (-not (Test-Path -LiteralPath (Join-Path $PackageDir $name) -PathType Leaf)) { return $false } } + return $true +} + +function Test-ReleaseIsComplete { + param( + [string]$ReleaseDir, + [string]$ExpectedVersion, + [string]$ExpectedTarget, + [string]$Layout + ) + + switch ($Layout) { + "Package" { + if (-not (Test-PackageContentsAreComplete -PackageDir $ReleaseDir)) { + return $false + } + } + "LegacyPlatformNpm" { + if (-not (Test-LegacyPlatformNpmContentsAreComplete -PackageDir $ReleaseDir)) { + return $false + } + } + default { + throw "Unknown Codex installer layout: $Layout" + } + } + return (Split-Path -Leaf $ReleaseDir) -eq "$ExpectedVersion-$ExpectedTarget" } @@ -637,7 +723,21 @@ Write-Step "Resolved version: $resolvedVersion" $conflictingInstall = Get-ConflictingInstall -VisibleBinDir $visibleBinDir $oldStandaloneBackup = $null -$packageAsset = "codex-npm-$npmTag-$resolvedVersion.tgz" +$packageAsset = "codex-package-$target.tar.gz" +$checksumAsset = "codex-package_SHA256SUMS" +$packageMetadata = Find-ReleaseAssetMetadata -AssetName $packageAsset -ResolvedVersion $resolvedVersion +$checksumMetadata = Find-ReleaseAssetMetadata -AssetName $checksumAsset -ResolvedVersion $resolvedVersion +$installLayout = "Package" +if ($null -eq $packageMetadata -or $null -eq $checksumMetadata) { + $packageAsset = "codex-npm-$npmTag-$resolvedVersion.tgz" + $packageMetadata = Find-ReleaseAssetMetadata -AssetName $packageAsset -ResolvedVersion $resolvedVersion + if ($null -ne $packageMetadata) { + $installLayout = "LegacyPlatformNpm" + } else { + throw "Could not find Codex package or platform npm release assets for Codex $resolvedVersion." + } + $checksumMetadata = $null +} $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("codex-install-" + [System.Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Force -Path $tempDir | Out-Null @@ -645,40 +745,58 @@ try { Invoke-WithInstallLock -LockPath $lockPath -Script { Remove-StaleInstallArtifacts -ReleasesDir $releasesDir - if (-not (Test-ReleaseIsComplete -ReleaseDir $releaseDir -ExpectedVersion $resolvedVersion -ExpectedTarget $target)) { + if (-not (Test-ReleaseIsComplete -ReleaseDir $releaseDir -ExpectedVersion $resolvedVersion -ExpectedTarget $target -Layout $installLayout)) { if (Test-Path -LiteralPath $releaseDir) { Write-WarningStep "Found incomplete existing release at $releaseDir. Reinstalling." } $archivePath = Join-Path $tempDir $packageAsset - $extractDir = Join-Path $tempDir "extract" + $checksumPath = Join-Path $tempDir $checksumAsset $stagingDir = Join-Path $releasesDir ".staging.$releaseName.$PID" - $assetMetadata = Get-ReleaseAssetMetadata -AssetName $packageAsset -ResolvedVersion $resolvedVersion Write-Step "Downloading Codex CLI" - Invoke-WebRequest -Uri $assetMetadata.Url -OutFile $archivePath - Test-ArchiveDigest -ArchivePath $archivePath -ExpectedDigest $assetMetadata.Sha256 + if ($installLayout -eq "Package") { + Invoke-WebRequest -Uri $checksumMetadata.Url -OutFile $checksumPath + Test-ArchiveDigest -ArchivePath $checksumPath -ExpectedDigest $checksumMetadata.Sha256 + $expectedPackageDigest = Get-PackageArchiveDigest -ManifestPath $checksumPath -AssetName $packageAsset + } else { + $expectedPackageDigest = $packageMetadata.Sha256 + } + Invoke-WebRequest -Uri $packageMetadata.Url -OutFile $archivePath + Test-ArchiveDigest -ArchivePath $archivePath -ExpectedDigest $expectedPackageDigest - New-Item -ItemType Directory -Force -Path $extractDir | Out-Null New-Item -ItemType Directory -Force -Path $releasesDir | Out-Null if (Test-Path -LiteralPath $stagingDir) { Remove-Item -LiteralPath $stagingDir -Recurse -Force } New-Item -ItemType Directory -Force -Path $stagingDir | Out-Null - tar -xzf $archivePath -C $extractDir + if ($installLayout -eq "Package") { + tar -xzf $archivePath -C $stagingDir + if (-not (Test-PackageContentsAreComplete -PackageDir $stagingDir)) { + throw "Downloaded Codex package archive did not contain the expected package layout." + } + } else { + $extractDir = Join-Path $tempDir "extract" + New-Item -ItemType Directory -Force -Path $extractDir | Out-Null + tar -xzf $archivePath -C $extractDir - $vendorRoot = Join-Path $extractDir "package/vendor/$target" - $resourcesDir = Join-Path $stagingDir "codex-resources" - New-Item -ItemType Directory -Force -Path $resourcesDir | Out-Null - $copyMap = @{ - "codex/codex.exe" = "codex.exe" - "codex/codex-command-runner.exe" = "codex-resources\codex-command-runner.exe" - "codex/codex-windows-sandbox-setup.exe" = "codex-resources\codex-windows-sandbox-setup.exe" - "path/rg.exe" = "codex-resources\rg.exe" - } + $vendorRoot = Join-Path $extractDir "package/vendor/$target" + $resourcesDir = Join-Path $stagingDir "codex-resources" + New-Item -ItemType Directory -Force -Path $resourcesDir | Out-Null + $copyMap = @{ + "codex/codex.exe" = "codex.exe" + "codex/codex-command-runner.exe" = "codex-resources\codex-command-runner.exe" + "codex/codex-windows-sandbox-setup.exe" = "codex-resources\codex-windows-sandbox-setup.exe" + "path/rg.exe" = "codex-resources\rg.exe" + } - foreach ($relativeSource in $copyMap.Keys) { - Copy-Item -LiteralPath (Join-Path $vendorRoot $relativeSource) -Destination (Join-Path $stagingDir $copyMap[$relativeSource]) + foreach ($relativeSource in $copyMap.Keys) { + Copy-Item -LiteralPath (Join-Path $vendorRoot $relativeSource) -Destination (Join-Path $stagingDir $copyMap[$relativeSource]) + } + + if (-not (Test-LegacyPlatformNpmContentsAreComplete -PackageDir $stagingDir)) { + throw "Downloaded Codex npm archive did not contain the expected legacy platform package layout." + } } if (Test-Path -LiteralPath $releaseDir) { @@ -691,10 +809,15 @@ try { Ensure-Junction -LinkPath $currentDir -TargetPath $releaseDir -InstallerOwnedTargetPrefix $releasesDir $visibleParent = Split-Path -Parent $visibleBinDir + $currentBinDir = if ($installLayout -eq "Package") { + Join-Path $currentDir "bin" + } else { + $currentDir + } New-Item -ItemType Directory -Force -Path $visibleParent | Out-Null $oldStandaloneBackup = Move-OldStandaloneBinIfApproved -VisibleBinDir $visibleBinDir -DefaultVisibleBinDir $defaultVisibleBinDir try { - Ensure-Junction -LinkPath $visibleBinDir -TargetPath $currentDir -InstallerOwnedTargetPrefix $standaloneRoot + Ensure-Junction -LinkPath $visibleBinDir -TargetPath $currentBinDir -InstallerOwnedTargetPrefix $standaloneRoot Test-VisibleCodexCommand -VisibleBinDir $visibleBinDir } catch { if ($null -ne $oldStandaloneBackup -and (Test-Path -LiteralPath $oldStandaloneBackup)) { diff --git a/scripts/install/install.sh b/scripts/install/install.sh index 2fc585d7e9..cc4fc91345 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -120,24 +120,29 @@ release_metadata_url() { printf 'https://api.github.com/repos/openai/codex/releases/tags/rust-v%s\n' "$resolved_version" } -release_asset_digest() { +release_asset_digest_or_empty() { asset="$1" resolved_version="$2" release_json="$(download_text "$(release_metadata_url "$resolved_version")")" digest="$(printf '%s\n' "$release_json" | awk -v asset="$asset" ' - { - if ($0 ~ "\"name\":[[:space:]]*\"" asset "\"") { + /"name":[[:space:]]*"[^"]+"/ { + name = $0 + sub(/^.*"name":[[:space:]]*"/, "", name) + sub(/".*$/, "", name) + if (name == asset) { in_asset = 1 asset_depth = depth } + } - if (in_asset && /"digest":[[:space:]]*"[^"]+"/) { - sub(/^.*"digest":[[:space:]]*"/, "") - sub(/".*$/, "") - digest = $0 - } + in_asset && /"digest":[[:space:]]*"[^"]+"/ { + digest = $0 + sub(/^.*"digest":[[:space:]]*"/, "", digest) + sub(/".*$/, "", digest) + } + { line = $0 opens = gsub(/\{/, "{", line) closes = gsub(/\}/, "}", line) @@ -147,6 +152,7 @@ release_asset_digest() { in_asset = 0 } } + END { if (digest != "") { print digest @@ -159,12 +165,56 @@ release_asset_digest() { printf '%s\n' "${digest#sha256:}" ;; *) - echo "Could not find SHA-256 digest for release asset $asset." >&2 - exit 1 + return 1 ;; esac } +release_asset_exists() { + asset="$1" + resolved_version="$2" + + release_asset_digest_or_empty "$asset" "$resolved_version" >/dev/null 2>&1 +} + +release_asset_digest() { + asset="$1" + resolved_version="$2" + + digest="$(release_asset_digest_or_empty "$asset" "$resolved_version" || true)" + if [ -z "$digest" ]; then + echo "Could not find SHA-256 digest for release asset $asset." >&2 + exit 1 + fi + + printf '%s\n' "$digest" +} + +package_archive_digest() { + asset="$1" + manifest_path="$2" + + digest="$(awk -v asset="$asset" ' + $2 == asset && $1 ~ /^[0-9a-fA-F]{64}$/ { + print tolower($1) + found = 1 + exit + } + END { + if (!found) { + exit 1 + } + } + ' "$manifest_path" 2>/dev/null || true)" + + if [ -z "$digest" ]; then + echo "Could not find SHA-256 digest for $asset in codex-package_SHA256SUMS." >&2 + exit 1 + fi + + printf '%s\n' "$digest" +} + file_sha256() { path="$1" @@ -193,7 +243,7 @@ verify_archive_digest() { actual_digest="$(file_sha256 "$archive_path")" if [ "$actual_digest" != "$expected_digest" ]; then - echo "Downloaded Codex archive checksum did not match release metadata." >&2 + echo "Downloaded Codex archive checksum did not match expected digest." >&2 echo "expected: $expected_digest" >&2 echo "actual: $actual_digest" >&2 exit 1 @@ -441,6 +491,12 @@ version_from_binary() { } current_installed_version() { + version="$(version_from_binary "$CURRENT_LINK/bin/codex" || true)" + if [ -n "$version" ]; then + printf '%s\n' "$version" + return 0 + fi + version="$(version_from_binary "$CURRENT_LINK/codex" || true)" if [ -n "$version" ]; then printf '%s\n' "$version" @@ -584,18 +640,43 @@ handle_conflicting_install() { fi } -install_release() { +install_package_release() { release_dir="$1" - vendor_root="$2" + archive_path="$2" stage_release="$RELEASES_DIR/.staging.$(basename "$release_dir").$$" mkdir -p "$RELEASES_DIR" rm -rf "$stage_release" - mkdir -p "$stage_release/codex-resources" + mkdir -p "$stage_release" + tar -xzf "$archive_path" -C "$stage_release" + chmod 0755 "$stage_release/bin/codex" "$stage_release/codex-path/rg" + if [ -f "$stage_release/codex-resources/bwrap" ]; then + chmod 0755 "$stage_release/codex-resources/bwrap" + fi + ln -sf "bin/codex" "$stage_release/codex" + + if [ -e "$release_dir" ] || [ -L "$release_dir" ]; then + rm -rf "$release_dir" + fi + mv "$stage_release" "$release_dir" +} + +install_legacy_platform_npm_release() { + release_dir="$1" + archive_path="$2" + target="$3" + stage_release="$RELEASES_DIR/.staging.$(basename "$release_dir").$$" + extract_dir="$tmp_dir/extract" + vendor_root="$extract_dir/package/vendor/$target" + + mkdir -p "$RELEASES_DIR" + rm -rf "$stage_release" "$extract_dir" + mkdir -p "$stage_release/codex-resources" "$extract_dir" + tar -xzf "$archive_path" -C "$extract_dir" + cp "$vendor_root/codex/codex" "$stage_release/codex" cp "$vendor_root/path/rg" "$stage_release/codex-resources/rg" - chmod 0755 "$stage_release/codex" - chmod 0755 "$stage_release/codex-resources/rg" + chmod 0755 "$stage_release/codex" "$stage_release/codex-resources/rg" if [ -f "$vendor_root/codex-resources/bwrap" ]; then cp "$vendor_root/codex-resources/bwrap" "$stage_release/codex-resources/bwrap" chmod 0755 "$stage_release/codex-resources/bwrap" @@ -611,15 +692,34 @@ release_dir_is_complete() { release_dir="$1" expected_version="$2" expected_target="$3" + layout="$4" [ -d "$release_dir" ] && - [ -x "$release_dir/codex" ] && - [ -x "$release_dir/codex-resources/rg" ] && - [ "$(basename "$release_dir")" = "$expected_version-$expected_target" ] && - case "$expected_target" in - *linux*) [ -x "$release_dir/codex-resources/bwrap" ] ;; - *) true ;; - esac + [ "$(basename "$release_dir")" = "$expected_version-$expected_target" ] || + return 1 + + case "$layout" in + package) + [ -f "$release_dir/codex-package.json" ] && + [ -x "$release_dir/bin/codex" ] && + [ -x "$release_dir/codex" ] && + [ -x "$release_dir/codex-path/rg" ] || + return 1 + ;; + legacy-platform-npm) + [ -x "$release_dir/codex" ] && + [ -x "$release_dir/codex-resources/rg" ] || + return 1 + ;; + *) + return 1 + ;; + esac + + case "$layout:$expected_target" in + package:*linux* | legacy-platform-npm:*linux*) [ -x "$release_dir/codex-resources/bwrap" ] ;; + *) true ;; + esac } update_current_link() { @@ -629,11 +729,23 @@ update_current_link() { replace_path_with_symlink "$CURRENT_LINK" "$release_dir" "$tmp_link" } +release_codex_relative_path() { + release_dir="$1" + + if [ -x "$release_dir/bin/codex" ]; then + printf 'bin/codex\n' + else + printf 'codex\n' + fi +} + update_visible_command() { + release_dir="$1" mkdir -p "$BIN_DIR" tmp_link="$BIN_DIR/.codex.$$" + codex_relative_path="$(release_codex_relative_path "$release_dir")" - replace_path_with_symlink "$BIN_PATH" "$CURRENT_LINK/codex" "$tmp_link" + replace_path_with_symlink "$BIN_PATH" "$CURRENT_LINK/$codex_relative_path" "$tmp_link" } verify_visible_command() { @@ -700,8 +812,21 @@ else fi resolved_version="$(resolve_version)" -asset="codex-npm-$npm_tag-$resolved_version.tgz" +package_asset="codex-package-$vendor_target.tar.gz" +checksum_asset="codex-package_SHA256SUMS" +if release_asset_exists "$package_asset" "$resolved_version" && + release_asset_exists "$checksum_asset" "$resolved_version"; then + install_layout="package" + asset="$package_asset" +elif release_asset_exists "codex-npm-$npm_tag-$resolved_version.tgz" "$resolved_version"; then + install_layout="legacy-platform-npm" + asset="codex-npm-$npm_tag-$resolved_version.tgz" +else + echo "Could not find Codex package or platform npm release assets for Codex $resolved_version." >&2 + exit 1 +fi download_url="$(release_url_for_asset "$asset" "$resolved_version")" +checksum_url="$(release_url_for_asset "$checksum_asset" "$resolved_version")" release_name="$resolved_version-$vendor_target" release_dir="$RELEASES_DIR/$release_name" current_version="$(current_installed_version)" @@ -730,27 +855,35 @@ trap cleanup EXIT INT TERM acquire_install_lock cleanup_stale_install_artifacts -if ! release_dir_is_complete "$release_dir" "$resolved_version" "$vendor_target"; then +if ! release_dir_is_complete "$release_dir" "$resolved_version" "$vendor_target" "$install_layout"; then if [ -e "$release_dir" ] || [ -L "$release_dir" ]; then warn "Found incomplete existing release at $release_dir; reinstalling." fi archive_path="$tmp_dir/$asset" - extract_dir="$tmp_dir/extract" + checksum_path="$tmp_dir/$checksum_asset" step "Downloading Codex CLI" - expected_digest="$(release_asset_digest "$asset" "$resolved_version")" + if [ "$install_layout" = "package" ]; then + checksum_digest="$(release_asset_digest "$checksum_asset" "$resolved_version")" + download_file "$checksum_url" "$checksum_path" + verify_archive_digest "$checksum_path" "$checksum_digest" + expected_digest="$(package_archive_digest "$asset" "$checksum_path")" + else + expected_digest="$(release_asset_digest "$asset" "$resolved_version")" + fi download_file "$download_url" "$archive_path" verify_archive_digest "$archive_path" "$expected_digest" - mkdir -p "$extract_dir" - tar -xzf "$archive_path" -C "$extract_dir" - step "Installing standalone package to $release_dir" - install_release "$release_dir" "$extract_dir/package/vendor/$vendor_target" + if [ "$install_layout" = "package" ]; then + install_package_release "$release_dir" "$archive_path" + else + install_legacy_platform_npm_release "$release_dir" "$archive_path" "$vendor_target" + fi fi update_current_link "$release_dir" -update_visible_command +update_visible_command "$release_dir" add_to_path verify_visible_command release_install_lock From d84b824d539c131d90fa4e1ff6345b924a87bfa1 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 20 May 2026 20:37:27 +0200 Subject: [PATCH 48/63] [codex] Preserve failed goal accounting flushes (#23717) ## What - Preserve database accounting failures from the goal extension instead of collapsing them into `None` - Warn with turn/tool context when a flush fails - Keep stop/abort accounting snapshots alive when the final flush did not persist ## Why PR #23696 can finish and discard a turn snapshot after `account_thread_goal_usage` fails. That loses the final accumulated accounting state silently. This follow-up keeps that failure explicit and avoids deleting the local snapshot in the failing path. ## Testing - `just fmt` - `cargo test -p codex-goal-extension` --- codex-rs/ext/goal/src/extension.rs | 83 +++++++++++++++++++----------- 1 file changed, 53 insertions(+), 30 deletions(-) diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index 61114bf57e..361147a512 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -151,14 +151,21 @@ where } let turn_id = input.turn_store.level_id(); - self.account_active_goal_progress( - input.thread_store, - turn_id, - &format!("{turn_id}:turn-stop"), - codex_state::GoalAccountingMode::ActiveOnly, - BudgetLimitedGoalDisposition::ClearActive, - ) - .await; + if let Err(err) = self + .account_active_goal_progress( + input.thread_store, + turn_id, + &format!("{turn_id}:turn-stop"), + codex_state::GoalAccountingMode::ActiveOnly, + BudgetLimitedGoalDisposition::ClearActive, + ) + .await + { + tracing::warn!( + "failed to account active goal progress at turn stop for {turn_id}: {err}" + ); + return; + } accounting_state(input.thread_store).finish_turn(turn_id); } @@ -168,14 +175,21 @@ where } let turn_id = input.turn_store.level_id(); - self.account_active_goal_progress( - input.thread_store, - turn_id, - &format!("{turn_id}:turn-abort"), - codex_state::GoalAccountingMode::ActiveOnly, - BudgetLimitedGoalDisposition::ClearActive, - ) - .await; + if let Err(err) = self + .account_active_goal_progress( + input.thread_store, + turn_id, + &format!("{turn_id}:turn-abort"), + codex_state::GoalAccountingMode::ActiveOnly, + BudgetLimitedGoalDisposition::ClearActive, + ) + .await + { + tracing::warn!( + "failed to account active goal progress after turn abort for {turn_id}: {err}" + ); + return; + } accounting_state(input.thread_store).finish_turn(turn_id); } } @@ -217,14 +231,21 @@ where if !should_count_for_goal_progress { return; } - self.account_active_goal_progress( - input.thread_store, - input.turn_id, - input.call_id, - codex_state::GoalAccountingMode::ActiveOnly, - BudgetLimitedGoalDisposition::KeepActive, - ) - .await; + let turn_id = input.turn_id; + if let Err(err) = self + .account_active_goal_progress( + input.thread_store, + turn_id, + input.call_id, + codex_state::GoalAccountingMode::ActiveOnly, + BudgetLimitedGoalDisposition::KeepActive, + ) + .await + { + tracing::warn!( + "failed to account active goal progress after tool finish for {turn_id}: {err}" + ); + } }) } } @@ -326,12 +347,14 @@ impl GoalExtension { event_id: &str, mode: codex_state::GoalAccountingMode, budget_limited_goal_disposition: BudgetLimitedGoalDisposition, - ) -> Option { + ) -> Result, String> { let Ok(thread_id) = ThreadId::from_string(thread_store.level_id()) else { - return None; + return Ok(None); }; let accounting = accounting_state(thread_store); - let snapshot = accounting.progress_snapshot(turn_id)?; + let Some(snapshot) = accounting.progress_snapshot(turn_id) else { + return Ok(None); + }; let outcome = self .state_dbs .thread_goals() @@ -343,8 +366,8 @@ impl GoalExtension { Some(snapshot.expected_goal_id.as_str()), ) .await - .ok()?; - match outcome { + .map_err(|err| err.to_string())?; + Ok(match outcome { codex_state::GoalAccountingOutcome::Updated(goal) => { accounting.mark_progress_accounted_for_status( turn_id, @@ -361,6 +384,6 @@ impl GoalExtension { Some(goal) } codex_state::GoalAccountingOutcome::Unchanged(_) => None, - } + }) } } From ed6d73b3b94fd436db87eca3d5984628fa8c7a57 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Wed, 20 May 2026 11:38:21 -0700 Subject: [PATCH 49/63] add standalone websearch api client (#23655) add standalone web search request types and a `codex-api` client ahead of the extension-contributed search tool. this adds typed commands/settings and opaque encrypted output handling for the new standalone search flow. the endpoint types are close to finalized but may still shift slightly as that API settles. --- codex-rs/codex-api/src/endpoint/mod.rs | 2 + codex-rs/codex-api/src/endpoint/search.rs | 264 ++++++++++++++++++++++ codex-rs/codex-api/src/lib.rs | 27 +++ codex-rs/codex-api/src/search.rs | 246 ++++++++++++++++++++ 4 files changed, 539 insertions(+) create mode 100644 codex-rs/codex-api/src/endpoint/search.rs create mode 100644 codex-rs/codex-api/src/search.rs diff --git a/codex-rs/codex-api/src/endpoint/mod.rs b/codex-rs/codex-api/src/endpoint/mod.rs index 70c52b9580..21ebf372a1 100644 --- a/codex-rs/codex-api/src/endpoint/mod.rs +++ b/codex-rs/codex-api/src/endpoint/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod realtime_call; pub(crate) mod realtime_websocket; pub(crate) mod responses; pub(crate) mod responses_websocket; +pub(crate) mod search; mod session; pub use compact::CompactClient; @@ -27,3 +28,4 @@ pub use responses_websocket::ResponsesWebsocketClient; pub use responses_websocket::ResponsesWebsocketClose; pub use responses_websocket::ResponsesWebsocketConnection; pub use responses_websocket::ResponsesWebsocketProbe; +pub use search::SearchClient; diff --git a/codex-rs/codex-api/src/endpoint/search.rs b/codex-rs/codex-api/src/endpoint/search.rs new file mode 100644 index 0000000000..2940f872d8 --- /dev/null +++ b/codex-rs/codex-api/src/endpoint/search.rs @@ -0,0 +1,264 @@ +use crate::auth::SharedAuthProvider; +use crate::endpoint::session::EndpointSession; +use crate::error::ApiError; +use crate::provider::Provider; +use crate::search::SearchRequest; +use crate::search::SearchResponse; +use codex_client::HttpTransport; +use codex_client::RequestTelemetry; +use http::HeaderMap; +use http::Method; +use serde_json::to_value; +use std::sync::Arc; + +pub struct SearchClient { + session: EndpointSession, +} + +impl SearchClient { + pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self { + Self { + session: EndpointSession::new(transport, provider, auth), + } + } + + pub fn with_telemetry(self, request: Option>) -> Self { + Self { + session: self.session.with_request_telemetry(request), + } + } + + fn path() -> &'static str { + "alpha/search" + } + + pub async fn search( + &self, + request: &SearchRequest, + extra_headers: HeaderMap, + ) -> Result { + let body = to_value(request) + .map_err(|e| ApiError::Stream(format!("failed to encode search request: {e}")))?; + let resp = self + .session + .execute(Method::POST, Self::path(), extra_headers, Some(body)) + .await?; + serde_json::from_slice(&resp.body) + .map_err(|e| ApiError::Stream(format!("failed to decode search response: {e}"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::AuthProvider; + use crate::provider::RetryConfig; + use crate::search::AllowedCaller; + use crate::search::ApproximateLocation; + use crate::search::LocationType; + use crate::search::OpenOperation; + use crate::search::SearchCommands; + use crate::search::SearchContextSize; + use crate::search::SearchFilters; + use crate::search::SearchImageSettings; + use crate::search::SearchInput; + use crate::search::SearchQuery; + use crate::search::SearchSettings; + use async_trait::async_trait; + use codex_client::Request; + use codex_client::RequestBody; + use codex_client::Response; + use codex_client::StreamResponse; + use codex_client::TransportError; + use codex_protocol::models::ContentItem; + use codex_protocol::models::ResponseItem; + use http::StatusCode; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::sync::Mutex; + use std::time::Duration; + + #[derive(Clone, Default)] + struct DummyAuth; + + impl AuthProvider for DummyAuth { + fn add_auth_headers(&self, _headers: &mut HeaderMap) {} + } + + #[derive(Clone)] + struct CapturingTransport { + last_request: Arc>>, + response_body: Arc>, + } + + impl CapturingTransport { + fn new(response_body: Vec) -> Self { + Self { + last_request: Arc::new(Mutex::new(None)), + response_body: Arc::new(response_body), + } + } + } + + #[async_trait] + impl HttpTransport for CapturingTransport { + async fn execute(&self, req: Request) -> Result { + *self.last_request.lock().expect("lock request store") = Some(req); + Ok(Response { + status: StatusCode::OK, + headers: HeaderMap::new(), + body: self.response_body.as_ref().clone().into(), + }) + } + + async fn stream(&self, _req: Request) -> Result { + Err(TransportError::Build("stream should not run".to_string())) + } + } + + fn provider() -> Provider { + Provider { + name: "test".to_string(), + base_url: "https://example.com/v1".to_string(), + query_params: None, + headers: HeaderMap::new(), + retry: RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: true, + retry_transport: true, + }, + stream_idle_timeout: Duration::from_secs(1), + } + } + + #[tokio::test] + async fn search_posts_typed_request_and_parses_encrypted_output() { + let transport = CapturingTransport::new( + serde_json::to_vec(&json!({"encrypted_output": "ciphertext"})) + .expect("serialize response"), + ); + let client = SearchClient::new(transport.clone(), provider(), Arc::new(DummyAuth)); + + let response = client + .search( + &SearchRequest { + id: "search-session".to_string(), + model: Some("gpt-test".to_string()), + reasoning: None, + input: Some(SearchInput::Items(vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputText { + text: "find this".to_string(), + }, + ContentItem::InputImage { + image_url: "https://example.com/image.png".to_string(), + detail: None, + }, + ], + phase: None, + }])), + commands: Some(SearchCommands { + search_query: Some(vec![SearchQuery { + q: "OpenAI news".to_string(), + recency: Some(7), + domains: Some(vec!["openai.com".to_string()]), + }]), + open: Some(vec![OpenOperation { + ref_id: "https://openai.com".to_string(), + lineno: Some(12), + }]), + ..Default::default() + }), + settings: Some(SearchSettings { + user_location: Some(ApproximateLocation { + r#type: LocationType::Approximate, + country: Some("US".to_string()), + region: None, + city: Some("San Francisco".to_string()), + timezone: None, + }), + search_context_size: Some(SearchContextSize::Low), + filters: Some(SearchFilters { + allowed_domains: Some(vec!["openai.com".to_string()]), + blocked_domains: Some(vec!["example.com".to_string()]), + }), + image_settings: Some(SearchImageSettings { + max_results: Some(4), + caption: Some(true), + }), + allowed_callers: Some(vec![AllowedCaller::Direct]), + external_web_access: Some(true), + }), + max_output_tokens: Some(2500), + }, + HeaderMap::new(), + ) + .await + .expect("search request should succeed"); + + assert_eq!( + response, + SearchResponse { + encrypted_output: "ciphertext".to_string(), + } + ); + + let request = transport + .last_request + .lock() + .expect("lock request store") + .clone() + .expect("request should be captured"); + let body = request + .body + .as_ref() + .and_then(RequestBody::json) + .expect("request body should be JSON"); + assert_eq!( + body, + &json!({ + "id": "search-session", + "model": "gpt-test", + "input": [{ + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "find this"}, + { + "type": "input_image", + "image_url": "https://example.com/image.png" + } + ] + }], + "commands": { + "search_query": [{ + "q": "OpenAI news", + "recency": 7, + "domains": ["openai.com"] + }], + "open": [{"ref_id": "https://openai.com", "lineno": 12}] + }, + "settings": { + "user_location": { + "type": "approximate", + "country": "US", + "city": "San Francisco" + }, + "search_context_size": "low", + "filters": { + "allowed_domains": ["openai.com"], + "blocked_domains": ["example.com"] + }, + "image_settings": {"max_results": 4, "caption": true}, + "allowed_callers": ["direct"], + "external_web_access": true + }, + "max_output_tokens": 2500 + }) + ); + } +} diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 99470cac59..f47791b799 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -7,6 +7,7 @@ pub(crate) mod files; pub(crate) mod provider; pub(crate) mod rate_limits; pub(crate) mod requests; +pub(crate) mod search; pub(crate) mod sse; pub(crate) mod telemetry; @@ -58,6 +59,7 @@ pub use crate::endpoint::ResponsesWebsocketClient; pub use crate::endpoint::ResponsesWebsocketClose; pub use crate::endpoint::ResponsesWebsocketConnection; pub use crate::endpoint::ResponsesWebsocketProbe; +pub use crate::endpoint::SearchClient; pub use crate::endpoint::session_update_session_json; pub use crate::error::ApiError; pub use crate::files::upload_local_file; @@ -65,6 +67,31 @@ pub use crate::provider::Provider; pub use crate::provider::RetryConfig; pub use crate::provider::is_azure_responses_provider; pub use crate::requests::Compression; +pub use crate::search::AllowedCaller; +pub use crate::search::ApproximateLocation; +pub use crate::search::ClickOperation; +pub use crate::search::FinanceAssetType; +pub use crate::search::FinanceOperation; +pub use crate::search::FindOperation; +pub use crate::search::LocationType; +pub use crate::search::OpenOperation; +pub use crate::search::ScreenshotOperation; +pub use crate::search::SearchCommands; +pub use crate::search::SearchContextSize; +pub use crate::search::SearchFilters; +pub use crate::search::SearchImageSettings; +pub use crate::search::SearchInput; +pub use crate::search::SearchQuery; +pub use crate::search::SearchRequest; +pub use crate::search::SearchResponse; +pub use crate::search::SearchResponseLength; +pub use crate::search::SearchSettings; +pub use crate::search::SportsFunction; +pub use crate::search::SportsLeague; +pub use crate::search::SportsOperation; +pub use crate::search::SportsToolName; +pub use crate::search::TimeOperation; +pub use crate::search::WeatherOperation; pub use crate::telemetry::SseTelemetry; pub use crate::telemetry::WebsocketTelemetry; pub use codex_protocol::protocol::RealtimeAudioFrame; diff --git a/codex-rs/codex-api/src/search.rs b/codex-rs/codex-api/src/search.rs new file mode 100644 index 0000000000..b841d06a30 --- /dev/null +++ b/codex-rs/codex-api/src/search.rs @@ -0,0 +1,246 @@ +use crate::common::Reasoning; +use codex_protocol::models::ResponseItem; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct SearchRequest { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub commands: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub settings: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(untagged)] +pub enum SearchInput { + Text(String), + Items(Vec), +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct SearchCommands { + #[serde(skip_serializing_if = "Option::is_none")] + pub search_query: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub image_query: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub open: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub click: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub find: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub screenshot: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub finance: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub weather: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub sports: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub time: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub response_length: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SearchQuery { + pub q: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub recency: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub domains: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct OpenOperation { + pub ref_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub lineno: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ClickOperation { + pub ref_id: String, + pub id: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FindOperation { + pub ref_id: String, + pub pattern: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScreenshotOperation { + pub ref_id: String, + pub pageno: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FinanceOperation { + pub ticker: String, + pub r#type: FinanceAssetType, + #[serde(skip_serializing_if = "Option::is_none")] + pub market: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum FinanceAssetType { + Equity, + Fund, + Crypto, + Index, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct WeatherOperation { + pub location: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SportsOperation { + #[serde(skip_serializing_if = "Option::is_none")] + pub tool: Option, + pub r#fn: SportsFunction, + pub league: SportsLeague, + #[serde(skip_serializing_if = "Option::is_none")] + pub team: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub opponent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub date_from: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub date_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub num_games: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub locale: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SportsToolName { + Sports, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SportsFunction { + Schedule, + Standings, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SportsLeague { + Nba, + Wnba, + Nfl, + Nhl, + Mlb, + Epl, + Ncaamb, + Ncaawb, + Ipl, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TimeOperation { + pub utc_offset: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SearchResponseLength { + Short, + Medium, + Long, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct SearchSettings { + #[serde(skip_serializing_if = "Option::is_none")] + pub user_location: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub search_context_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub filters: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image_settings: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_callers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub external_web_access: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApproximateLocation { + pub r#type: LocationType, + #[serde(skip_serializing_if = "Option::is_none")] + pub country: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub city: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timezone: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum LocationType { + Approximate, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SearchContextSize { + Low, + Medium, + High, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct SearchFilters { + #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_domains: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub blocked_domains: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct SearchImageSettings { + #[serde(skip_serializing_if = "Option::is_none")] + pub max_results: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub caption: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AllowedCaller { + Direct, + Shell, + CodeInterpreter, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct SearchResponse { + pub encrypted_output: String, +} From 7c3cc1db81a46a4f3ceeca1d7d50df0f7d10d09c Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 20 May 2026 11:58:23 -0700 Subject: [PATCH 50/63] Fix thread settings clippy failure (#23724) ## Why `main` picked up two small Rust build failures after nearby merges: - #23507 added a real handler for `ServerNotification::ThreadSettingsUpdated`, but the same variant was still listed in the ignored-notification match arm. Full Clippy runs treat the resulting unreachable-pattern warning as an error. - #23666 added `turn_id` and `truncation_policy` to `codex_tools::ToolCall`, while the goal extension backend test fixtures from the goal-extension work still used the old shape. That left `codex-goal-extension` tests unable to compile once the branches met on `main`. ## What changed Removed the duplicate `ThreadSettingsUpdated` match pattern from `tui/src/chatwidget/protocol.rs`. Updated the goal extension test `tool_call` helper to populate the new `ToolCall` fields, and reused that helper for the one direct literal that still had the old field list. ## Verification - `just fix -p codex-tui` - `cargo test -p codex-goal-extension` --- .../ext/goal/tests/goal_extension_backend.rs | 23 +++++++++---------- codex-rs/tui/src/chatwidget/protocol.rs | 1 - 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/codex-rs/ext/goal/tests/goal_extension_backend.rs b/codex-rs/ext/goal/tests/goal_extension_backend.rs index d04dbe6021..c2c0a2306c 100644 --- a/codex-rs/ext/goal/tests/goal_extension_backend.rs +++ b/codex-rs/ext/goal/tests/goal_extension_backend.rs @@ -12,7 +12,6 @@ use codex_extension_api::ToolCallOutcome; use codex_extension_api::ToolCallSource; use codex_extension_api::ToolExecutor; use codex_extension_api::ToolFinishInput; -use codex_extension_api::ToolName; use codex_extension_api::ToolPayload; use codex_extension_api::TurnStartInput; use codex_extension_api::TurnStopInput; @@ -27,6 +26,7 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; +use codex_protocol::protocol::TruncationPolicy; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; @@ -39,17 +39,14 @@ async fn installed_goal_tools_create_goal_and_fill_empty_preview() -> anyhow::Re let tools = installed_tools(runtime.clone(), thread_id).await; let create_tool = tool_by_name(&tools, "create_goal"); - let invocation = ToolCall { - call_id: "call-create-goal".to_string(), - tool_name: ToolName::plain("create_goal"), - payload: ToolPayload::Function { - arguments: json!({ - "objective": "ship goal extension backend", - "token_budget": 123, - }) - .to_string(), - }, - }; + let invocation = tool_call( + "create_goal", + "call-create-goal", + json!({ + "objective": "ship goal extension backend", + "token_budget": 123, + }), + ); let output = create_tool.handle(invocation.clone()).await?; let result = output.code_mode_result(&invocation.payload); assert_eq!( @@ -534,8 +531,10 @@ fn tool_by_name<'a>( fn tool_call(tool_name: &str, call_id: &str, arguments: serde_json::Value) -> ToolCall { ToolCall { + turn_id: "turn-1".to_string(), call_id: call_id.to_string(), tool_name: codex_extension_api::ToolName::plain(tool_name), + truncation_policy: TruncationPolicy::Bytes(1024), payload: ToolPayload::Function { arguments: arguments.to_string(), }, diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index 6a980e347c..b3b58cbc82 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -220,7 +220,6 @@ impl ChatWidget { | ServerNotification::AccountRateLimitsUpdated(_) | ServerNotification::ThreadStarted(_) | ServerNotification::ThreadStatusChanged(_) - | ServerNotification::ThreadSettingsUpdated(_) | ServerNotification::ThreadArchived(_) | ServerNotification::ThreadUnarchived(_) | ServerNotification::RawResponseItemCompleted(_) From e389e01f8347a4861ed139b01956fe64aa0c0fda Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 20 May 2026 12:02:32 -0700 Subject: [PATCH 51/63] npm: ship platform packages in Codex package layout (#23637) ## Summary The npm platform packages should stop carrying a bespoke native layout now that the release workflow builds canonical Codex package archives. Keeping npm on the same `bin/`, `codex-resources/`, and `codex-path/` structure lets the Rust package-layout detection behave consistently across standalone, npm, and future DotSlash installs. This changes platform npm packages to stage the `codex-package` artifact for each target under `vendor/`. The Node launcher now resolves `bin/codex` and prepends `codex-path`, while retaining legacy `vendor//codex` and `vendor//path` fallback support for local development and migration. The npm staging helper downloads `codex-package` archives instead of rebuilding the CLI payload from individual `codex`, `rg`, `bwrap`, and sandbox helper artifacts. CI still needs to stage npm packages from historical rust-release workflow artifacts that predate package archives, so the staging scripts expose an explicit `--allow-legacy-codex-package` fallback. That fallback synthesizes the canonical package layout from legacy per-binary artifacts and is wired only into the CI smoke path; release staging remains strict and continues to require real package archives. For direct local use, `install_native_deps.py` now points its built-in default workflow at the same recent artifact run used by CI and automatically enables legacy package synthesis only when `--workflow-url` is omitted. Explicit workflow URLs remain strict unless callers opt in with `--allow-legacy-codex-package`. ## Test plan - `python3 -m py_compile codex-cli/scripts/build_npm_package.py codex-cli/scripts/install_native_deps.py scripts/stage_npm_packages.py scripts/codex_package/cli.py` - `node --check codex-cli/bin/codex.js` - `ruby -e 'require "yaml"; YAML.load_file(".github/workflows/rust-release.yml"); YAML.load_file(".github/workflows/ci.yml"); puts "ok"'` - Staged a synthetic `codex-linux-x64` platform package from a canonical vendor tree and verified it copied only `bin/`, `codex-path/`, `codex-resources/`, and `codex-package.json`. - Imported `install_native_deps.py` and extracted a synthetic `codex-package-x86_64-unknown-linux-musl.tar.gz` into `vendor/`. - Ran legacy-layout conversion smokes for Linux, Windows, and unsigned macOS artifact naming. - Ran a synthetic `install_native_deps.py` default-workflow smoke that verifies legacy package synthesis is automatic only when `--workflow-url` is omitted. - `NPM_CONFIG_CACHE="$tmp_dir/npm-cache" python3 ./scripts/stage_npm_packages.py --release-version 0.125.0 --workflow-url https://github.com/openai/codex/actions/runs/26131514935 --package codex --allow-legacy-codex-package --output-dir "$tmp_dir"` - `node codex-cli/bin/codex.js --version` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/23637). * #23638 * __->__ #23637 --- .github/workflows/ci.yml | 8 +- .github/workflows/rust-release.yml | 2 - codex-cli/bin/codex.js | 60 ++++--- codex-cli/scripts/README.md | 8 +- codex-cli/scripts/build_npm_package.py | 70 ++++++-- codex-cli/scripts/install_native_deps.py | 210 ++++++++++++++++++++--- scripts/stage_npm_packages.py | 22 ++- 7 files changed, 312 insertions(+), 68 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1c60acc26..97591d4c19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,14 +53,16 @@ jobs: # Passing the workflow URL directly avoids relying on old rust-v* # branches remaining discoverable via `gh run list --branch ...`. CODEX_VERSION=0.125.0 - WORKFLOW_URL="https://github.com/openai/codex/actions/runs/24901475298" + WORKFLOW_URL="https://github.com/openai/codex/actions/runs/26131514935" OUTPUT_DIR="${RUNNER_TEMP}" - # This reused workflow predates the standalone bwrap artifact. + # This reused workflow predates codex-package archive artifacts, so + # CI synthesizes the package layout from the older per-binary + # artifacts. Release staging must use real package archives. python3 ./scripts/stage_npm_packages.py \ --release-version "$CODEX_VERSION" \ --workflow-url "$WORKFLOW_URL" \ --package codex \ - --allow-missing-native-component bwrap \ + --allow-legacy-codex-package \ --output-dir "$OUTPUT_DIR" PACK_OUTPUT="${OUTPUT_DIR}/codex-npm-${CODEX_VERSION}.tgz" echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 93e23b8fd7..7268a24ad8 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -1208,8 +1208,6 @@ jobs: if: ${{ env.SIGN_MACOS == 'true' }} run: pnpm install --frozen-lockfile - # stage_npm_packages.py requires DotSlash when staging releases. - - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - name: Stage npm packages if: ${{ env.SIGN_MACOS == 'true' }} env: diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index 475239549a..1a43ce7e1f 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -77,33 +77,43 @@ if (!platformPackage) { const codexBinaryName = process.platform === "win32" ? "codex.exe" : "codex"; const localVendorRoot = path.join(__dirname, "..", "vendor"); -const localBinaryPath = path.join( - localVendorRoot, - targetTriple, - "codex", - codexBinaryName, -); +const packageBinaryPath = (vendorRoot) => + path.join(vendorRoot, targetTriple, "bin", codexBinaryName); +const legacyBinaryPath = (vendorRoot) => + path.join(vendorRoot, targetTriple, "codex", codexBinaryName); -let vendorRoot; -try { - const packageJsonPath = require.resolve(`${platformPackage}/package.json`); - vendorRoot = path.join(path.dirname(packageJsonPath), "vendor"); -} catch { - if (existsSync(localBinaryPath)) { - vendorRoot = localVendorRoot; - } else { - const packageManager = detectPackageManager(); - const updateCommand = - packageManager === "bun" - ? "bun install -g @openai/codex@latest" - : "npm install -g @openai/codex@latest"; - throw new Error( - `Missing optional dependency ${platformPackage}. Reinstall Codex: ${updateCommand}`, - ); +function resolveNativePackage(vendorRoot) { + const packageRoot = path.join(vendorRoot, targetTriple); + const binaryPath = packageBinaryPath(vendorRoot); + if (existsSync(binaryPath)) { + return { + binaryPath, + pathDir: path.join(packageRoot, "codex-path"), + }; } + + const legacyPath = legacyBinaryPath(vendorRoot); + if (existsSync(legacyPath)) { + return { + binaryPath: legacyPath, + pathDir: path.join(packageRoot, "path"), + }; + } + + return null; } -if (!vendorRoot) { +let nativePackage; +try { + const packageJsonPath = require.resolve(`${platformPackage}/package.json`); + nativePackage = resolveNativePackage( + path.join(path.dirname(packageJsonPath), "vendor"), + ); +} catch { + nativePackage = resolveNativePackage(localVendorRoot); +} + +if (!nativePackage) { const packageManager = detectPackageManager(); const updateCommand = packageManager === "bun" @@ -114,8 +124,7 @@ if (!vendorRoot) { ); } -const archRoot = path.join(vendorRoot, targetTriple); -const binaryPath = path.join(archRoot, "codex", codexBinaryName); +const { binaryPath, pathDir } = nativePackage; // Use an asynchronous spawn instead of spawnSync so that Node is able to // respond to signals (e.g. Ctrl-C / SIGINT) while the native binary is @@ -159,7 +168,6 @@ function detectPackageManager() { } const additionalDirs = []; -const pathDir = path.join(archRoot, "path"); if (existsSync(pathDir)) { additionalDirs.push(pathDir); } diff --git a/codex-cli/scripts/README.md b/codex-cli/scripts/README.md index ca0d54b544..ce58097db5 100644 --- a/codex-cli/scripts/README.md +++ b/codex-cli/scripts/README.md @@ -11,13 +11,13 @@ example, to stage the CLI, responses proxy, and SDK packages for version `0.6.0` --package codex-sdk ``` -This downloads the native artifacts once, hydrates `vendor/` for each package, and writes -tarballs to `dist/npm/`. +This downloads the native package archive artifacts once, hydrates `vendor/` for each +package, and writes tarballs to `dist/npm/`. When `--package codex` is provided, the staging helper builds the lightweight `@openai/codex` meta package plus all platform-native `@openai/codex` variants that are later published under platform-specific dist-tags. If you need to invoke `build_npm_package.py` directly, run -`codex-cli/scripts/install_native_deps.py` first and pass `--vendor-src` pointing to the -directory that contains the populated `vendor/` tree. +`codex-cli/scripts/install_native_deps.py --component codex-package` first and pass +`--vendor-src` pointing to the directory that contains the populated `vendor/` tree. diff --git a/codex-cli/scripts/build_npm_package.py b/codex-cli/scripts/build_npm_package.py index c38fed68d9..5d4bc99c4e 100755 --- a/codex-cli/scripts/build_npm_package.py +++ b/codex-cli/scripts/build_npm_package.py @@ -15,6 +15,8 @@ REPO_ROOT = CODEX_CLI_ROOT.parent RESPONSES_API_PROXY_NPM_ROOT = REPO_ROOT / "codex-rs" / "responses-api-proxy" / "npm" CODEX_SDK_ROOT = REPO_ROOT / "sdk" / "typescript" CODEX_NPM_NAME = "@openai/codex" +CODEX_PACKAGE_COMPONENT = "codex-package" +CODEX_PACKAGE_ENTRIES = ("codex-package.json", "bin", "codex-resources", "codex-path") # `npm_name` is the local optional-dependency alias consumed by `bin/codex.js`. # The underlying package published to npm is always `@openai/codex`. @@ -69,12 +71,12 @@ PACKAGE_EXPANSIONS: dict[str, list[str]] = { PACKAGE_NATIVE_COMPONENTS: dict[str, list[str]] = { "codex": [], - "codex-linux-x64": ["bwrap", "codex", "rg"], - "codex-linux-arm64": ["bwrap", "codex", "rg"], - "codex-darwin-x64": ["codex", "rg"], - "codex-darwin-arm64": ["codex", "rg"], - "codex-win32-x64": ["codex", "rg", "codex-windows-sandbox-setup", "codex-command-runner"], - "codex-win32-arm64": ["codex", "rg", "codex-windows-sandbox-setup", "codex-command-runner"], + "codex-linux-x64": [CODEX_PACKAGE_COMPONENT], + "codex-linux-arm64": [CODEX_PACKAGE_COMPONENT], + "codex-darwin-x64": [CODEX_PACKAGE_COMPONENT], + "codex-darwin-arm64": [CODEX_PACKAGE_COMPONENT], + "codex-win32-x64": [CODEX_PACKAGE_COMPONENT], + "codex-win32-arm64": [CODEX_PACKAGE_COMPONENT], "codex-responses-api-proxy": ["codex-responses-api-proxy"], "codex-sdk": [], } @@ -383,7 +385,11 @@ def copy_native_binaries( if not vendor_src.exists(): raise RuntimeError(f"Vendor source directory not found: {vendor_src}") - components_set = {component for component in components if component in COMPONENT_DEST_DIR} + components_set = { + component + for component in components + if component == CODEX_PACKAGE_COMPONENT or component in COMPONENT_DEST_DIR + } allow_missing_components = allow_missing_components or set() if not components_set: return @@ -402,11 +408,26 @@ def copy_native_binaries( if target_filter is not None and target_dir.name not in target_filter: continue - dest_target_dir = vendor_dest / target_dir.name - dest_target_dir.mkdir(parents=True, exist_ok=True) copied_targets.add(target_dir.name) - for component in components_set: + dest_target_dir = vendor_dest / target_dir.name + + if CODEX_PACKAGE_COMPONENT in components_set: + validate_codex_package_dir(target_dir) + if dest_target_dir.exists(): + shutil.rmtree(dest_target_dir) + dest_target_dir.mkdir(parents=True, exist_ok=True) + for entry in CODEX_PACKAGE_ENTRIES: + src = target_dir / entry + dest = dest_target_dir / entry + if src.is_dir(): + shutil.copytree(src, dest) + else: + shutil.copy2(src, dest) + else: + dest_target_dir.mkdir(parents=True, exist_ok=True) + + for component in components_set - {CODEX_PACKAGE_COMPONENT}: dest_dir_name = COMPONENT_DEST_DIR.get(component) if dest_dir_name is None: continue @@ -431,6 +452,35 @@ def copy_native_binaries( raise RuntimeError(f"Missing target directories in vendor source: {missing_list}") +def validate_codex_package_dir(package_dir: Path) -> None: + is_windows = "windows" in package_dir.name + required_files = [ + Path("codex-package.json"), + Path("bin") / ("codex.exe" if is_windows else "codex"), + Path("codex-path") / ("rg.exe" if is_windows else "rg"), + ] + + if "linux" in package_dir.name: + required_files.append(Path("codex-resources") / "bwrap") + + if is_windows: + required_files.extend( + [ + Path("codex-resources") / "codex-command-runner.exe", + Path("codex-resources") / "codex-windows-sandbox-setup.exe", + ] + ) + + missing_files = [ + str(relative_path) + for relative_path in required_files + if not (package_dir / relative_path).is_file() + ] + if missing_files: + missing = ", ".join(missing_files) + raise RuntimeError(f"Missing files in Codex package directory {package_dir}: {missing}") + + def run_npm_pack(staging_dir: Path, output_path: Path) -> Path: output_path = output_path.resolve() output_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/codex-cli/scripts/install_native_deps.py b/codex-cli/scripts/install_native_deps.py index 79f596fbaf..b785b2a877 100755 --- a/codex-cli/scripts/install_native_deps.py +++ b/codex-cli/scripts/install_native_deps.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Install Codex native binaries (Rust CLI, bwrap, and ripgrep helpers).""" +"""Install Codex package archives and native helper binaries.""" import argparse from contextlib import contextmanager @@ -20,7 +20,7 @@ from urllib.request import urlopen SCRIPT_DIR = Path(__file__).resolve().parent CODEX_CLI_ROOT = SCRIPT_DIR.parent -DEFAULT_WORKFLOW_URL = "https://github.com/openai/codex/actions/runs/17952349351" # rust-v0.40.0 +DEFAULT_WORKFLOW_URL = "https://github.com/openai/codex/actions/runs/26131514935" # rust-v0.132.0 VENDOR_DIR_NAME = "vendor" RG_MANIFEST = CODEX_CLI_ROOT / "bin" / "rg" BINARY_TARGETS = ( @@ -31,6 +31,7 @@ BINARY_TARGETS = ( "x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc", ) +CODEX_PACKAGE_COMPONENT = "codex-package" @dataclass(frozen=True) @@ -139,11 +140,20 @@ def parse_args() -> argparse.Namespace: "--component", dest="components", action="append", - choices=tuple(list(BINARY_COMPONENTS) + ["rg"]), + choices=tuple([CODEX_PACKAGE_COMPONENT, *BINARY_COMPONENTS, "rg"]), help=( "Limit installation to the specified components." - " May be repeated. Defaults to bwrap, codex, codex-windows-sandbox-setup," - " codex-command-runner, and rg." + " May be repeated. Defaults to codex-package and codex-responses-api-proxy." + ), + ) + parser.add_argument( + "--allow-legacy-codex-package", + action="store_true", + help=( + "Allow codex-package to be synthesized from legacy per-binary artifacts " + "when package archives are missing. Intended for CI compatibility only; " + "release staging should not use this. Automatically enabled for the " + "built-in default workflow." ), ) parser.add_argument( @@ -165,17 +175,11 @@ def main() -> int: vendor_dir = codex_cli_root / VENDOR_DIR_NAME vendor_dir.mkdir(parents=True, exist_ok=True) - components = args.components or [ - "bwrap", - "codex", - "codex-windows-sandbox-setup", - "codex-command-runner", - "rg", - ] + components = args.components or [CODEX_PACKAGE_COMPONENT, "codex-responses-api-proxy"] - workflow_url = (args.workflow_url or DEFAULT_WORKFLOW_URL).strip() - if not workflow_url: - workflow_url = DEFAULT_WORKFLOW_URL + workflow_override = (args.workflow_url or "").strip() + use_default_workflow = not workflow_override + workflow_url = workflow_override or DEFAULT_WORKFLOW_URL workflow_id = workflow_url.rstrip("/").split("/")[-1] print(f"Downloading native artifacts from workflow {workflow_id}...") @@ -184,6 +188,18 @@ def main() -> int: with tempfile.TemporaryDirectory(prefix="codex-native-artifacts-") as artifacts_dir_str: artifacts_dir = Path(artifacts_dir_str) _download_artifacts(workflow_id, artifacts_dir) + if CODEX_PACKAGE_COMPONENT in components: + try: + install_codex_package_archives(artifacts_dir, vendor_dir, BINARY_TARGETS) + except FileNotFoundError: + if not (args.allow_legacy_codex_package or use_default_workflow): + raise + install_legacy_codex_package_layouts( + artifacts_dir, + vendor_dir, + BINARY_TARGETS, + manifest_path=RG_MANIFEST, + ) install_binary_components( artifacts_dir, vendor_dir, @@ -199,6 +215,135 @@ def main() -> int: return 0 +def install_codex_package_archives( + artifacts_dir: Path, + vendor_dir: Path, + targets: Sequence[str], +) -> None: + targets = list(targets) + if not targets: + return + + print("Installing Codex package archives for targets: " + ", ".join(targets)) + max_workers = min(len(targets), max(1, (os.cpu_count() or 1))) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit( + _install_single_codex_package_archive, + artifacts_dir, + vendor_dir, + target, + ): target + for target in targets + } + for future in as_completed(futures): + installed_path = future.result() + print(f" installed {installed_path}") + + +def _install_single_codex_package_archive( + artifacts_dir: Path, + vendor_dir: Path, + target: str, +) -> Path: + artifact_subdir = artifact_dir_for_target(artifacts_dir, target) + archive_path = artifact_subdir / f"codex-package-{target}.tar.gz" + if not archive_path.exists(): + raise FileNotFoundError(f"Expected package archive not found: {archive_path}") + + dest_dir = vendor_dir / target + if dest_dir.exists(): + shutil.rmtree(dest_dir) + dest_dir.mkdir(parents=True, exist_ok=True) + + with tarfile.open(archive_path, "r:gz") as archive: + archive.extractall(dest_dir, filter="data") + + return dest_dir + + +def install_legacy_codex_package_layouts( + artifacts_dir: Path, + vendor_dir: Path, + targets: Sequence[str], + *, + manifest_path: Path, +) -> None: + targets = list(targets) + print( + "Synthesizing Codex package layouts from legacy artifacts for targets: " + + ", ".join(targets) + ) + with tempfile.TemporaryDirectory(prefix="codex-legacy-package-") as legacy_vendor_dir_str: + legacy_vendor_dir = Path(legacy_vendor_dir_str) + install_binary_components( + artifacts_dir, + legacy_vendor_dir, + [ + BINARY_COMPONENTS["codex"], + BINARY_COMPONENTS["bwrap"], + BINARY_COMPONENTS["codex-windows-sandbox-setup"], + BINARY_COMPONENTS["codex-command-runner"], + ], + ) + fetch_rg(legacy_vendor_dir, targets, manifest_path=manifest_path) + + for target in targets: + dest_dir = vendor_dir / target + if dest_dir.exists(): + shutil.rmtree(dest_dir) + _build_legacy_codex_package_layout(legacy_vendor_dir / target, dest_dir, target) + print(f" synthesized {dest_dir}") + + +def _build_legacy_codex_package_layout( + legacy_target_dir: Path, + package_dir: Path, + target: str, +) -> None: + is_windows = "windows" in target + exe_suffix = ".exe" if is_windows else "" + package_dir.mkdir(parents=True) + + bin_dir = package_dir / "bin" + resources_dir = package_dir / "codex-resources" + path_dir = package_dir / "codex-path" + bin_dir.mkdir() + resources_dir.mkdir() + path_dir.mkdir() + + shutil.copy2( + legacy_target_dir / "codex" / f"codex{exe_suffix}", + bin_dir / f"codex{exe_suffix}", + ) + shutil.copy2( + legacy_target_dir / "path" / f"rg{exe_suffix}", + path_dir / f"rg{exe_suffix}", + ) + + if is_windows: + for helper in [ + "codex-command-runner.exe", + "codex-windows-sandbox-setup.exe", + ]: + shutil.copy2(legacy_target_dir / "codex" / helper, resources_dir / helper) + elif "linux" in target: + shutil.copy2(legacy_target_dir / "codex-resources" / "bwrap", resources_dir / "bwrap") + + write_json( + package_dir / "codex-package.json", + { + "layoutVersion": 1, + "version": "unknown", + "target": target, + "variant": "codex", + "entrypoint": f"bin/codex{exe_suffix}", + "resourcesDir": "codex-resources", + "pathDir": "codex-path", + }, + ) + + def fetch_rg( vendor_dir: Path, targets: Sequence[str] | None = None, @@ -319,11 +464,8 @@ def _install_single_binary( target: str, component: BinaryComponent, ) -> Path: - artifact_subdir = artifacts_dir / target - archive_name = _archive_name_for_target(component.artifact_prefix, target) - archive_path = artifact_subdir / archive_name - if not archive_path.exists(): - raise FileNotFoundError(f"Expected artifact not found: {archive_path}") + artifact_subdir = artifact_dir_for_target(artifacts_dir, target) + archive_path = legacy_binary_archive_path(artifact_subdir, component.artifact_prefix, target) dest_dir = vendor_dir / target / component.dest_dir dest_dir.mkdir(parents=True, exist_ok=True) @@ -345,6 +487,28 @@ def _archive_name_for_target(artifact_prefix: str, target: str) -> str: return f"{artifact_prefix}-{target}.zst" +def legacy_binary_archive_path(artifact_dir: Path, artifact_prefix: str, target: str) -> Path: + archive_names = [_archive_name_for_target(artifact_prefix, target)] + if artifact_dir.name == f"{target}-unsigned": + archive_names.append(_archive_name_for_target(artifact_prefix, f"{target}-unsigned")) + + for archive_name in archive_names: + archive_path = artifact_dir / archive_name + if archive_path.exists(): + return archive_path + + raise FileNotFoundError(f"Expected artifact not found: {artifact_dir / archive_names[0]}") + + +def artifact_dir_for_target(artifacts_dir: Path, target: str) -> Path: + for artifact_name in [target, f"{target}-unsigned"]: + artifact_dir = artifacts_dir / artifact_name + if artifact_dir.is_dir(): + return artifact_dir + + return artifacts_dir / target + + def _fetch_single_rg( vendor_dir: Path, target: str, @@ -477,6 +641,12 @@ def _load_manifest(manifest_path: Path) -> dict: return manifest +def write_json(path: Path, value: object) -> None: + with open(path, "w", encoding="utf-8") as out: + json.dump(value, out, indent=2) + out.write("\n") + + if __name__ == "__main__": import sys diff --git a/scripts/stage_npm_packages.py b/scripts/stage_npm_packages.py index 2a0cd0028d..4eb69053eb 100755 --- a/scripts/stage_npm_packages.py +++ b/scripts/stage_npm_packages.py @@ -1,8 +1,6 @@ #!/usr/bin/env python3 """Stage one or more Codex npm packages for release.""" -from __future__ import annotations - import argparse import importlib.util import json @@ -68,6 +66,15 @@ def parse_args() -> argparse.Namespace: "Intended for CI compatibility only; release staging should not use this." ), ) + parser.add_argument( + "--allow-legacy-codex-package", + action="store_true", + help=( + "Allow codex-package layouts to be synthesized from legacy per-binary " + "workflow artifacts. Intended for CI compatibility only; release staging " + "should not use this." + ), + ) return parser.parse_args() @@ -124,11 +131,15 @@ def install_native_components( workflow_url: str, components: set[str], vendor_root: Path, + *, + allow_legacy_codex_package: bool, ) -> None: if not components: return cmd = [str(INSTALL_NATIVE_DEPS), "--workflow-url", workflow_url] + if allow_legacy_codex_package: + cmd.append("--allow-legacy-codex-package") for component in sorted(components): cmd.extend(["--component", component]) cmd.append(str(vendor_root)) @@ -172,7 +183,12 @@ def main() -> int: args.release_version, args.workflow_url ) vendor_temp_root = Path(tempfile.mkdtemp(prefix="npm-native-", dir=runner_temp)) - install_native_components(workflow_url, native_components_to_install, vendor_temp_root) + install_native_components( + workflow_url, + native_components_to_install, + vendor_temp_root, + allow_legacy_codex_package=args.allow_legacy_codex_package, + ) vendor_src = vendor_temp_root / "vendor" if resolved_head_sha: From 0edcc4b94e63b208fbc18d4fabf3b5a7ca8b3e12 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Wed, 20 May 2026 12:15:44 -0700 Subject: [PATCH 52/63] fix(config): resolve cloud requirements deny-read globs (#23729) ## Why Cloud-managed `requirements.toml` contents were deserialized without an `AbsolutePathBuf` base directory. Relative managed `permissions.filesystem.deny_read` glob entries therefore failed while the equivalent local system requirements path succeeded under its `AbsolutePathBufGuard`. This follows the `codex_home` base path convention clarified in https://github.com/openai/codex/pull/15707. ## What changed - Resolve cloud requirements TOML under an `AbsolutePathBufGuard` rooted at `codex_home`. - Reuse the same base for cloud requirements loaded from the signed cache. - Add a regression test for a relative cloud-managed `deny_read` glob. ## Validation - `just fmt` - `cargo test -p codex-cloud-requirements` - `cargo clippy -p codex-cloud-requirements --all-targets --no-deps` - `just bazel-lock-update` - `just bazel-lock-check` - `git diff --check` --- codex-rs/Cargo.lock | 1 + codex-rs/cloud-requirements/Cargo.toml | 1 + codex-rs/cloud-requirements/src/lib.rs | 101 +++++++++++++++++++------ 3 files changed, 78 insertions(+), 25 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 72d395c273..823eb6d3c1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2321,6 +2321,7 @@ dependencies = [ "codex-login", "codex-otel", "codex-protocol", + "codex-utils-absolute-path", "hmac", "pretty_assertions", "serde", diff --git a/codex-rs/cloud-requirements/Cargo.toml b/codex-rs/cloud-requirements/Cargo.toml index cc7aefc478..f4b2d2dbcb 100644 --- a/codex-rs/cloud-requirements/Cargo.toml +++ b/codex-rs/cloud-requirements/Cargo.toml @@ -17,6 +17,7 @@ codex-core = { workspace = true } codex-login = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } +codex-utils-absolute-path = { workspace = true } hmac = "0.12.1" serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 885e907ef8..b9740602dd 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -25,11 +25,13 @@ use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::RefreshTokenError; use codex_protocol::account::PlanType; +use codex_utils_absolute_path::AbsolutePathBufGuard; use hmac::Hmac; use hmac::Mac; use serde::Deserialize; use serde::Serialize; use sha2::Sha256; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; @@ -138,10 +140,12 @@ struct CloudRequirementsCacheSignedPayload { } impl CloudRequirementsCacheSignedPayload { - fn requirements(&self) -> Option { - self.contents - .as_deref() - .and_then(|contents| parse_cloud_requirements(contents).ok().flatten()) + fn requirements(&self, requirements_base_dir: &Path) -> Option { + self.contents.as_deref().and_then(|contents| { + parse_cloud_requirements(contents, requirements_base_dir) + .ok() + .flatten() + }) } } fn sign_cache_payload(payload_bytes: &[u8]) -> Option { @@ -258,6 +262,7 @@ impl RequirementsFetcher for BackendRequirementsFetcher { struct CloudRequirementsService { auth_manager: Arc, fetcher: Arc, + requirements_base_dir: PathBuf, cache_path: PathBuf, timeout: Duration, } @@ -272,6 +277,7 @@ impl CloudRequirementsService { Self { auth_manager, fetcher, + requirements_base_dir: codex_home.clone(), cache_path: codex_home.join(CLOUD_REQUIREMENTS_CACHE_FILENAME), timeout, } @@ -351,7 +357,7 @@ impl CloudRequirementsService { path = %self.cache_path.display(), "Using cached cloud requirements" ); - return Ok(signed_payload.requirements()); + return Ok(signed_payload.requirements(&self.requirements_base_dir)); } Err(cache_load_status) => { self.log_cache_load_status(&cache_load_status); @@ -482,24 +488,26 @@ impl CloudRequirementsService { }; let requirements = match contents.as_deref() { - Some(contents) => match parse_cloud_requirements(contents) { - Ok(requirements) => requirements, - Err(err) => { - tracing::error!(error = %err, "Failed to parse cloud requirements"); - emit_fetch_final_metric( - trigger, - "error", - "parse_error", - attempt, - last_status_code, - ); - return Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Parse, - /*status_code*/ None, - format_cloud_requirements_parse_failed_message(contents, &err), - )); + Some(contents) => { + match parse_cloud_requirements(contents, &self.requirements_base_dir) { + Ok(requirements) => requirements, + Err(err) => { + tracing::error!(error = %err, "Failed to parse cloud requirements"); + emit_fetch_final_metric( + trigger, + "error", + "parse_error", + attempt, + last_status_code, + ); + return Err(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Parse, + /*status_code*/ None, + format_cloud_requirements_parse_failed_message(contents, &err), + )); + } } - }, + } None => None, }; @@ -738,11 +746,13 @@ pub async fn cloud_requirements_loader_for_storage( fn parse_cloud_requirements( contents: &str, + requirements_base_dir: &Path, ) -> Result, toml::de::Error> { if contents.trim().is_empty() { return Ok(None); } + let _guard = AbsolutePathBufGuard::new(requirements_base_dir); let requirements: ConfigRequirementsToml = toml::from_str(contents)?; if requirements.is_empty() { Ok(None) @@ -1038,7 +1048,11 @@ mod tests { } fn parse_for_fetch(contents: Option<&str>) -> Option { - contents.and_then(|contents| parse_cloud_requirements(contents).ok().flatten()) + contents.and_then(|contents| { + parse_cloud_requirements(contents, &std::env::temp_dir()) + .ok() + .flatten() + }) } fn request_error() -> FetchAttemptError { @@ -1391,6 +1405,35 @@ mod tests { ); } + #[tokio::test] + async fn fetch_cloud_requirements_resolves_relative_deny_read_globs_from_codex_home() { + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager_with_plan("enterprise").await, + Arc::new(StaticFetcher { + contents: Some( + r#" +[permissions.filesystem] +deny_read = ["./sensitive/**/*.txt"] +"# + .to_string(), + ), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + let deny_read = format!("{}/sensitive/**/*.txt", codex_home.path().display()); + let expected = toml::from_str::(&format!( + r#" +[permissions.filesystem] +deny_read = [{deny_read:?}] +"# + )) + .expect("parse expected cloud requirements"); + + assert_eq!(service.fetch().await, Ok(Some(expected))); + } + #[tokio::test] async fn fetch_cloud_requirements_parses_apps_requirements_toml() { let result = parse_for_fetch(Some( @@ -2234,7 +2277,11 @@ command = "sample-mcp" .signed_payload .contents .as_deref() - .and_then(|contents| parse_cloud_requirements(contents).ok().flatten()), + .and_then(|contents| { + parse_cloud_requirements(contents, codex_home.path()) + .ok() + .flatten() + }), Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, @@ -2358,7 +2405,11 @@ command = "sample-mcp" .signed_payload .contents .as_deref() - .and_then(|contents| parse_cloud_requirements(contents).ok().flatten()), + .and_then(|contents| { + parse_cloud_requirements(contents, codex_home.path()) + .ok() + .flatten() + }), Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), allowed_approvals_reviewers: None, From cb05de6724085dfff600a933b78bef7fe078f94a Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 20 May 2026 12:18:10 -0700 Subject: [PATCH 53/63] dotslash: publish Codex entrypoints from package archives (#23638) ## Summary DotSlash should resolve the same canonical package archives used by standalone installers and npm platform packages, rather than continuing to point at single-binary zstd artifacts or the older Linux bundle archive. This updates the Codex CLI and `codex-app-server` DotSlash release config entries to match `codex-package-.tar.gz` and `codex-app-server-package-.tar.gz`, with paths that select `bin/codex` or `bin/codex-app-server` inside the extracted package. The other helper outputs stay on their existing per-binary artifacts for now. ## Test plan - `python3 -m json.tool .github/dotslash-config.json > /dev/null` - Ran a Python regex smoke test that checked every updated `codex` and `codex-app-server` platform entry against the archive names emitted by `.github/scripts/build-codex-package-archive.sh`. --- .github/dotslash-config.json | 48 +++++++++---------- .../scripts/build-codex-package-archive.sh | 10 +++- .github/workflows/rust-release-windows.yml | 8 ++-- scripts/codex_package/cli.py | 9 ++-- 4 files changed, 41 insertions(+), 34 deletions(-) diff --git a/.github/dotslash-config.json b/.github/dotslash-config.json index a0297c269a..78ea6b37e6 100644 --- a/.github/dotslash-config.json +++ b/.github/dotslash-config.json @@ -3,56 +3,56 @@ "codex": { "platforms": { "macos-aarch64": { - "regex": "^codex-aarch64-apple-darwin\\.zst$", - "path": "codex" + "regex": "^codex-package-aarch64-apple-darwin\\.tar\\.zst$", + "path": "bin/codex" }, "macos-x86_64": { - "regex": "^codex-x86_64-apple-darwin\\.zst$", - "path": "codex" + "regex": "^codex-package-x86_64-apple-darwin\\.tar\\.zst$", + "path": "bin/codex" }, "linux-x86_64": { - "regex": "^codex-x86_64-unknown-linux-musl-bundle\\.tar\\.zst$", - "path": "codex" + "regex": "^codex-package-x86_64-unknown-linux-musl\\.tar\\.zst$", + "path": "bin/codex" }, "linux-aarch64": { - "regex": "^codex-aarch64-unknown-linux-musl-bundle\\.tar\\.zst$", - "path": "codex" + "regex": "^codex-package-aarch64-unknown-linux-musl\\.tar\\.zst$", + "path": "bin/codex" }, "windows-x86_64": { - "regex": "^codex-x86_64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex.exe" + "regex": "^codex-package-x86_64-pc-windows-msvc\\.tar\\.zst$", + "path": "bin/codex.exe" }, "windows-aarch64": { - "regex": "^codex-aarch64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex.exe" + "regex": "^codex-package-aarch64-pc-windows-msvc\\.tar\\.zst$", + "path": "bin/codex.exe" } } }, "codex-app-server": { "platforms": { "macos-aarch64": { - "regex": "^codex-app-server-aarch64-apple-darwin\\.zst$", - "path": "codex-app-server" + "regex": "^codex-app-server-package-aarch64-apple-darwin\\.tar\\.zst$", + "path": "bin/codex-app-server" }, "macos-x86_64": { - "regex": "^codex-app-server-x86_64-apple-darwin\\.zst$", - "path": "codex-app-server" + "regex": "^codex-app-server-package-x86_64-apple-darwin\\.tar\\.zst$", + "path": "bin/codex-app-server" }, "linux-x86_64": { - "regex": "^codex-app-server-x86_64-unknown-linux-musl\\.zst$", - "path": "codex-app-server" + "regex": "^codex-app-server-package-x86_64-unknown-linux-musl\\.tar\\.zst$", + "path": "bin/codex-app-server" }, "linux-aarch64": { - "regex": "^codex-app-server-aarch64-unknown-linux-musl\\.zst$", - "path": "codex-app-server" + "regex": "^codex-app-server-package-aarch64-unknown-linux-musl\\.tar\\.zst$", + "path": "bin/codex-app-server" }, "windows-x86_64": { - "regex": "^codex-app-server-x86_64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex-app-server.exe" + "regex": "^codex-app-server-package-x86_64-pc-windows-msvc\\.tar\\.zst$", + "path": "bin/codex-app-server.exe" }, "windows-aarch64": { - "regex": "^codex-app-server-aarch64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex-app-server.exe" + "regex": "^codex-app-server-package-aarch64-pc-windows-msvc\\.tar\\.zst$", + "path": "bin/codex-app-server.exe" } } }, diff --git a/.github/scripts/build-codex-package-archive.sh b/.github/scripts/build-codex-package-archive.sh index 90eae12ef0..c475ffe952 100644 --- a/.github/scripts/build-codex-package-archive.sh +++ b/.github/scripts/build-codex-package-archive.sh @@ -97,9 +97,14 @@ else python_bin="python" fi +if ! command -v zstd >/dev/null 2>&1 && [[ -x "${repo_root}/.github/workflows/zstd" ]]; then + export PATH="${repo_root}/.github/workflows:${PATH}" +fi + mkdir -p "$archive_dir" package_dir="${RUNNER_TEMP:-/tmp}/${archive_stem}-${target}" -archive_path="${archive_dir}/${archive_stem}-${target}.tar.gz" +gzip_archive_path="${archive_dir}/${archive_stem}-${target}.tar.gz" +zstd_archive_path="${archive_dir}/${archive_stem}-${target}.tar.zst" rm -rf "$package_dir" "$python_bin" "${repo_root}/scripts/build_codex_package.py" \ @@ -108,5 +113,6 @@ rm -rf "$package_dir" --entrypoint-bin "${entrypoint_dir%/}/${entrypoint_name}${exe_suffix}" \ --cargo-profile release \ --package-dir "$package_dir" \ - --archive-output "$archive_path" \ + --archive-output "$gzip_archive_path" \ + --archive-output "$zstd_archive_path" \ --force diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index ac28b7855a..12be586188 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -220,6 +220,9 @@ jobs: "$dest/${binary}-${{ matrix.target }}.exe" done + - name: Install DotSlash + uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 + - name: Build Codex package archives shell: bash run: | @@ -274,9 +277,6 @@ jobs: path: python-runtime-dist/${{ matrix.target }}/*.whl if-no-files-found: error - - name: Install DotSlash - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - - name: Compress artifacts shell: bash run: | @@ -295,7 +295,7 @@ jobs: base="$(basename "$f")" # Skip files that are already archives (shouldn't happen, but be # safe). - if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then + if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then continue fi diff --git a/scripts/codex_package/cli.py b/scripts/codex_package/cli.py index 16324cf44d..0ca9f5d35e 100644 --- a/scripts/codex_package/cli.py +++ b/scripts/codex_package/cli.py @@ -50,9 +50,11 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--archive-output", type=Path, + action="append", + default=[], help=( - "Optional archive output path. Supported suffixes: .tar.gz, .tgz, " - ".tar.zst, .zip." + "Optional archive output path. May be repeated. Supported suffixes: " + ".tar.gz, .tgz, .tar.zst, .zip." ), ) parser.add_argument( @@ -130,8 +132,7 @@ def main() -> int: build_package_dir(package_dir, version, variant, spec, inputs) validate_package_dir(package_dir, variant, spec) - archive_output = args.archive_output - if archive_output is not None: + for archive_output in args.archive_output: archive_path = archive_output.resolve() write_archive(package_dir, archive_path, force=args.force) print(f"Built Codex package archive at {archive_path}") From 729bdf3c8d4995c10491debba476a9a3068ac183 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 20 May 2026 12:41:06 -0700 Subject: [PATCH 54/63] windows-sandbox: send permission profiles to elevated runner (#22918) ## Why This is the next PR in the Windows sandbox migration stack after #22896. The bottom PR introduces a Windows-local resolved permissions helper while existing callers still start from legacy `SandboxPolicy`. This PR moves the elevated runner IPC boundary to `PermissionProfile`, which makes the direction of the stack visible without changing the public core call sites yet. Because that changes the CLI-to-command-runner message shape, the framed IPC protocol version is bumped in the same PR so the boundary change is explicit. ## What changed - Replaced elevated IPC `policy_json_or_preset`/`sandbox_policy_cwd` fields with `permission_profile`/`permission_profile_cwd`. - Bumped the elevated command-runner IPC protocol to `IPC_PROTOCOL_VERSION = 2` and switched parent/runner frames to use the shared constant. - Converted the parent elevated paths from the parsed legacy policy into a materialized `PermissionProfile` before sending the runner request. - Added `WindowsSandboxTokenMode` resolution for managed `PermissionProfile` values and made the runner choose read-only vs writable-root capability tokens from that resolved profile. - Rejected disabled, external, unrestricted, and full-disk-write profiles before token selection. - Added IPC JSON coverage for tagged `PermissionProfile` payloads and token-mode unit coverage for the resolved permission helper. ## Verification - `cargo test -p codex-windows-sandbox` - `just fix -p codex-windows-sandbox` - `cargo check -p codex-windows-sandbox --target x86_64-pc-windows-msvc --tests` was attempted locally but blocked before crate type-checking because the macOS compiler environment lacks Windows C headers such as `windows.h` and `assert.h`; GitHub Windows CI is the required verification for the runner path. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22918). * #23715 * #23714 * #23167 * #22923 * __->__ #22918 --- .../src/bin/command_runner/win.rs | 31 +-- .../src/elevated/ipc_framed.rs | 52 ++++- .../src/elevated/runner_client.rs | 3 +- .../windows-sandbox-rs/src/elevated_impl.rs | 7 +- codex-rs/windows-sandbox-rs/src/lib.rs | 6 + .../src/resolved_permissions.rs | 182 ++++++++++++++++++ .../src/unified_exec/backends/elevated.rs | 12 +- .../unified_exec/backends/windows_common.rs | 7 +- 8 files changed, 273 insertions(+), 27 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/bin/command_runner/win.rs b/codex-rs/windows-sandbox-rs/src/bin/command_runner/win.rs index b161dd57b8..afdbdd5813 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/command_runner/win.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/command_runner/win.rs @@ -16,17 +16,18 @@ use anyhow::Result; use codex_windows_sandbox::ErrorPayload; use codex_windows_sandbox::ExitPayload; use codex_windows_sandbox::FramedMessage; +use codex_windows_sandbox::IPC_PROTOCOL_VERSION; use codex_windows_sandbox::LocalSid; use codex_windows_sandbox::Message; use codex_windows_sandbox::OutputPayload; use codex_windows_sandbox::OutputStream; use codex_windows_sandbox::PipeSpawnHandles; use codex_windows_sandbox::ResizePayload; -use codex_windows_sandbox::SandboxPolicy; use codex_windows_sandbox::SpawnReady; use codex_windows_sandbox::SpawnRequest; use codex_windows_sandbox::StderrMode; use codex_windows_sandbox::StdinMode; +use codex_windows_sandbox::WindowsSandboxTokenMode; use codex_windows_sandbox::allow_null_device; use codex_windows_sandbox::create_readonly_token_with_caps_and_user_from; use codex_windows_sandbox::create_workspace_write_token_with_caps_and_user_from; @@ -35,11 +36,11 @@ use codex_windows_sandbox::encode_bytes; use codex_windows_sandbox::get_current_token_for_restriction; use codex_windows_sandbox::hide_current_user_profile_dir; use codex_windows_sandbox::log_note; -use codex_windows_sandbox::parse_policy; use codex_windows_sandbox::read_frame; use codex_windows_sandbox::read_handle_loop; use codex_windows_sandbox::spawn_process_with_pipes; use codex_windows_sandbox::to_wide; +use codex_windows_sandbox::token_mode_for_permission_profile; use codex_windows_sandbox::write_frame; use std::ffi::OsStr; use std::fs::File; @@ -167,7 +168,7 @@ fn open_pipe(name: &str, access: u32) -> Result { /// Send an error frame back to the parent process. fn send_error(writer: &Arc>, code: &str, message: String) -> Result<()> { let msg = FramedMessage { - version: 1, + version: IPC_PROTOCOL_VERSION, message: Message::Error { payload: ErrorPayload { message, @@ -186,7 +187,7 @@ fn read_spawn_request(reader: &mut File) -> Result { let Some(msg) = read_frame(reader)? else { anyhow::bail!("runner: pipe closed before spawn_request"); }; - if msg.version != 1 { + if msg.version != IPC_PROTOCOL_VERSION { anyhow::bail!("runner: unsupported protocol version {}", msg.version); } match msg.message { @@ -235,7 +236,12 @@ fn effective_cwd(req_cwd: &Path, log_dir: Option<&Path>) -> PathBuf { fn spawn_ipc_process(req: &SpawnRequest) -> Result { let log_dir = req.codex_home.clone(); hide_current_user_profile_dir(req.codex_home.as_path()); - let policy = parse_policy(&req.policy_json_or_preset).context("parse policy_json_or_preset")?; + let token_mode = token_mode_for_permission_profile( + &req.permission_profile, + &req.permission_profile_cwd, + &req.env, + ) + .context("resolve permission profile token mode")?; let mut cap_psids: Vec = Vec::new(); for sid in &req.cap_sids { cap_psids.push( @@ -253,16 +259,13 @@ fn spawn_ipc_process(req: &SpawnRequest) -> Result { let cap_psid_ptrs: Vec<*mut _> = cap_psids.iter().map(LocalSid::as_ptr).collect(); let base = OwnedWinHandle::new(unsafe { get_current_token_for_restriction()? }); let h_token = OwnedWinHandle::new(unsafe { - match &policy { - SandboxPolicy::ReadOnly { .. } => { + match token_mode { + WindowsSandboxTokenMode::ReadOnlyCapability => { create_readonly_token_with_caps_and_user_from(base.raw(), &cap_psid_ptrs) } - SandboxPolicy::WorkspaceWrite { .. } => { + WindowsSandboxTokenMode::WritableRootsCapability => { create_workspace_write_token_with_caps_and_user_from(base.raw(), &cap_psid_ptrs) } - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { - unreachable!() - } } }?); unsafe { @@ -352,7 +355,7 @@ fn spawn_output_reader( ) -> std::thread::JoinHandle<()> { read_handle_loop(handle, move |chunk| { let msg = FramedMessage { - version: 1, + version: IPC_PROTOCOL_VERSION, message: Message::Output { payload: OutputPayload { data_b64: encode_bytes(chunk), @@ -554,7 +557,7 @@ pub fn main() -> Result<()> { let process_handle = Arc::new(StdMutex::new(Some(pi.hProcess))); let msg = FramedMessage { - version: 1, + version: IPC_PROTOCOL_VERSION, message: Message::SpawnReady { payload: SpawnReady { process_id: unsafe { GetProcessId(pi.hProcess) }, @@ -631,7 +634,7 @@ pub fn main() -> Result<()> { } let exit_msg = FramedMessage { - version: 1, + version: IPC_PROTOCOL_VERSION, message: Message::Exit { payload: ExitPayload { exit_code, diff --git a/codex-rs/windows-sandbox-rs/src/elevated/ipc_framed.rs b/codex-rs/windows-sandbox-rs/src/elevated/ipc_framed.rs index 56e33e57ca..5512ebefb4 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated/ipc_framed.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated/ipc_framed.rs @@ -10,6 +10,7 @@ use anyhow::Result; use base64::Engine as _; use base64::engine::general_purpose::STANDARD; +use codex_protocol::models::PermissionProfile; use serde::Deserialize; use serde::Serialize; use std::collections::HashMap; @@ -23,6 +24,9 @@ use std::path::PathBuf; /// obviously invalid frames. const MAX_FRAME_LEN: usize = 8 * 1024 * 1024; +/// Protocol version shared by the parent process and elevated command runner. +pub const IPC_PROTOCOL_VERSION: u8 = 2; + /// Length-prefixed, JSON-encoded frame. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct FramedMessage { @@ -55,8 +59,8 @@ pub struct SpawnRequest { pub command: Vec, pub cwd: PathBuf, pub env: HashMap, - pub policy_json_or_preset: String, - pub sandbox_policy_cwd: PathBuf, + pub permission_profile: PermissionProfile, + pub permission_profile_cwd: PathBuf, pub codex_home: PathBuf, pub real_codex_home: PathBuf, pub cap_sids: Vec, @@ -164,11 +168,12 @@ pub fn read_frame(mut reader: R) -> Result> { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn framed_round_trip() { let msg = FramedMessage { - version: 1, + version: IPC_PROTOCOL_VERSION, message: Message::Output { payload: OutputPayload { data_b64: encode_bytes(b"hello"), @@ -179,7 +184,7 @@ mod tests { let mut buf = Vec::new(); write_frame(&mut buf, &msg).expect("write"); let decoded = read_frame(buf.as_slice()).expect("read").expect("some"); - assert_eq!(decoded.version, 1); + assert_eq!(decoded.version, IPC_PROTOCOL_VERSION); match decoded.message { Message::Output { payload } => { assert_eq!(payload.stream, OutputStream::Stdout); @@ -189,4 +194,43 @@ mod tests { other => panic!("unexpected message: {other:?}"), } } + + #[test] + fn spawn_request_serializes_permission_profile() { + let msg = FramedMessage { + version: IPC_PROTOCOL_VERSION, + message: Message::SpawnRequest { + payload: Box::new(SpawnRequest { + command: vec!["cmd.exe".to_string(), "/c".to_string(), "ver".to_string()], + cwd: PathBuf::from(r"C:\workspace"), + env: HashMap::new(), + permission_profile: PermissionProfile::read_only(), + permission_profile_cwd: PathBuf::from(r"C:\workspace"), + codex_home: PathBuf::from(r"C:\codex"), + real_codex_home: PathBuf::from(r"C:\Users\codex"), + cap_sids: vec!["S-1-15-3-1024-1".to_string()], + timeout_ms: Some(1000), + tty: false, + stdin_open: false, + use_private_desktop: false, + }), + }, + }; + + let encoded = serde_json::to_value(&msg).expect("serialize"); + assert_eq!("spawn_request", encoded["type"]); + assert_eq!("managed", encoded["payload"]["permission_profile"]["type"]); + assert_eq!(None, encoded["payload"].get("policy_json_or_preset")); + assert_eq!(None, encoded["payload"].get("sandbox_policy_cwd")); + + let decoded: FramedMessage = serde_json::from_value(encoded).expect("deserialize"); + let Message::SpawnRequest { payload } = decoded.message else { + panic!("unexpected message"); + }; + assert_eq!(PermissionProfile::read_only(), payload.permission_profile); + assert_eq!( + PathBuf::from(r"C:\workspace"), + payload.permission_profile_cwd + ); + } } diff --git a/codex-rs/windows-sandbox-rs/src/elevated/runner_client.rs b/codex-rs/windows-sandbox-rs/src/elevated/runner_client.rs index d313b6cfcc..0da7faae4d 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated/runner_client.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated/runner_client.rs @@ -1,5 +1,6 @@ use crate::identity::SandboxCreds; use crate::ipc_framed::FramedMessage; +use crate::ipc_framed::IPC_PROTOCOL_VERSION; use crate::ipc_framed::Message; use crate::ipc_framed::SpawnRequest; use crate::ipc_framed::read_frame; @@ -56,7 +57,7 @@ pub(crate) struct RunnerTransport { impl RunnerTransport { pub(crate) fn send_spawn_request(&mut self, request: SpawnRequest) -> Result<()> { let spawn_request = FramedMessage { - version: 1, + version: IPC_PROTOCOL_VERSION, message: Message::SpawnRequest { payload: Box::new(request), }, diff --git a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs index f43715c122..5861317376 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs @@ -45,6 +45,7 @@ mod windows_impl { use crate::setup::effective_write_roots_for_setup; use crate::token::LocalSid; use anyhow::Result; + use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use std::path::Path; @@ -144,12 +145,14 @@ mod windows_impl { } (|| -> Result { + let permission_profile = + PermissionProfile::from_legacy_sandbox_policy_for_cwd(&policy, sandbox_policy_cwd); let spawn_request = SpawnRequest { command: command.clone(), cwd: cwd.to_path_buf(), env: env_map.clone(), - policy_json_or_preset: policy_json_or_preset.to_string(), - sandbox_policy_cwd: sandbox_policy_cwd.to_path_buf(), + permission_profile, + permission_profile_cwd: sandbox_policy_cwd.to_path_buf(), codex_home: sandbox_base.clone(), real_codex_home: codex_home.to_path_buf(), cap_sids, diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 5accd0be36..d662a62db0 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -155,6 +155,8 @@ pub use ipc_framed::ExitPayload; #[cfg(target_os = "windows")] pub use ipc_framed::FramedMessage; #[cfg(target_os = "windows")] +pub use ipc_framed::IPC_PROTOCOL_VERSION; +#[cfg(target_os = "windows")] pub use ipc_framed::Message; #[cfg(target_os = "windows")] pub use ipc_framed::OutputPayload; @@ -197,6 +199,10 @@ pub use process::read_handle_loop; #[cfg(target_os = "windows")] pub use process::spawn_process_with_pipes; #[cfg(target_os = "windows")] +pub use resolved_permissions::WindowsSandboxTokenMode; +#[cfg(target_os = "windows")] +pub use resolved_permissions::token_mode_for_permission_profile; +#[cfg(target_os = "windows")] pub use setup::SETUP_VERSION; #[cfg(target_os = "windows")] pub use setup::SandboxSetupRequest; diff --git a/codex-rs/windows-sandbox-rs/src/resolved_permissions.rs b/codex-rs/windows-sandbox-rs/src/resolved_permissions.rs index 6b2daa56e0..5c6b0cdffe 100644 --- a/codex-rs/windows-sandbox-rs/src/resolved_permissions.rs +++ b/codex-rs/windows-sandbox-rs/src/resolved_permissions.rs @@ -1,5 +1,8 @@ +use anyhow::Result; +use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::SandboxPolicy; @@ -25,6 +28,33 @@ pub(crate) struct WindowsWritableRoot { pub(crate) read_only_subpaths: Vec, } +/// Restricted-token family needed to enforce a Windows permission profile. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WindowsSandboxTokenMode { + ReadOnlyCapability, + WritableRootsCapability, +} + +/// Chooses the restricted-token family needed for a managed permission profile. +pub fn token_mode_for_permission_profile( + permission_profile: &PermissionProfile, + cwd: &Path, + env_map: &HashMap, +) -> Result { + let permissions = + ResolvedWindowsSandboxPermissions::try_from_permission_profile(permission_profile)?; + if permissions.file_system.has_full_disk_write_access() { + anyhow::bail!( + "permission profile requests full-disk filesystem writes, which cannot be enforced by the Windows sandbox" + ); + } + if permissions.writable_roots_for_cwd(cwd, env_map).is_empty() { + Ok(WindowsSandboxTokenMode::ReadOnlyCapability) + } else { + Ok(WindowsSandboxTokenMode::WritableRootsCapability) + } +} + impl ResolvedWindowsSandboxPermissions { pub(crate) fn from_legacy_policy(policy: &SandboxPolicy) -> Self { Self { @@ -40,6 +70,26 @@ impl ResolvedWindowsSandboxPermissions { } } + pub(crate) fn try_from_permission_profile( + permission_profile: &PermissionProfile, + ) -> Result { + if !matches!(permission_profile, PermissionProfile::Managed { .. }) { + anyhow::bail!( + "only managed permission profiles can be enforced by the Windows sandbox" + ); + } + let (file_system, network) = permission_profile.to_runtime_permissions(); + if !matches!(file_system.kind, FileSystemSandboxKind::Restricted) { + anyhow::bail!( + "only restricted managed filesystem permissions can be enforced by the Windows sandbox" + ); + } + Ok(Self { + file_system, + network, + }) + } + pub(crate) fn should_apply_network_block(&self) -> bool { !self.network.is_enabled() } @@ -114,3 +164,135 @@ fn windows_temp_env_roots(env_map: &HashMap) -> Vec { .filter(|path| path.is_absolute()) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::models::ManagedFileSystemPermissions; + use codex_protocol::permissions::FileSystemAccessMode; + use codex_protocol::permissions::FileSystemSandboxEntry; + use codex_protocol::permissions::FileSystemSpecialPath; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn permission_profile_workspace_write_uses_windows_temp_env_vars() { + let tmp = TempDir::new().expect("tempdir"); + let cwd = tmp.path().join("workspace"); + let temp_dir = tmp.path().join("temp"); + std::fs::create_dir_all(&cwd).expect("create cwd"); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + + let mut env_map = HashMap::new(); + env_map.insert("TEMP".to_string(), temp_dir.to_string_lossy().to_string()); + env_map.insert("TMP".to_string(), temp_dir.to_string_lossy().to_string()); + + let permissions = ResolvedWindowsSandboxPermissions::try_from_permission_profile( + &PermissionProfile::workspace_write(), + ) + .expect("managed permission profile"); + let roots = permissions + .writable_roots_for_cwd(&cwd, &env_map) + .into_iter() + .map(|root| root.root) + .collect::>(); + + let expected_roots = [ + temp_dir, + dunce::canonicalize(&cwd).expect("canonicalize cwd"), + ] + .into_iter() + .collect::>(); + + assert_eq!(expected_roots, roots); + } + + #[test] + fn token_mode_for_profile_without_writable_roots_uses_readonly_capability() { + let tmp = TempDir::new().expect("tempdir"); + let cwd = tmp.path().join("workspace"); + std::fs::create_dir_all(&cwd).expect("create cwd"); + + let token_mode = token_mode_for_permission_profile( + &PermissionProfile::read_only(), + &cwd, + &HashMap::new(), + ) + .expect("token mode"); + + assert_eq!(WindowsSandboxTokenMode::ReadOnlyCapability, token_mode); + } + + #[test] + fn token_mode_for_profile_with_writable_roots_uses_write_capabilities() { + let tmp = TempDir::new().expect("tempdir"); + let cwd = tmp.path().join("workspace"); + std::fs::create_dir_all(&cwd).expect("create cwd"); + + let token_mode = token_mode_for_permission_profile( + &PermissionProfile::workspace_write(), + &cwd, + &HashMap::new(), + ) + .expect("token mode"); + + assert_eq!(WindowsSandboxTokenMode::WritableRootsCapability, token_mode); + } + + #[test] + fn permission_profile_rejects_disabled_profiles() { + let err = ResolvedWindowsSandboxPermissions::try_from_permission_profile( + &PermissionProfile::Disabled, + ) + .expect_err("disabled profile should not resolve for sandbox enforcement"); + + assert!( + err.to_string() + .contains("only managed permission profiles can be enforced") + ); + } + + #[test] + fn permission_profile_rejects_unrestricted_managed_filesystem() { + let permission_profile = PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Unrestricted, + network: NetworkSandboxPolicy::Restricted, + }; + + let err = + ResolvedWindowsSandboxPermissions::try_from_permission_profile(&permission_profile) + .expect_err("unrestricted profile should not resolve for sandbox enforcement"); + + assert!( + err.to_string() + .contains("only restricted managed filesystem permissions can be enforced") + ); + } + + #[test] + fn token_mode_rejects_full_disk_write_entries() { + let tmp = TempDir::new().expect("tempdir"); + let cwd = tmp.path().join("workspace"); + std::fs::create_dir_all(&cwd).expect("create cwd"); + let permission_profile = PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Restricted { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }], + glob_scan_max_depth: None, + }, + network: NetworkSandboxPolicy::Restricted, + }; + + let err = token_mode_for_permission_profile(&permission_profile, &cwd, &HashMap::new()) + .expect_err("full disk writes should not resolve to a token mode"); + + assert!( + err.to_string() + .contains("full-disk filesystem writes, which cannot be enforced") + ); + } +} diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated.rs index 0d7e94a1ff..352226644d 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated.rs @@ -5,11 +5,13 @@ use super::windows_common::start_runner_stdin_writer; use super::windows_common::start_runner_stdout_reader; use crate::ipc_framed::EmptyPayload; use crate::ipc_framed::FramedMessage; +use crate::ipc_framed::IPC_PROTOCOL_VERSION; use crate::ipc_framed::Message; use crate::ipc_framed::SpawnRequest; use crate::runner_client::spawn_runner_transport; use crate::spawn_prep::prepare_elevated_spawn_context; use anyhow::Result; +use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_pty::ProcessDriver; use codex_utils_pty::SpawnedProcess; @@ -60,12 +62,16 @@ pub(crate) async fn spawn_windows_sandbox_session_elevated( &deny_write_paths_override, )?; + let permission_profile = PermissionProfile::from_legacy_sandbox_policy_for_cwd( + &elevated.common.policy, + sandbox_policy_cwd, + ); let spawn_request = SpawnRequest { command: command.clone(), cwd: cwd.to_path_buf(), env: env_map.clone(), - policy_json_or_preset: policy_json_or_preset.to_string(), - sandbox_policy_cwd: sandbox_policy_cwd.to_path_buf(), + permission_profile, + permission_profile_cwd: sandbox_policy_cwd.to_path_buf(), codex_home: elevated.common.sandbox_base.clone(), real_codex_home: codex_home.to_path_buf(), cap_sids: elevated.cap_sids.clone(), @@ -106,7 +112,7 @@ pub(crate) async fn spawn_windows_sandbox_session_elevated( let outbound_tx = outbound_tx.clone(); Some(Box::new(move || { let _ = outbound_tx.send(FramedMessage { - version: 1, + version: IPC_PROTOCOL_VERSION, message: Message::Terminate { payload: EmptyPayload::default(), }, diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/windows_common.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/windows_common.rs index 3396b4ed98..f7be315ad8 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/windows_common.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/windows_common.rs @@ -1,5 +1,6 @@ use crate::ipc_framed::EmptyPayload; use crate::ipc_framed::FramedMessage; +use crate::ipc_framed::IPC_PROTOCOL_VERSION; use crate::ipc_framed::Message; use crate::ipc_framed::OutputStream; use crate::ipc_framed::ResizePayload; @@ -70,7 +71,7 @@ pub(crate) fn start_runner_stdin_writer( bytes }; let msg = FramedMessage { - version: 1, + version: IPC_PROTOCOL_VERSION, message: Message::Stdin { payload: StdinPayload { data_b64: encode_bytes(&bytes), @@ -83,7 +84,7 @@ pub(crate) fn start_runner_stdin_writer( } if stdin_open { let _ = outbound_tx.send(FramedMessage { - version: 1, + version: IPC_PROTOCOL_VERSION, message: Message::CloseStdin { payload: EmptyPayload::default(), }, @@ -165,7 +166,7 @@ pub(crate) fn make_runner_resizer( Box::new(move |size: TerminalSize| { outbound_tx .send(FramedMessage { - version: 1, + version: IPC_PROTOCOL_VERSION, message: Message::Resize { payload: ResizePayload { rows: size.rows, From 61aae565710e0937de6cc9fb110eaf3e40035239 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 20 May 2026 12:50:38 -0700 Subject: [PATCH 55/63] windows-sandbox: share bundled helper lookup (#23735) ## Summary Follow-up to #23636 review feedback: the Windows sandbox had two copies of the same bundled-helper lookup order, one for `codex-command-runner.exe` in `helper_materialization.rs` and one for `codex-windows-sandbox-setup.exe` in `setup.rs`. This PR centralizes that lookup in `helper_materialization::bundled_executable_path_for_exe()` and has setup reuse it for `codex-windows-sandbox-setup.exe`. The lookup behavior is unchanged: direct sibling first, package-root `codex-resources/` when running from `bin/`, then legacy sibling `codex-resources/`. ## Test plan - `cargo test -p codex-windows-sandbox` ## Notes I also attempted `cargo check -p codex-windows-sandbox --target x86_64-pc-windows-gnullvm`, but this local host is missing `x86_64-w64-mingw32-clang`. --- .../src/helper_materialization.rs | 32 +++++++++-------- codex-rs/windows-sandbox-rs/src/setup.rs | 36 ++++--------------- 2 files changed, 24 insertions(+), 44 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs index 3540f4c463..e5b202dede 100644 --- a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs +++ b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs @@ -16,8 +16,8 @@ use crate::logging::log_note; use crate::sandbox_bin_dir; const DEV_BUILD_VERSION_SENTINEL: &str = "0.0.0"; -const BIN_DIRNAME: &str = "bin"; -const RESOURCES_DIRNAME: &str = "codex-resources"; +pub(crate) const BIN_DIRNAME: &str = "bin"; +pub(crate) const RESOURCES_DIRNAME: &str = "codex-resources"; #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub(crate) enum HelperExecutable { @@ -52,7 +52,7 @@ pub(crate) fn helper_bin_dir(codex_home: &Path) -> PathBuf { pub(crate) fn legacy_lookup(kind: HelperExecutable) -> PathBuf { if let Ok(exe) = std::env::current_exe() - && let Some(candidate) = source_path_for_exe(&exe, kind.file_name()) + && let Some(candidate) = bundled_executable_path_for_exe(&exe, kind.file_name()) { return candidate; } @@ -179,7 +179,7 @@ fn store_helper_path(cache_key: String, path: PathBuf) { fn sibling_source_path(kind: HelperExecutable) -> Result { let exe = std::env::current_exe().context("resolve current executable for helper lookup")?; - source_path_for_exe(&exe, kind.file_name()).ok_or_else(|| { + bundled_executable_path_for_exe(&exe, kind.file_name()).ok_or_else(|| { anyhow!( "helper not found next to current executable or under {RESOURCES_DIRNAME}: {}", exe.display() @@ -187,7 +187,7 @@ fn sibling_source_path(kind: HelperExecutable) -> Result { }) } -fn source_path_for_exe(exe: &Path, file_name: &str) -> Option { +pub(crate) fn bundled_executable_path_for_exe(exe: &Path, file_name: &str) -> Option { let dir = exe.parent()?; let direct_candidate = dir.join(file_name); if direct_candidate.is_file() { @@ -361,13 +361,13 @@ mod tests { use super::DEV_BUILD_VERSION_SENTINEL; use super::HelperExecutable; use super::RESOURCES_DIRNAME; + use super::bundled_executable_path_for_exe; use super::copy_from_source_if_needed; use super::destination_is_fresh; use super::dev_build_suffix; use super::helper_bin_dir; use super::helper_version_suffix; use super::materialized_file_name; - use super::source_path_for_exe; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; @@ -469,8 +469,9 @@ mod tests { fs::write(&exe, b"codex").expect("write exe"); fs::write(&helper, b"runner").expect("write helper"); - let resolved = source_path_for_exe(&exe, /*file_name*/ "codex-command-runner.exe") - .expect("helper path"); + let resolved = + bundled_executable_path_for_exe(&exe, /*file_name*/ "codex-command-runner.exe") + .expect("helper path"); assert_eq!(resolved, helper); } @@ -488,8 +489,9 @@ mod tests { fs::write(&exe, b"codex").expect("write exe"); fs::write(&helper, b"runner").expect("write helper"); - let resolved = source_path_for_exe(&exe, /*file_name*/ "codex-command-runner.exe") - .expect("helper path"); + let resolved = + bundled_executable_path_for_exe(&exe, /*file_name*/ "codex-command-runner.exe") + .expect("helper path"); assert_eq!(resolved, helper); } @@ -510,8 +512,9 @@ mod tests { fs::write(&package_helper, b"package runner").expect("write package helper"); fs::write(&bin_helper, b"bin runner").expect("write bin helper"); - let resolved = source_path_for_exe(&exe, /*file_name*/ "codex-command-runner.exe") - .expect("helper path"); + let resolved = + bundled_executable_path_for_exe(&exe, /*file_name*/ "codex-command-runner.exe") + .expect("helper path"); assert_eq!(resolved, package_helper); } @@ -529,8 +532,9 @@ mod tests { fs::write(&sibling_helper, b"sibling runner").expect("write sibling helper"); fs::write(&resource_helper, b"resource runner").expect("write resource helper"); - let resolved = source_path_for_exe(&exe, /*file_name*/ "codex-command-runner.exe") - .expect("helper path"); + let resolved = + bundled_executable_path_for_exe(&exe, /*file_name*/ "codex-command-runner.exe") + .expect("helper path"); assert_eq!(resolved, sibling_helper); } diff --git a/codex-rs/windows-sandbox-rs/src/setup.rs b/codex-rs/windows-sandbox-rs/src/setup.rs index 3da5933ee2..4c3d0d6f8a 100644 --- a/codex-rs/windows-sandbox-rs/src/setup.rs +++ b/codex-rs/windows-sandbox-rs/src/setup.rs @@ -3,7 +3,6 @@ use serde::Serialize; use std::collections::BTreeSet; use std::collections::HashMap; use std::collections::HashSet; -use std::ffi::OsStr; use std::ffi::c_void; use std::os::windows::process::CommandExt; use std::path::Path; @@ -13,6 +12,7 @@ use std::process::Stdio; use crate::allow::AllowDenyPaths; use crate::allow::compute_allow_paths; +use crate::helper_materialization::bundled_executable_path_for_exe; use crate::helper_materialization::helper_bin_dir; use crate::logging::log_note; use crate::path_normalization::canonical_path_key; @@ -40,11 +40,10 @@ use windows_sys::Win32::Security::SECURITY_NT_AUTHORITY; pub const SETUP_VERSION: u32 = 5; pub const OFFLINE_USERNAME: &str = "CodexSandboxOffline"; pub const ONLINE_USERNAME: &str = "CodexSandboxOnline"; -const BIN_DIRNAME: &str = "bin"; const ERROR_CANCELLED: u32 = 1223; const SECURITY_BUILTIN_DOMAIN_RID: u32 = 0x0000_0020; const DOMAIN_ALIAS_RID_ADMINS: u32 = 0x0000_0220; -const RESOURCES_DIRNAME: &str = "codex-resources"; +const SETUP_EXE_FILENAME: &str = "codex-windows-sandbox-setup.exe"; const USERPROFILE_ROOT_EXCLUSIONS: &[&str] = &[ ".ssh", ".tsh", @@ -586,34 +585,11 @@ fn find_setup_exe() -> PathBuf { { return setup_exe; } - PathBuf::from("codex-windows-sandbox-setup.exe") + PathBuf::from(SETUP_EXE_FILENAME) } fn find_setup_exe_for_current_exe(exe: &Path) -> Option { - let dir = exe.parent()?; - let candidate = dir.join("codex-windows-sandbox-setup.exe"); - if candidate.is_file() { - return Some(candidate); - } - - if dir.file_name() == Some(OsStr::new(BIN_DIRNAME)) - && let Some(package_dir) = dir.parent() - { - let package_resource_candidate = package_dir - .join(RESOURCES_DIRNAME) - .join("codex-windows-sandbox-setup.exe"); - if package_resource_candidate.is_file() { - return Some(package_resource_candidate); - } - } - - // Older standalone installs keep Windows helper binaries under - // `codex-resources/` next to `codex.exe`, so elevation still probes that - // sibling folder before falling back to PATH. - let resource_candidate = dir - .join(RESOURCES_DIRNAME) - .join("codex-windows-sandbox-setup.exe"); - resource_candidate.is_file().then_some(resource_candidate) + bundled_executable_path_for_exe(exe, SETUP_EXE_FILENAME) } fn report_helper_failure( @@ -976,8 +952,6 @@ fn filter_sensitive_write_roots(mut roots: Vec, codex_home: &Path) -> V #[cfg(test)] mod tests { - use super::BIN_DIRNAME; - use super::RESOURCES_DIRNAME; use super::WINDOWS_PLATFORM_DEFAULT_READ_ROOTS; use super::build_payload_roots; use super::find_setup_exe_for_current_exe; @@ -987,6 +961,8 @@ mod tests { use super::offline_proxy_settings_from_env; use super::profile_read_roots; use super::proxy_ports_from_env; + use crate::helper_materialization::BIN_DIRNAME; + use crate::helper_materialization::RESOURCES_DIRNAME; use crate::helper_materialization::helper_bin_dir; use crate::policy::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; From 3d94e24a3de988aa39c58b404cd7f088b920913b Mon Sep 17 00:00:00 2001 From: evawong-oai Date: Wed, 20 May 2026 12:51:12 -0700 Subject: [PATCH 56/63] Add MITM hook config model (#18868) ## Stack 1. This PR adds MITM hook config and model only. 2. Runtime follow up: #20659 wires hook enforcement into the proxy request path. 3. User facing config follow up: #18240 moves MITM policy into the PermissionProfile network tree. ## Why 1. Viyat asked for the original parent PR to be split so reviewers can inspect the policy model before request behavior changes. 2. This PR gives the proxy a typed MITM hook model, validation, matcher compilation, permissions TOML plumbing, schema support, and config tests. 3. This PR deliberately does not change CONNECT or MITM request handling. 4. Keeping runtime behavior out of this PR makes the review boundary simple: does the policy model parse, validate, compile, and lower correctly. ## Summary 1. Add the MITM hook config model and matcher compilation. 2. Validate hosts, methods, paths, query matchers, header matchers, secret sources, and reserved body matching. 3. Add wildcard matcher support for path, query value, and header value matching. 4. Add permissions TOML and schema support for flat runtime hook config. 5. Add config loader tests for MITM hook overlay behavior. ## Validation 1. Regenerated the config schema. 2. Ran the network proxy MITM hook unit tests. 3. Ran the core permission profile MITM hook parsing tests. 4. Ran the core config schema fixture test. 5. Ran the scoped Clippy fixer for the network proxy crate. 6. Ran the scoped Clippy fixer for the core crate. ## Notes 1. Runtime enforcement moved to #20659. 2. User facing PermissionProfile TOML shape remains in #18240. --- codex-rs/core/src/config/config_tests.rs | 4 +- codex-rs/network-proxy/src/config.rs | 11 +- codex-rs/network-proxy/src/lib.rs | 6 + codex-rs/network-proxy/src/mitm_hook.rs | 1047 ++++++++++++++++++++++ codex-rs/network-proxy/src/state.rs | 14 + 5 files changed, 1079 insertions(+), 3 deletions(-) create mode 100644 codex-rs/network-proxy/src/mitm_hook.rs diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index f968677483..1eb55c4562 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -69,6 +69,7 @@ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; +use codex_network_proxy::NetworkMode; use codex_protocol::config_types::ServiceTier; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; @@ -754,6 +755,7 @@ enabled = true proxy_url = "http://127.0.0.1:43128" enable_socks5 = false allow_upstream_proxy = false +mode = "full" [permissions.workspace.network.domains] "openai.com" = "allow" @@ -804,7 +806,7 @@ allow_upstream_proxy = false allow_upstream_proxy: Some(false), dangerously_allow_non_loopback_proxy: None, dangerously_allow_all_unix_sockets: None, - mode: None, + mode: Some(NetworkMode::Full), domains: Some(NetworkDomainPermissionsToml { entries: BTreeMap::from([( "openai.com".to_string(), diff --git a/codex-rs/network-proxy/src/config.rs b/codex-rs/network-proxy/src/config.rs index 1b80b80fd1..398ec321a3 100644 --- a/codex-rs/network-proxy/src/config.rs +++ b/codex-rs/network-proxy/src/config.rs @@ -13,6 +13,8 @@ use std::path::Path; use tracing::warn; use url::Url; +use crate::mitm_hook::MitmHookConfig; + #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] pub struct NetworkProxyConfig { #[serde(default)] @@ -139,6 +141,8 @@ pub struct NetworkProxySettings { pub allow_local_binding: bool, #[serde(default)] pub mitm: bool, + #[serde(default)] + pub mitm_hooks: Vec, } impl Default for NetworkProxySettings { @@ -157,6 +161,7 @@ impl Default for NetworkProxySettings { unix_sockets: None, allow_local_binding: false, mitm: false, + mitm_hooks: Vec::new(), } } } @@ -273,8 +278,8 @@ pub enum NetworkMode { /// blocked unless MITM is enabled so the proxy can enforce method policy on inner requests. /// SOCKS5 remains blocked in limited mode. Limited, - /// Full network access: all HTTP methods are allowed, and HTTPS CONNECTs are tunneled without - /// MITM interception. + /// Full network access: all HTTP methods are allowed. HTTPS CONNECTs are tunneled directly. + /// MITM hooks do not currently make full mode enter MITM. #[default] Full, } @@ -588,6 +593,7 @@ mod tests { unix_sockets: None, allow_local_binding: false, mitm: false, + mitm_hooks: Vec::new(), } ); } @@ -652,6 +658,7 @@ mod tests { "unix_sockets": null, "allow_local_binding": false, "mitm": false, + "mitm_hooks": [], } }) ); diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 416ebb0a41..76316c8359 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -5,6 +5,7 @@ mod config; mod connect_policy; mod http_proxy; mod mitm; +mod mitm_hook; mod network_policy; mod policy; mod proxy; @@ -23,6 +24,11 @@ pub use config::NetworkProxyConfig; pub use config::NetworkUnixSocketPermission; pub use config::NetworkUnixSocketPermissions; pub use config::host_and_port_from_network_addr; +pub use mitm_hook::InjectedHeaderConfig; +pub use mitm_hook::MitmHookActionsConfig; +pub use mitm_hook::MitmHookBodyConfig; +pub use mitm_hook::MitmHookConfig; +pub use mitm_hook::MitmHookMatchConfig; pub use network_policy::NetworkDecision; pub use network_policy::NetworkDecisionSource; pub use network_policy::NetworkPolicyDecider; diff --git a/codex-rs/network-proxy/src/mitm_hook.rs b/codex-rs/network-proxy/src/mitm_hook.rs new file mode 100644 index 0000000000..6262dcd4ff --- /dev/null +++ b/codex-rs/network-proxy/src/mitm_hook.rs @@ -0,0 +1,1047 @@ +#![cfg_attr(not(test), allow(dead_code))] + +use crate::config::NetworkProxyConfig; +use crate::policy::normalize_host; +use anyhow::Context as _; +use anyhow::Result; +use anyhow::anyhow; +use codex_utils_absolute_path::AbsolutePathBuf; +use globset::GlobBuilder; +use globset::GlobMatcher; +use rama_http::HeaderValue; +use rama_http::Request; +use rama_http::header::HeaderName; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use std::env; +use std::fs; +use std::path::Path; +use url::form_urlencoded; + +const PATTERN_PREFIX: &str = "pattern:"; +const LITERAL_PREFIX: &str = "literal:"; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(default)] +pub struct MitmHookConfig { + pub host: String, + #[serde(rename = "match", default)] + pub matcher: MitmHookMatchConfig, + #[serde(default)] + pub actions: MitmHookActionsConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(default)] +pub struct MitmHookMatchConfig { + pub methods: Vec, + pub path_prefixes: Vec, + pub query: BTreeMap>, + pub headers: BTreeMap>, + pub body: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(default)] +pub struct MitmHookActionsConfig { + pub strip_request_headers: Vec, + pub inject_request_headers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(default)] +pub struct InjectedHeaderConfig { + pub name: String, + pub secret_env_var: Option, + pub secret_file: Option, + pub prefix: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(transparent)] +pub struct MitmHookBodyConfig(pub serde_json::Value); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MitmHook { + pub host: String, + pub matcher: MitmHookMatcher, + pub actions: MitmHookActions, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MitmHookMatcher { + pub methods: Vec, + pub path_prefixes: Vec, + pub query: Vec, + pub headers: Vec, + pub body: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QueryConstraint { + pub name: String, + pub allowed_values: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HeaderConstraint { + pub name: HeaderName, + pub allowed_values: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MitmHookActions { + pub strip_request_headers: Vec, + pub inject_request_headers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedInjectedHeader { + pub name: HeaderName, + pub value: HeaderValue, + pub source: SecretSource, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SecretSource { + EnvVar(String), + File(AbsolutePathBuf), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MitmHookBodyMatcher { + pub raw: serde_json::Value, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PathMatcher { + Prefix(String), + Glob(CompiledGlobMatcher), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ValueMatcher { + Exact(String), + Glob(CompiledGlobMatcher), +} + +enum MatcherPattern<'a> { + Literal(&'a str), + Glob(&'a str), +} + +#[derive(Clone)] +pub struct CompiledGlobMatcher { + pattern: String, + matcher: GlobMatcher, +} + +impl std::fmt::Debug for CompiledGlobMatcher { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CompiledGlobMatcher") + .field("pattern", &self.pattern) + .finish() + } +} + +impl PartialEq for CompiledGlobMatcher { + fn eq(&self, other: &Self) -> bool { + self.pattern == other.pattern + } +} + +impl Eq for CompiledGlobMatcher {} + +impl CompiledGlobMatcher { + fn is_match(&self, candidate: &str) -> bool { + self.matcher.is_match(candidate) + } +} + +pub type MitmHooksByHost = BTreeMap>; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HookEvaluation { + NoHooksForHost, + Matched { actions: MitmHookActions }, + HookedHostNoMatch, +} + +pub(crate) fn validate_mitm_hook_config(config: &NetworkProxyConfig) -> Result<()> { + let hooks = &config.network.mitm_hooks; + if hooks.is_empty() { + return Ok(()); + } + + if !config.network.mitm { + return Err(anyhow!("network.mitm_hooks requires network.mitm = true")); + } + + for (hook_index, hook) in hooks.iter().enumerate() { + let host = normalize_hook_host(&hook.host) + .with_context(|| format!("invalid network.mitm_hooks[{hook_index}].host"))?; + + let methods = normalize_methods(&hook.matcher.methods) + .with_context(|| format!("invalid network.mitm_hooks[{hook_index}].match.methods"))?; + if methods.is_empty() { + return Err(anyhow!( + "network.mitm_hooks[{hook_index}].match.methods must not be empty" + )); + } + + let path_prefixes = + compile_path_matchers(&hook.matcher.path_prefixes).with_context(|| { + format!("invalid network.mitm_hooks[{hook_index}].match.path_prefixes") + })?; + if path_prefixes.is_empty() { + return Err(anyhow!( + "network.mitm_hooks[{hook_index}].match.path_prefixes must not be empty" + )); + } + + if let Some(body) = hook.matcher.body.as_ref() { + let _ = body; + return Err(anyhow!( + "network.mitm_hooks[{hook_index}].match.body is reserved for a future release and is not yet supported" + )); + } + + validate_query_constraints(&hook.matcher.query) + .with_context(|| format!("invalid network.mitm_hooks[{hook_index}].match.query"))?; + validate_header_constraints(&hook.matcher.headers) + .with_context(|| format!("invalid network.mitm_hooks[{hook_index}].match.headers"))?; + validate_strip_request_headers(&hook.actions.strip_request_headers).with_context(|| { + format!("invalid network.mitm_hooks[{hook_index}].actions.strip_request_headers") + })?; + validate_injected_headers(&hook.actions.inject_request_headers).with_context(|| { + format!("invalid network.mitm_hooks[{hook_index}].actions.inject_request_headers") + })?; + + if host.is_empty() { + return Err(anyhow!( + "network.mitm_hooks[{hook_index}].host must not be empty" + )); + } + } + + Ok(()) +} + +pub(crate) fn compile_mitm_hooks(config: &NetworkProxyConfig) -> Result { + compile_mitm_hooks_with_resolvers( + config, + |name| env::var(name).ok(), + |path| { + let value = fs::read_to_string(path.as_path()).with_context(|| { + format!("failed to read secret file {}", path.as_path().display()) + })?; + Ok(value.trim().to_string()) + }, + ) +} + +pub(crate) fn evaluate_mitm_hooks( + hooks_by_host: &MitmHooksByHost, + host: &str, + req: &Request, +) -> HookEvaluation { + let normalized_host = normalize_host(host); + let Some(hooks) = hooks_by_host.get(&normalized_host) else { + return HookEvaluation::NoHooksForHost; + }; + + for hook in hooks { + if hook_matches(hook, req) { + return HookEvaluation::Matched { + actions: hook.actions.clone(), + }; + } + } + + HookEvaluation::HookedHostNoMatch +} + +fn compile_mitm_hooks_with_resolvers( + config: &NetworkProxyConfig, + resolve_env_var: EnvFn, + read_secret_file: FileFn, +) -> Result +where + EnvFn: Fn(&str) -> Option, + FileFn: Fn(&AbsolutePathBuf) -> Result, +{ + validate_mitm_hook_config(config)?; + + let mut hooks_by_host = MitmHooksByHost::new(); + for hook in &config.network.mitm_hooks { + let host = normalize_hook_host(&hook.host)?; + let methods = normalize_methods(&hook.matcher.methods)?; + let path_prefixes = compile_path_matchers(&hook.matcher.path_prefixes)?; + let query = hook + .matcher + .query + .iter() + .map(|(name, values)| { + Ok(QueryConstraint { + name: normalize_query_name(name)?, + allowed_values: compile_value_matchers(values)?, + }) + }) + .collect::>>()?; + let headers = hook + .matcher + .headers + .iter() + .map(|(name, values)| { + Ok(HeaderConstraint { + name: parse_header_name(name)?, + allowed_values: compile_value_matchers(values)?, + }) + }) + .collect::>>()?; + let strip_request_headers = hook + .actions + .strip_request_headers + .iter() + .map(|name| parse_header_name(name)) + .collect::>>()?; + let inject_request_headers = hook + .actions + .inject_request_headers + .iter() + .map(|header| { + compile_injected_header(header, &resolve_env_var, &read_secret_file) + .with_context(|| format!("failed to compile injected header {}", header.name)) + }) + .collect::>>()?; + + hooks_by_host + .entry(host.clone()) + .or_default() + .push(MitmHook { + host, + matcher: MitmHookMatcher { + methods, + path_prefixes, + query, + headers, + body: None, + }, + actions: MitmHookActions { + strip_request_headers, + inject_request_headers, + }, + }); + } + + Ok(hooks_by_host) +} + +fn compile_injected_header( + header: &InjectedHeaderConfig, + resolve_env_var: &EnvFn, + read_secret_file: &FileFn, +) -> Result +where + EnvFn: Fn(&str) -> Option, + FileFn: Fn(&AbsolutePathBuf) -> Result, +{ + let name = parse_header_name(&header.name)?; + let (secret, source) = match ( + header.secret_env_var.as_deref(), + header.secret_file.as_deref(), + ) { + (Some(env_var), None) => { + let value = resolve_env_var(env_var) + .ok_or_else(|| anyhow!("missing required environment variable {env_var}"))?; + (value, SecretSource::EnvVar(env_var.to_string())) + } + (None, Some(secret_file)) => { + let path = parse_secret_file(secret_file)?; + let value = read_secret_file(&path)?; + (value, SecretSource::File(path)) + } + _ => { + return Err(anyhow!( + "expected exactly one of secret_env_var or secret_file" + )); + } + }; + + let prefix = header.prefix.clone().unwrap_or_default(); + let value = HeaderValue::from_str(&format!("{prefix}{secret}")) + .with_context(|| format!("invalid value for injected header {}", header.name))?; + + Ok(ResolvedInjectedHeader { + name, + value, + source, + }) +} + +fn hook_matches(hook: &MitmHook, req: &Request) -> bool { + let method = req.method().as_str().to_ascii_uppercase(); + if !hook + .matcher + .methods + .iter() + .any(|allowed| allowed == &method) + { + return false; + } + + let path = req.uri().path(); + if !path_matches(&hook.matcher.path_prefixes, path) { + return false; + } + + if !query_matches(&hook.matcher.query, req) { + return false; + } + + headers_match(&hook.matcher.headers, req) +} + +fn query_matches(query_constraints: &[QueryConstraint], req: &Request) -> bool { + if query_constraints.is_empty() { + return true; + } + + let actual_query = req.uri().query().unwrap_or_default(); + let mut actual_values: BTreeMap> = BTreeMap::new(); + for (name, value) in form_urlencoded::parse(actual_query.as_bytes()) { + actual_values + .entry(name.into_owned()) + .or_default() + .push(value.into_owned()); + } + + query_constraints.iter().all(|constraint| { + actual_values.get(&constraint.name).is_some_and(|actual| { + actual.iter().any(|candidate| { + constraint + .allowed_values + .iter() + .any(|allowed| allowed.matches(candidate)) + }) + }) + }) +} + +fn headers_match(header_constraints: &[HeaderConstraint], req: &Request) -> bool { + header_constraints.iter().all(|constraint| { + let actual = req.headers().get_all(&constraint.name); + if actual.iter().next().is_none() { + return false; + } + if constraint.allowed_values.is_empty() { + return true; + } + + actual.iter().any(|value| { + value.to_str().ok().is_some_and(|candidate| { + constraint + .allowed_values + .iter() + .any(|allowed| allowed.matches(candidate)) + }) + }) + }) +} + +fn path_matches(path_prefixes: &[PathMatcher], path: &str) -> bool { + path_prefixes.iter().any(|matcher| matcher.matches(path)) +} + +impl PathMatcher { + fn matches(&self, candidate: &str) -> bool { + match self { + Self::Prefix(prefix) => candidate.starts_with(prefix), + Self::Glob(glob) => glob.is_match(candidate), + } + } +} + +impl ValueMatcher { + fn matches(&self, candidate: &str) -> bool { + match self { + Self::Exact(value) => value == candidate, + Self::Glob(glob) => glob.is_match(candidate), + } + } +} + +fn compile_path_matchers(path_prefixes: &[String]) -> Result> { + path_prefixes + .iter() + .map(|prefix| { + match parse_matcher_pattern(prefix)? { + MatcherPattern::Literal(prefix) => { + if prefix.is_empty() { + return Err(anyhow!("path_prefixes must not contain empty entries")); + } + Ok(PathMatcher::Prefix(prefix.to_string())) + } + MatcherPattern::Glob(glob_pattern) => Ok(PathMatcher::Glob(compile_glob_matcher( + glob_pattern, + /*literal_separator*/ true, + )?)), + } + }) + .collect() +} + +fn compile_value_matchers(values: &[String]) -> Result> { + values + .iter() + .map(|value| match parse_matcher_pattern(value)? { + MatcherPattern::Literal(value) => Ok(ValueMatcher::Exact(value.to_string())), + MatcherPattern::Glob(glob_pattern) => Ok(ValueMatcher::Glob(compile_glob_matcher( + glob_pattern, + /*literal_separator*/ false, + )?)), + }) + .collect() +} + +fn parse_matcher_pattern(pattern: &str) -> Result> { + if let Some(literal) = pattern.strip_prefix(LITERAL_PREFIX) { + return Ok(MatcherPattern::Literal(literal)); + } + let Some(glob_pattern) = pattern.strip_prefix(PATTERN_PREFIX) else { + return Ok(MatcherPattern::Literal(pattern)); + }; + if glob_pattern.is_empty() { + return Err(anyhow!("glob pattern must not be empty")); + } + Ok(MatcherPattern::Glob(glob_pattern)) +} + +fn compile_glob_matcher(pattern: &str, literal_separator: bool) -> Result { + let mut builder = GlobBuilder::new(pattern); + builder + .backslash_escape(true) + .literal_separator(literal_separator); + builder + .build() + .map(|glob| CompiledGlobMatcher { + pattern: pattern.to_string(), + matcher: glob.compile_matcher(), + }) + .map_err(|err| anyhow!("invalid glob pattern {pattern:?}: {err}")) +} + +fn normalize_hook_host(host: &str) -> Result { + let normalized = normalize_host(host); + if normalized.is_empty() { + return Err(anyhow!("host must not be empty")); + } + if normalized.contains('*') { + return Err(anyhow!( + "MITM hook hosts must be exact hosts and cannot contain wildcards" + )); + } + Ok(normalized) +} + +fn normalize_methods(methods: &[String]) -> Result> { + methods + .iter() + .map(|method| { + let normalized = method.trim().to_ascii_uppercase(); + if normalized.is_empty() { + return Err(anyhow!("methods must not contain empty entries")); + } + Ok(normalized) + }) + .collect() +} + +fn validate_query_constraints(query: &BTreeMap>) -> Result<()> { + for (name, values) in query { + let normalized = normalize_query_name(name)?; + if normalized.is_empty() { + return Err(anyhow!("query keys must not be empty")); + } + if values.is_empty() { + return Err(anyhow!( + "query key {name:?} must list at least one allowed value" + )); + } + let _ = compile_value_matchers(values) + .with_context(|| format!("invalid matcher for query key {name:?}"))?; + } + Ok(()) +} + +fn normalize_query_name(name: &str) -> Result { + if name.is_empty() { + return Err(anyhow!("query keys must not be empty")); + } + Ok(name.to_string()) +} + +fn validate_header_constraints(headers: &BTreeMap>) -> Result<()> { + for (name, values) in headers { + let _ = parse_header_name(name)?; + let _ = compile_value_matchers(values) + .with_context(|| format!("invalid matcher for header {name:?}"))?; + } + Ok(()) +} + +fn validate_strip_request_headers(header_names: &[String]) -> Result<()> { + for name in header_names { + let _ = parse_header_name(name)?; + } + Ok(()) +} + +fn validate_injected_headers(headers: &[InjectedHeaderConfig]) -> Result<()> { + for header in headers { + let _ = parse_header_name(&header.name)?; + match ( + header.secret_env_var.as_deref(), + header.secret_file.as_deref(), + ) { + (Some(secret_env_var), None) => { + if secret_env_var.trim().is_empty() { + return Err(anyhow!("secret_env_var must not be empty")); + } + } + (None, Some(secret_file)) => { + let _ = parse_secret_file(secret_file)?; + } + _ => { + return Err(anyhow!( + "expected exactly one of secret_env_var or secret_file" + )); + } + } + } + Ok(()) +} + +fn parse_header_name(name: &str) -> Result { + HeaderName::from_bytes(name.as_bytes()) + .map_err(|err| anyhow!("invalid header name {name:?}: {err}")) +} + +fn parse_secret_file(path: &str) -> Result { + if path.trim().is_empty() { + return Err(anyhow!("secret_file must not be empty")); + } + let path = Path::new(path); + if !path.is_absolute() { + return Err(anyhow!("secret_file must be an absolute path: {path:?}")); + } + AbsolutePathBuf::from_absolute_path(path) + .with_context(|| format!("secret_file must be an absolute path: {path:?}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::NetworkMode; + use crate::config::NetworkProxySettings; + use pretty_assertions::assert_eq; + use rama_http::Body; + use rama_http::Method; + use tempfile::NamedTempFile; + + fn base_config() -> NetworkProxyConfig { + NetworkProxyConfig { + network: NetworkProxySettings { + mitm: true, + mode: NetworkMode::Limited, + ..NetworkProxySettings::default() + }, + } + } + + fn github_hook() -> MitmHookConfig { + MitmHookConfig { + host: "api.github.com".to_string(), + matcher: MitmHookMatchConfig { + methods: vec!["POST".to_string(), "PUT".to_string()], + path_prefixes: vec!["/repos/openai/".to_string()], + ..MitmHookMatchConfig::default() + }, + actions: MitmHookActionsConfig { + strip_request_headers: vec!["authorization".to_string()], + inject_request_headers: vec![InjectedHeaderConfig { + name: "authorization".to_string(), + secret_env_var: Some("CODEX_GITHUB_TOKEN".to_string()), + secret_file: None, + prefix: Some("Bearer ".to_string()), + }], + }, + } + } + + #[test] + fn validate_requires_mitm_for_hooks() { + let mut config = base_config(); + config.network.mitm = false; + config.network.mitm_hooks = vec![github_hook()]; + + let err = validate_mitm_hook_config(&config).expect_err("hooks require mitm"); + assert!( + err.to_string() + .contains("network.mitm_hooks requires network.mitm = true") + ); + } + + #[test] + fn validate_allows_hooks_in_full_mode() { + let mut config = base_config(); + config.network.mode = NetworkMode::Full; + config.network.mitm_hooks = vec![github_hook()]; + + validate_mitm_hook_config(&config).expect("hooks should be allowed in full mode"); + } + + #[test] + fn validate_rejects_body_matchers_for_now() { + let mut config = base_config(); + let mut hook = github_hook(); + hook.matcher.body = Some(MitmHookBodyConfig(serde_json::json!({ + "repository": "openai/codex" + }))); + config.network.mitm_hooks = vec![hook]; + + let err = validate_mitm_hook_config(&config).expect_err("body matchers are reserved"); + assert!(err.to_string().contains("match.body is reserved")); + } + + #[test] + fn validate_rejects_relative_secret_file() { + let mut config = base_config(); + let mut hook = github_hook(); + hook.actions.inject_request_headers[0].secret_env_var = None; + hook.actions.inject_request_headers[0].secret_file = Some("token.txt".to_string()); + config.network.mitm_hooks = vec![hook]; + + let err = validate_mitm_hook_config(&config).expect_err("secret file must be absolute"); + assert!(format!("{err:#}").contains("secret_file must be an absolute path")); + } + + #[test] + fn validate_rejects_dual_secret_sources() { + let mut config = base_config(); + let mut hook = github_hook(); + hook.actions.inject_request_headers[0].secret_file = Some("/tmp/github-token".to_string()); + config.network.mitm_hooks = vec![hook]; + + let err = validate_mitm_hook_config(&config).expect_err("dual secret sources invalid"); + assert!(format!("{err:#}").contains("exactly one of secret_env_var or secret_file")); + } + + #[test] + fn compile_resolves_env_backed_injected_headers() { + let mut config = base_config(); + config.network.mitm_hooks = vec![github_hook()]; + + let hooks = compile_mitm_hooks_with_resolvers( + &config, + |name| (name == "CODEX_GITHUB_TOKEN").then(|| "ghp-secret".to_string()), + |_| Err(anyhow!("unexpected file lookup")), + ) + .unwrap(); + + let compiled = hooks.get("api.github.com").unwrap(); + assert_eq!(compiled.len(), 1); + assert_eq!( + compiled[0].actions.inject_request_headers[0].source, + SecretSource::EnvVar("CODEX_GITHUB_TOKEN".to_string()) + ); + assert_eq!( + compiled[0].actions.inject_request_headers[0].value, + HeaderValue::from_static("Bearer ghp-secret") + ); + } + + #[test] + fn compile_resolves_file_backed_injected_headers() { + let secret_file = NamedTempFile::new().unwrap(); + std::fs::write(secret_file.path(), "ghp-file-secret\n").unwrap(); + + let mut config = base_config(); + let mut hook = github_hook(); + hook.actions.inject_request_headers[0].secret_env_var = None; + hook.actions.inject_request_headers[0].secret_file = + Some(secret_file.path().display().to_string()); + config.network.mitm_hooks = vec![hook]; + + let hooks = compile_mitm_hooks(&config).unwrap(); + let compiled = hooks.get("api.github.com").unwrap(); + assert_eq!( + compiled[0].actions.inject_request_headers[0].value, + HeaderValue::from_static("Bearer ghp-file-secret") + ); + } + + #[test] + fn evaluate_returns_first_matching_hook() { + let mut config = base_config(); + let mut first = github_hook(); + first.matcher.path_prefixes = vec!["/repos/openai/".to_string()]; + let mut second = github_hook(); + second.actions.inject_request_headers[0].prefix = Some("Token ".to_string()); + config.network.mitm_hooks = vec![first, second]; + + let hooks = compile_mitm_hooks_with_resolvers( + &config, + |_| Some("abc".to_string()), + |_| Err(anyhow!("unexpected file lookup")), + ) + .unwrap(); + let req = Request::builder() + .method(Method::POST) + .uri("/repos/openai/codex/issues") + .header("x-trace", "1") + .body(Body::empty()) + .unwrap(); + + let evaluation = evaluate_mitm_hooks(&hooks, "api.github.com", &req); + let HookEvaluation::Matched { actions } = evaluation else { + panic!("expected a matching hook"); + }; + + assert_eq!( + actions.inject_request_headers[0].value, + HeaderValue::from_static("Bearer abc") + ); + } + + #[test] + fn evaluate_matches_query_and_header_constraints() { + let mut config = base_config(); + let mut hook = github_hook(); + hook.matcher.query = BTreeMap::from([( + "state".to_string(), + vec!["open".to_string(), "triage".to_string()], + )]); + hook.matcher.headers = BTreeMap::from([( + "x-github-api-version".to_string(), + vec!["2022-11-28".to_string()], + )]); + config.network.mitm_hooks = vec![hook]; + + let hooks = compile_mitm_hooks_with_resolvers( + &config, + |_| Some("abc".to_string()), + |_| Err(anyhow!("unexpected file lookup")), + ) + .unwrap(); + let req = Request::builder() + .method(Method::POST) + .uri("/repos/openai/codex/issues?state=open&per_page=10") + .header("x-github-api-version", "2022-11-28") + .body(Body::empty()) + .unwrap(); + + assert_eq!( + evaluate_mitm_hooks(&hooks, "api.github.com", &req), + HookEvaluation::Matched { + actions: hooks.get("api.github.com").unwrap()[0].actions.clone(), + } + ); + } + + #[test] + fn evaluate_matches_wildcard_path_query_and_header_constraints() { + let mut config = base_config(); + let mut hook = github_hook(); + hook.matcher.path_prefixes = vec!["pattern:/repos/*/codex/issues*".to_string()]; + hook.matcher.query = + BTreeMap::from([("state".to_string(), vec!["pattern:op*".to_string()])]); + hook.matcher.headers = BTreeMap::from([( + "x-github-api-version".to_string(), + vec!["pattern:2022*preview".to_string()], + )]); + config.network.mitm_hooks = vec![hook]; + + let hooks = compile_mitm_hooks_with_resolvers( + &config, + |_| Some("abc".to_string()), + |_| Err(anyhow!("unexpected file lookup")), + ) + .unwrap(); + let req = Request::builder() + .method(Method::POST) + .uri("/repos/openai/codex/issues?state=open") + .header("x-github-api-version", "2022-11-28-preview") + .body(Body::empty()) + .unwrap(); + + assert_eq!( + evaluate_mitm_hooks(&hooks, "api.github.com", &req), + HookEvaluation::Matched { + actions: hooks.get("api.github.com").unwrap()[0].actions.clone(), + } + ); + } + + #[test] + fn validate_rejects_invalid_wildcard_path_pattern() { + let mut config = base_config(); + let mut hook = github_hook(); + hook.matcher.path_prefixes = vec!["pattern:/repos/[".to_string()]; + config.network.mitm_hooks = vec![hook]; + + let err = validate_mitm_hook_config(&config).expect_err("invalid glob should fail"); + assert!(format!("{err:#}").contains("invalid glob pattern")); + } + + #[test] + fn evaluate_path_wildcard_does_not_cross_segment_boundaries() { + let mut config = base_config(); + let mut hook = github_hook(); + hook.matcher.path_prefixes = vec!["pattern:/repos/*/codex/issues*".to_string()]; + config.network.mitm_hooks = vec![hook]; + + let hooks = compile_mitm_hooks_with_resolvers( + &config, + |_| Some("abc".to_string()), + |_| Err(anyhow!("unexpected file lookup")), + ) + .unwrap(); + let nested_req = Request::builder() + .method(Method::POST) + .uri("/repos/openai/private/codex/issues") + .body(Body::empty()) + .unwrap(); + + assert_eq!( + evaluate_mitm_hooks(&hooks, "api.github.com", &nested_req), + HookEvaluation::HookedHostNoMatch + ); + } + + #[test] + fn evaluate_treats_glob_metacharacters_as_literal_without_glob_prefix() { + let mut config = base_config(); + let mut hook = github_hook(); + hook.matcher.path_prefixes = vec!["/repos/[draft]/".to_string()]; + hook.matcher.query = BTreeMap::from([("state".to_string(), vec!["op*".to_string()])]); + hook.matcher.headers = BTreeMap::from([( + "x-github-api-version".to_string(), + vec!["2022-11-28[preview]".to_string()], + )]); + config.network.mitm_hooks = vec![hook]; + + let hooks = compile_mitm_hooks_with_resolvers( + &config, + |_| Some("abc".to_string()), + |_| Err(anyhow!("unexpected file lookup")), + ) + .unwrap(); + let exact_req = Request::builder() + .method(Method::POST) + .uri("/repos/[draft]/codex/issues?state=op*") + .header("x-github-api-version", "2022-11-28[preview]") + .body(Body::empty()) + .unwrap(); + let non_literal_req = Request::builder() + .method(Method::POST) + .uri("/repos/draft/codex/issues?state=open") + .header("x-github-api-version", "2022-11-28-preview") + .body(Body::empty()) + .unwrap(); + + assert_eq!( + evaluate_mitm_hooks(&hooks, "api.github.com", &exact_req), + HookEvaluation::Matched { + actions: hooks.get("api.github.com").unwrap()[0].actions.clone(), + } + ); + assert_eq!( + evaluate_mitm_hooks(&hooks, "api.github.com", &non_literal_req), + HookEvaluation::HookedHostNoMatch + ); + } + + #[test] + fn evaluate_allows_literal_values_with_reserved_prefixes() { + let mut config = base_config(); + let mut hook = github_hook(); + hook.matcher.query = + BTreeMap::from([("state".to_string(), vec!["literal:pattern:*".to_string()])]); + hook.matcher.headers = BTreeMap::from([( + "x-github-api-version".to_string(), + vec!["literal:pattern:*".to_string()], + )]); + config.network.mitm_hooks = vec![hook]; + + let hooks = compile_mitm_hooks_with_resolvers( + &config, + |_| Some("abc".to_string()), + |_| Err(anyhow!("unexpected file lookup")), + ) + .unwrap(); + let exact_req = Request::builder() + .method(Method::POST) + .uri("/repos/openai/codex/issues?state=pattern%3A%2A") + .header("x-github-api-version", "pattern:*") + .body(Body::empty()) + .unwrap(); + let non_literal_req = Request::builder() + .method(Method::POST) + .uri("/repos/openai/codex/issues?state=pattern%3Aopen") + .header("x-github-api-version", "pattern:preview") + .body(Body::empty()) + .unwrap(); + + assert_eq!( + evaluate_mitm_hooks(&hooks, "api.github.com", &exact_req), + HookEvaluation::Matched { + actions: hooks.get("api.github.com").unwrap()[0].actions.clone(), + } + ); + assert_eq!( + evaluate_mitm_hooks(&hooks, "api.github.com", &non_literal_req), + HookEvaluation::HookedHostNoMatch + ); + } + + #[test] + fn evaluate_returns_hooked_host_no_match_when_query_constraint_fails() { + let mut config = base_config(); + let mut hook = github_hook(); + hook.matcher.query = BTreeMap::from([("state".to_string(), vec!["open".to_string()])]); + config.network.mitm_hooks = vec![hook]; + + let hooks = compile_mitm_hooks_with_resolvers( + &config, + |_| Some("abc".to_string()), + |_| Err(anyhow!("unexpected file lookup")), + ) + .unwrap(); + let req = Request::builder() + .method(Method::POST) + .uri("/repos/openai/codex/issues?state=closed") + .body(Body::empty()) + .unwrap(); + + assert_eq!( + evaluate_mitm_hooks(&hooks, "api.github.com", &req), + HookEvaluation::HookedHostNoMatch + ); + } + + #[test] + fn evaluate_returns_no_hooks_for_unconfigured_host() { + let req = Request::builder() + .method(Method::POST) + .uri("/repos/openai/codex/issues") + .body(Body::empty()) + .unwrap(); + + assert_eq!( + evaluate_mitm_hooks(&MitmHooksByHost::new(), "api.github.com", &req), + HookEvaluation::NoHooksForHost + ); + } +} diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index b14968e9a8..67a10d3bf5 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -4,6 +4,8 @@ use crate::config::NetworkProxyConfig; use crate::config::NetworkUnixSocketPermissions; use crate::mitm::MitmState; use crate::mitm::MitmUpstreamConfig; +use crate::mitm_hook::MitmHookConfig; +use crate::mitm_hook::validate_mitm_hook_config; use crate::policy::DomainPattern; use crate::policy::compile_allowlist_globset; use crate::policy::compile_denylist_globset; @@ -53,6 +55,9 @@ pub struct PartialNetworkConfig { #[serde(default)] pub unix_sockets: Option, pub allow_local_binding: Option, + pub mitm: Option, + #[serde(default)] + pub mitm_hooks: Option>, } pub fn build_config_state( @@ -116,6 +121,7 @@ pub fn validate_policy_against_constraints( .map(|entry| entry.to_ascii_lowercase()) .collect(); let config_allow_unix_sockets = config.network.allow_unix_sockets(); + validate_mitm_hook_config(config).map_err(invalid_mitm_hook_configuration)?; validate_non_global_wildcard_domain_patterns("network.denied_domains", &config_denied_domains)?; if let Some(max_enabled) = constraints.enabled { validate(enabled, move |candidate| { @@ -376,6 +382,14 @@ pub fn validate_policy_against_constraints( Ok(()) } +fn invalid_mitm_hook_configuration(err: anyhow::Error) -> NetworkProxyConstraintError { + NetworkProxyConstraintError::InvalidValue { + field_name: "network.mitm_hooks", + candidate: err.to_string(), + allowed: "valid MITM hook configuration".to_string(), + } +} + fn validate_non_global_wildcard_domain_patterns( field_name: &'static str, patterns: &[String], From fe7c069fe65b8148950c4d98c194e6283f9731a2 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Wed, 20 May 2026 13:12:07 -0700 Subject: [PATCH 57/63] feat(permissions): resolve permission profile inheritance (#22270) ## Stack This is the foundation PR for the permission-profile inheritance stack. - This PR adds config-level `extends` resolution and merge semantics. - Follow-up: #23705 applies resolved profiles at runtime and updates the active-profile protocol surfaces. ## Why Permission profiles are starting to carry enough policy that copy-pasting near-identical definitions becomes hard to review and easy to drift. Before the runtime can consume inherited profiles, the config layer needs one explicit resolver that can merge parent chains and reject unsafe or invalid inheritance shapes. ## What changed - Add `extends` to permission-profile TOML and resolve parent chains in inheritance order. - Merge inherited profile TOML with the existing config merge behavior while preserving the permission-specific normalization needed for network domain keys. - Keep parent descriptions out of resolved child profiles and record inherited profile names separately for downstream consumers. - Reject undefined parents, unsupported built-in parents, and inheritance cycles with targeted errors. - Cover resolver behavior with TOML fixture tests and refresh the generated config schema. ## Validation - `cargo test -p codex-config` - `cargo test -p codex-core permissions_profiles_` --- .../schema/json/ServerNotification.json | 2 +- .../codex_app_server_protocol.schemas.json | 2 +- .../codex_app_server_protocol.v2.schemas.json | 2 +- .../schema/json/v2/ThreadForkResponse.json | 2 +- .../schema/json/v2/ThreadResumeResponse.json | 2 +- .../v2/ThreadSettingsUpdatedNotification.json | 2 +- .../schema/json/v2/ThreadStartResponse.json | 2 +- .../typescript/v2/ActivePermissionProfile.ts | 4 +- .../src/protocol/v2/permissions.rs | 4 +- codex-rs/config/src/merge.rs | 20 + codex-rs/config/src/merge_tests.rs | 26 ++ codex-rs/config/src/permissions_toml.rs | 176 ++++++++ codex-rs/core/config.schema.json | 5 +- codex-rs/core/src/config/config_tests.rs | 320 +++++++++++++-- codex-rs/core/src/config/mod.rs | 12 +- codex-rs/core/src/config/permissions.rs | 96 +++-- codex-rs/core/src/config/permissions_tests.rs | 154 +++++++ codex-rs/core/src/network_proxy_loader.rs | 26 +- .../core/src/network_proxy_loader_tests.rs | 384 ++++++++++++++++-- codex-rs/protocol/src/models.rs | 4 +- 20 files changed, 1131 insertions(+), 114 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 2a36d66c1d..9270af85e7 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -68,7 +68,7 @@ "properties": { "extends": { "default": null, - "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", "type": [ "string", "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 ee48d1e259..b0158d0a19 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 @@ -5666,7 +5666,7 @@ "properties": { "extends": { "default": null, - "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", "type": [ "string", "null" 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 871cd81777..7e0053dcf2 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 @@ -134,7 +134,7 @@ "properties": { "extends": { "default": null, - "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", "type": [ "string", "null" 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 3d66152a60..cccc7e5b8d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -9,7 +9,7 @@ "properties": { "extends": { "default": null, - "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", "type": [ "string", "null" 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 f379455b59..f57257f94c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -9,7 +9,7 @@ "properties": { "extends": { "default": null, - "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json index d016e28311..42a76ae364 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json @@ -9,7 +9,7 @@ "properties": { "extends": { "default": null, - "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", "type": [ "string", "null" 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 7d31c52f1c..02d156d7a6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -9,7 +9,7 @@ "properties": { "extends": { "default": null, - "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts index 73f9efcab5..ee9026b543 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts @@ -9,7 +9,7 @@ export type ActivePermissionProfile = { */ id: string, /** - * Parent profile identifier once permissions profiles support - * inheritance. This is currently always `null`. + * Parent profile identifier from the selected permissions profile's + * `extends` setting, when present. */ extends: string | null, }; 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 99924ca1e5..dfc1942276 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -329,8 +329,8 @@ pub struct ActivePermissionProfile { /// Identifier from `default_permissions` or the implicit built-in default, /// such as `:workspace` or a user-defined `[permissions.]` profile. pub id: String, - /// Parent profile identifier once permissions profiles support - /// inheritance. This is currently always `null`. + /// Parent profile identifier from the selected permissions profile's + /// `extends` setting, when present. #[serde(default)] pub extends: Option, } diff --git a/codex-rs/config/src/merge.rs b/codex-rs/config/src/merge.rs index 020adb0313..94d6701546 100644 --- a/codex-rs/config/src/merge.rs +++ b/codex-rs/config/src/merge.rs @@ -1,5 +1,6 @@ use crate::key_aliases::normalize_key_aliases; use crate::key_aliases::normalized_with_key_aliases; +use codex_network_proxy::normalize_host; use toml::Value as TomlValue; /// Merge config `overlay` into `base`, giving `overlay` precedence. @@ -14,6 +15,10 @@ fn merge_toml_values_at_path(base: &mut TomlValue, overlay: &TomlValue, path: &m normalize_key_aliases(path, base_table); let mut overlay_table = overlay_table.clone(); normalize_key_aliases(path, &mut overlay_table); + if is_permission_network_domains_path(path) { + normalize_network_domain_keys(base_table); + normalize_network_domain_keys(&mut overlay_table); + } for (key, value) in overlay_table { path.push(key.clone()); @@ -29,6 +34,21 @@ fn merge_toml_values_at_path(base: &mut TomlValue, overlay: &TomlValue, path: &m } } +fn is_permission_network_domains_path(path: &[String]) -> bool { + matches!( + path, + [permissions, _, network, domains] + if permissions == "permissions" && network == "network" && domains == "domains" + ) +} + +fn normalize_network_domain_keys(table: &mut toml::map::Map) { + let entries = std::mem::take(table); + for (pattern, value) in entries { + table.insert(normalize_host(&pattern), value); + } +} + #[cfg(test)] #[path = "merge_tests.rs"] mod tests; diff --git a/codex-rs/config/src/merge_tests.rs b/codex-rs/config/src/merge_tests.rs index 0bb92c0015..f9da6e7ebc 100644 --- a/codex-rs/config/src/merge_tests.rs +++ b/codex-rs/config/src/merge_tests.rs @@ -98,3 +98,29 @@ disable_on_external_context = true ); assert_eq!(base, expected); } + +#[test] +fn merge_toml_values_normalizes_permission_network_domains_before_overlaying() { + let mut base = parse_toml( + r#" +[permissions.dev.network.domains] +"example.com" = "deny" +"#, + ); + let overlay = parse_toml( + r#" +[permissions.dev.network.domains] +"EXAMPLE.COM" = "allow" +"#, + ); + + merge_toml_values(&mut base, &overlay); + + let expected = parse_toml( + r#" +[permissions.dev.network.domains] +"example.com" = "allow" +"#, + ); + assert_eq!(base, expected); +} diff --git a/codex-rs/config/src/permissions_toml.rs b/codex-rs/config/src/permissions_toml.rs index 4d533776ad..60ec9e128d 100644 --- a/codex-rs/config/src/permissions_toml.rs +++ b/codex-rs/config/src/permissions_toml.rs @@ -1,5 +1,6 @@ use std::collections::BTreeMap; +use crate::merge::merge_toml_values; use codex_network_proxy::NetworkDomainPermission as ProxyNetworkDomainPermission; use codex_network_proxy::NetworkMode; use codex_network_proxy::NetworkProxyConfig; @@ -9,6 +10,8 @@ use codex_protocol::permissions::FileSystemAccessMode; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use thiserror::Error; +use toml::Value as TomlValue; #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] pub struct PermissionsToml { @@ -20,17 +23,190 @@ impl PermissionsToml { pub fn is_empty(&self) -> bool { self.entries.is_empty() } + + pub fn resolve_profile( + &self, + profile_name: &str, + mut parent_profile: F, + ) -> Result + where + F: FnMut(&str) -> Option, + { + let mut profile_names = Vec::new(); + let mut profiles = Vec::new(); + let mut next_profile_name = profile_name.to_string(); + let mut referenced_by: Option = None; + + loop { + if let Some(cycle_start) = profile_names + .iter() + .position(|name| name == &next_profile_name) + { + let cycle = profile_names[cycle_start..] + .iter() + .cloned() + .chain(std::iter::once(next_profile_name)) + .collect::>(); + return Err(PermissionProfileResolutionError::Cycle { cycle }); + } + + let profile = self + .entries + .get(&next_profile_name) + .cloned() + .or_else(|| parent_profile(&next_profile_name)) + .ok_or_else(|| { + referenced_by.as_deref().map_or_else( + || PermissionProfileResolutionError::UndefinedProfile { + profile_name: next_profile_name.clone(), + }, + |referenced_by| { + if next_profile_name.starts_with(':') { + PermissionProfileResolutionError::UnsupportedBuiltInParent { + profile_name: referenced_by.to_string(), + parent_profile_name: next_profile_name.clone(), + } + } else { + PermissionProfileResolutionError::UndefinedParent { + profile_name: referenced_by.to_string(), + parent_profile_name: next_profile_name.clone(), + } + } + }, + ) + })?; + let parent_profile_name = profile.extends.clone(); + + profile_names.push(next_profile_name.clone()); + + if let Some(parent_profile_name) = parent_profile_name { + profiles.push(profile); + referenced_by = Some(next_profile_name); + next_profile_name = parent_profile_name; + continue; + } + + let profile = profiles + .into_iter() + .rev() + .try_fold(profile, merge_permission_profiles)?; + return Ok(ResolvedPermissionProfileToml { + profile, + inherited_profile_names: profile_names.into_iter().skip(1).collect(), + }); + } + } } #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct PermissionProfileToml { pub description: Option, + pub extends: Option, pub workspace_roots: Option, pub filesystem: Option, pub network: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedPermissionProfileToml { + pub profile: PermissionProfileToml, + /// Names of profiles inherited while resolving `profile`, ordered from the + /// selected profile's direct parent to the farthest ancestor. + /// + /// Callers use this to preserve which built-in baseline contributed the + /// resolved permissions after the parent profiles have been merged away. + pub inherited_profile_names: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum PermissionProfileResolutionError { + #[error("default_permissions refers to undefined profile `{profile_name}`")] + UndefinedProfile { profile_name: String }, + #[error( + "permissions profile `{profile_name}` extends undefined profile `{parent_profile_name}`" + )] + UndefinedParent { + profile_name: String, + parent_profile_name: String, + }, + #[error( + "permissions profile `{profile_name}` cannot extend unsupported built-in profile `{parent_profile_name}`" + )] + UnsupportedBuiltInParent { + profile_name: String, + parent_profile_name: String, + }, + #[error( + "permissions profile inheritance cycle detected: {}", + cycle.join(" -> ") + )] + Cycle { cycle: Vec }, + #[error("failed to serialize permissions profile while resolving inheritance: {source}")] + SerializeProfileToml { + #[source] + source: toml::ser::Error, + }, + #[error( + "failed to deserialize merged permissions profile while resolving inheritance: {source}" + )] + DeserializeProfileToml { + #[source] + source: toml::de::Error, + }, +} + +fn merge_permission_profiles( + mut parent: PermissionProfileToml, + mut child: PermissionProfileToml, +) -> Result { + let merges_network_domains = parent + .network + .as_ref() + .and_then(|network| network.domains.as_ref()) + .is_some() + && child + .network + .as_ref() + .and_then(|network| network.domains.as_ref()) + .is_some(); + + // Description and inheritance metadata belong to the selected profile + // declaration, so an inherited profile must not fill those gaps. + parent.description = None; + parent.extends = None; + + if merges_network_domains { + normalize_profile_network_domains(&mut parent); + normalize_profile_network_domains(&mut child); + } + + let mut merged = TomlValue::try_from(parent) + .map_err(|source| PermissionProfileResolutionError::SerializeProfileToml { source })?; + let child = TomlValue::try_from(child) + .map_err(|source| PermissionProfileResolutionError::SerializeProfileToml { source })?; + merge_toml_values(&mut merged, &child); + merged + .try_into() + .map_err(|source| PermissionProfileResolutionError::DeserializeProfileToml { source }) +} + +fn normalize_profile_network_domains(profile: &mut PermissionProfileToml) { + let Some(domains) = profile + .network + .as_mut() + .and_then(|network| network.domains.as_mut()) + else { + return; + }; + + let entries = std::mem::take(&mut domains.entries); + domains.entries = entries + .into_iter() + .map(|(pattern, permission)| (normalize_host(&pattern), permission)) + .collect(); +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] pub struct WorkspaceRootsToml { #[serde(flatten)] diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 0e811e9017..636b1fcf3e 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2005,6 +2005,9 @@ "description": { "type": "string" }, + "extends": { + "type": "string" + }, "filesystem": { "$ref": "#/definitions/FilesystemPermissionsToml" }, @@ -4873,4 +4876,4 @@ }, "title": "ConfigToml", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 1eb55c4562..19fd762da9 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -733,44 +733,45 @@ async fn runtime_config_uses_tui_raw_output_mode() { #[test] fn config_toml_deserializes_permission_profiles() { let toml = r#" -default_permissions = "workspace" +default_permissions = "dev" -[permissions.workspace] +[permissions.dev] description = "Day-to-day workspace access." -[permissions.workspace.workspace_roots] +[permissions.dev.workspace_roots] "~/code/openai" = true "~/code/ignored" = false -[permissions.workspace.filesystem] +[permissions.dev.filesystem] ":minimal" = "read" "/tmp/secret.env" = "deny" -[permissions.workspace.filesystem.":workspace_roots"] +[permissions.dev.filesystem.":workspace_roots"] "." = "write" "docs" = "read" -[permissions.workspace.network] +[permissions.dev.network] enabled = true proxy_url = "http://127.0.0.1:43128" enable_socks5 = false allow_upstream_proxy = false mode = "full" -[permissions.workspace.network.domains] +[permissions.dev.network.domains] "openai.com" = "allow" "#; let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles"); - assert_eq!(cfg.default_permissions.as_deref(), Some("workspace")); + assert_eq!(cfg.default_permissions.as_deref(), Some("dev")); assert_eq!( cfg.permissions.expect("[permissions] should deserialize"), PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { description: Some("Day-to-day workspace access.".to_string()), + extends: None, workspace_roots: Some(WorkspaceRootsToml { entries: BTreeMap::from([ ("~/code/ignored".to_string(), false), @@ -831,12 +832,63 @@ async fn permissions_profiles_proxy_policy_does_not_start_managed_network_proxy_ let config = Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { description: None, + extends: None, + workspace_roots: None, + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: Some(NetworkToml { + enabled: Some(true), + ..Default::default() + }), + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + assert_eq!( + config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Enabled + ); + assert!( + config.permissions.network.is_none(), + "bare profile network.enabled should not start the managed network proxy" + ); + Ok(()) +} + +#[tokio::test] +async fn permissions_profiles_proxy_policy_starts_managed_network_proxy() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("dev".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "dev".to_string(), + PermissionProfileToml { + description: None, + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -986,12 +1038,13 @@ async fn network_proxy_feature_matrix_preserves_sandbox_network_semantics() -> s .then(|| toml::from_str("network_proxy = true").expect("valid features")); let base_config = match case.surface { Surface::PermissionProfile => ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { description: None, + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1138,12 +1191,13 @@ async fn network_proxy_feature_uses_profile_network_proxy_settings() -> std::io: let config = Config::load_from_base_config_with_overrides( ConfigToml { features: Some(toml::from_str("network_proxy = true").expect("valid features")), - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { description: None, + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1242,12 +1296,13 @@ enabled = false ) .expect("valid features"), ), - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { description: None, + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1292,12 +1347,13 @@ async fn permissions_profiles_network_disabled_by_default_does_not_start_proxy() let config = Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { description: None, + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1340,12 +1396,13 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; let cfg = ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { description: None, + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1436,7 +1493,66 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: .active_permission_profile() .as_ref() .map(|active| active.id.as_str()), - Some("workspace") + Some("dev") + ); + Ok(()) +} + +#[tokio::test] +async fn default_permissions_extended_profile_preserves_parent_metadata() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("dev".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([ + ( + "base".to_string(), + PermissionProfileToml { + description: None, + extends: None, + workspace_roots: None, + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: None, + }, + ), + ( + "dev".to_string(), + PermissionProfileToml { + description: None, + extends: Some("base".to_string()), + workspace_roots: None, + filesystem: None, + network: None, + }, + ), + ]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.permissions.active_permission_profile(), + Some(ActivePermissionProfile { + id: "dev".to_string(), + extends: Some("base".to_string()), + }) ); Ok(()) } @@ -1645,12 +1761,13 @@ async fn permission_profile_override_preserves_configured_network_policy_without let config = Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { description: None, + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1706,12 +1823,13 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std: let config = Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { description: None, + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: Some(2), @@ -1789,9 +1907,10 @@ async fn permissions_profiles_require_default_permissions() -> std::io::Result<( ConfigToml { permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { description: None, + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1920,6 +2039,7 @@ async fn workspace_profile_applies_rules_to_runtime_and_profile_workspace_roots( "dev".to_string(), PermissionProfileToml { description: None, + extends: None, workspace_roots: Some(WorkspaceRootsToml { entries: BTreeMap::from([( profile_root.to_string_lossy().into_owned(), @@ -2040,6 +2160,118 @@ async fn explicit_builtin_workspace_profile_ignores_legacy_workspace_write_setti Ok(()) } +#[tokio::test] +async fn default_permissions_profile_can_extend_builtin_workspace() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("workspace-with-network".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace-with-network".to_string(), + PermissionProfileToml { + description: None, + extends: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), + workspace_roots: None, + filesystem: None, + network: Some(NetworkToml { + enabled: Some(true), + ..Default::default() + }), + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + let policy = config.permissions.file_system_sandbox_policy(); + assert!( + policy.can_write_path_with_cwd(cwd.path(), cwd.path()), + "expected profile extending :workspace to keep project-root writes, policy: {policy:?}" + ); + assert!( + !policy.can_write_path_with_cwd(&cwd.path().join(".git"), cwd.path()), + "expected profile extending :workspace to keep metadata carveouts, policy: {policy:?}" + ); + assert_eq!( + config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Enabled + ); + assert_eq!( + config.permissions.active_permission_profile(), + Some(ActivePermissionProfile { + id: "workspace-with-network".to_string(), + extends: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), + }) + ); + Ok(()) +} + +#[tokio::test] +async fn default_permissions_profile_can_extend_builtin_read_only() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("read-only-with-network".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "read-only-with-network".to_string(), + PermissionProfileToml { + description: None, + extends: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), + workspace_roots: None, + filesystem: None, + network: Some(NetworkToml { + enabled: Some(true), + ..Default::default() + }), + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + let policy = config.permissions.file_system_sandbox_policy(); + assert!( + policy.can_read_path_with_cwd(cwd.path(), cwd.path()), + "expected profile extending :read-only to keep read access, policy: {policy:?}" + ); + assert!( + !policy.can_write_path_with_cwd(cwd.path(), cwd.path()), + "expected profile extending :read-only to stay non-writable, policy: {policy:?}" + ); + assert_eq!( + config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Enabled + ); + assert_eq!( + config.permissions.active_permission_profile(), + Some(ActivePermissionProfile { + id: "read-only-with-network".to_string(), + extends: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), + }) + ); + Ok(()) +} + #[tokio::test] async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -2373,12 +2605,13 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() let config = Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { description: None, + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2403,7 +2636,7 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() assert_eq!( config.custom_permission_profile_ids, - vec!["workspace".to_string()] + vec!["dev".to_string()] ); let memories_root = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize( codex_home.path().join("memories"), @@ -2435,12 +2668,13 @@ async fn permissions_profiles_reject_nested_entries_for_non_workspace_roots() -> let err = Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { description: None, + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2484,9 +2718,9 @@ async fn load_workspace_permission_profile( Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { - entries: BTreeMap::from([("workspace".to_string(), profile)]), + entries: BTreeMap::from([("dev".to_string(), profile)]), }), ..Default::default() }, @@ -2503,6 +2737,7 @@ async fn load_workspace_permission_profile( async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { description: None, + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2548,6 +2783,7 @@ async fn permissions_profiles_allow_unknown_special_paths_with_nested_entries() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { description: None, + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2586,6 +2822,7 @@ async fn permissions_profiles_allow_unknown_special_paths_with_nested_entries() async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { description: None, + extends: None, workspace_roots: None, filesystem: None, network: None, @@ -2604,7 +2841,7 @@ async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io ); assert!( config.startup_warnings.iter().any(|warning| warning.contains( - "Permissions profile `workspace` does not define any recognized filesystem entries for this version of Codex." + "Permissions profile `dev` does not define any recognized filesystem entries for this version of Codex." )), "{:?}", config.startup_warnings @@ -2616,6 +2853,7 @@ async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io async fn permissions_profiles_allow_empty_filesystem_with_warning() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { description: None, + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2631,7 +2869,7 @@ async fn permissions_profiles_allow_empty_filesystem_with_warning() -> std::io:: ); assert!( config.startup_warnings.iter().any(|warning| warning.contains( - "Permissions profile `workspace` does not define any recognized filesystem entries for this version of Codex." + "Permissions profile `dev` does not define any recognized filesystem entries for this version of Codex." )), "{:?}", config.startup_warnings @@ -2647,12 +2885,13 @@ async fn permissions_profiles_reject_workspace_root_parent_traversal() -> std::i let err = Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { description: None, + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2695,12 +2934,13 @@ async fn permissions_profiles_allow_network_enablement() -> std::io::Result<()> let config = Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { description: None, + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 51afdf0122..434969e2c5 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -150,6 +150,8 @@ pub use codex_sandboxing::system_bwrap_warning; pub use managed_features::ManagedFeatures; pub use network_proxy_spec::NetworkProxySpec; pub use network_proxy_spec::StartedNetworkProxy; +pub(crate) use permissions::is_builtin_permission_profile_name; +pub(crate) use permissions::reject_unknown_builtin_permission_profile; pub(crate) use permissions::resolve_permission_profile; pub use resolved_permission_profile::PermissionProfileSnapshot; pub(crate) use resolved_permission_profile::PermissionProfileState; @@ -2807,7 +2809,15 @@ impl Config { // when doing so would lose roots, network, or tmp settings. None } else { - Some(ActivePermissionProfile::new(default_permissions)) + let selected_profile_extends = cfg + .permissions + .as_ref() + .and_then(|permissions| permissions.entries.get(default_permissions)) + .and_then(|profile| profile.extends.clone()); + Some(ActivePermissionProfile { + id: default_permissions.to_string(), + extends: selected_profile_extends, + }) }; ( configured_network_proxy_config, diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index 4235cd0f65..7c757d386a 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -13,6 +13,7 @@ use codex_config::permissions_toml::NetworkUnixSocketPermissionToml; use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionsToml; +use codex_config::permissions_toml::ResolvedPermissionProfileToml; use codex_config::permissions_toml::WorkspaceRootsToml; use codex_config::types::SandboxWorkspaceWrite; use codex_features::NetworkProxyConfigToml; @@ -189,15 +190,29 @@ pub(crate) fn apply_network_proxy_feature_config( .apply_to_network_proxy_config(config); } -pub(crate) fn resolve_permission_profile<'a>( - permissions: &'a PermissionsToml, +pub(crate) fn resolve_permission_profile( + permissions: &PermissionsToml, profile_name: &str, -) -> io::Result<&'a PermissionProfileToml> { - permissions.entries.get(profile_name).ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - format!("default_permissions refers to undefined profile `{profile_name}`"), - ) +) -> io::Result { + permissions + .resolve_profile(profile_name, extensible_builtin_parent_profile_marker) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err.to_string())) +} + +/// Built-in parents provide their runtime permissions below. Resolution only +/// needs an empty profile marker so inheritance can terminate while preserving +/// the built-in parent id in `inherited_profile_names`. +fn extensible_builtin_parent_profile_marker(profile_name: &str) -> Option { + matches!( + profile_name, + BUILT_IN_READ_ONLY_PROFILE | BUILT_IN_WORKSPACE_PROFILE + ) + .then_some(PermissionProfileToml { + description: None, + extends: None, + workspace_roots: None, + filesystem: None, + network: None, }) } @@ -216,7 +231,7 @@ pub(crate) fn network_proxy_config_for_profile_selection( "default_permissions requires a `[permissions]` table", ) })?; - let profile = resolve_permission_profile(permissions, profile_name)?; + let profile = resolve_permission_profile(permissions, profile_name)?.profile; Ok(network_proxy_config_from_profile_network( profile.network.as_ref(), )) @@ -228,11 +243,27 @@ pub(crate) fn compile_permission_profile( policy_cwd: &Path, startup_warnings: &mut Vec, ) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { - let profile = resolve_permission_profile(permissions, profile_name)?; - - let mut entries = Vec::new(); + let ResolvedPermissionProfileToml { + profile, + inherited_profile_names, + } = resolve_permission_profile(permissions, profile_name)?; + let base_permissions = inherited_profile_names.iter().find_map(|name| { + match name.as_str() { + BUILT_IN_READ_ONLY_PROFILE => Some(PermissionProfile::read_only()), + BUILT_IN_WORKSPACE_PROFILE => Some(PermissionProfile::workspace_write()), + _ => None, + } + .map(|profile| profile.to_runtime_permissions()) + }); + let (mut file_system_sandbox_policy, base_network_sandbox_policy) = base_permissions + .unwrap_or_else(|| { + ( + FileSystemSandboxPolicy::restricted(Vec::new()), + NetworkSandboxPolicy::Restricted, + ) + }); if let Some(filesystem) = profile.filesystem.as_ref() { - if filesystem.is_empty() { + if filesystem.is_empty() && file_system_sandbox_policy.entries.is_empty() { push_warning( startup_warnings, missing_filesystem_entries_warning(profile_name), @@ -257,15 +288,17 @@ pub(crate) fn compile_permission_profile( } } for (path, permission) in &filesystem.entries { - entries.extend(compile_filesystem_permission( - path, - permission, - policy_cwd, - startup_warnings, - )?); + file_system_sandbox_policy + .entries + .extend(compile_filesystem_permission( + path, + permission, + policy_cwd, + startup_warnings, + )?); } } - } else { + } else if file_system_sandbox_policy.entries.is_empty() { push_warning( startup_warnings, missing_filesystem_entries_warning(profile_name), @@ -277,10 +310,11 @@ pub(crate) fn compile_permission_profile( .as_ref() .and_then(|filesystem| filesystem.glob_scan_max_depth), )?; - - let network_sandbox_policy = compile_network_sandbox_policy(profile.network.as_ref()); - let mut file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(entries); - file_system_sandbox_policy.glob_scan_max_depth = glob_scan_max_depth; + if let Some(glob_scan_max_depth) = glob_scan_max_depth { + file_system_sandbox_policy.glob_scan_max_depth = Some(glob_scan_max_depth); + } + let network_sandbox_policy = + compile_network_sandbox_policy(profile.network.as_ref(), base_network_sandbox_policy); Ok((file_system_sandbox_policy, network_sandbox_policy)) } @@ -323,7 +357,7 @@ pub(crate) fn compile_permission_profile_workspace_roots( })?; let profile = resolve_permission_profile(permissions, profile_name)?; Ok(compile_workspace_roots( - profile.workspace_roots.as_ref(), + profile.profile.workspace_roots.as_ref(), policy_cwd, )) } @@ -340,7 +374,7 @@ fn compile_workspace_roots( }) } -fn reject_unknown_builtin_permission_profile(profile_name: &str) -> io::Result<()> { +pub(crate) fn reject_unknown_builtin_permission_profile(profile_name: &str) -> io::Result<()> { if profile_name.starts_with(':') { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -382,14 +416,18 @@ pub(crate) fn get_readable_roots_required_for_codex_runtime( readable_roots } -fn compile_network_sandbox_policy(network: Option<&NetworkToml>) -> NetworkSandboxPolicy { +fn compile_network_sandbox_policy( + network: Option<&NetworkToml>, + base_network_sandbox_policy: NetworkSandboxPolicy, +) -> NetworkSandboxPolicy { let Some(network) = network else { - return NetworkSandboxPolicy::Restricted; + return base_network_sandbox_policy; }; match network.enabled { Some(true) => NetworkSandboxPolicy::Enabled, - _ => NetworkSandboxPolicy::Restricted, + Some(false) => NetworkSandboxPolicy::Restricted, + None => base_network_sandbox_policy, } } diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index 5e1fcf3525..09aa7606aa 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -68,6 +68,7 @@ async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Resu "workspace".to_string(), PermissionProfileToml { description: None, + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -239,6 +240,157 @@ fn network_toml_overlays_unix_socket_permissions_by_path() { ); } +#[test] +fn permissions_profiles_resolve_extends_parent_first_with_child_overrides() { + let permissions = toml::from_str::( + r#" +[base] +description = "Base profile" + +[base.filesystem] +glob_scan_max_depth = 1 +"/tmp/base" = "read" +"/tmp/shared" = "read" + +[base.filesystem.":project_roots"] +"**/*.env" = "deny" +docs = "read" + +[base.network] +enabled = true + +[base.network.domains] +"base.example.com" = "allow" +"SHARED.EXAMPLE.COM." = "deny" + +[base.network.unix_sockets] +"/tmp/base.sock" = "allow" + +[child] +extends = "base" + +[child.filesystem] +glob_scan_max_depth = 3 +"/tmp/shared" = "write" + +[child.filesystem.":project_roots"] +docs = "write" +src = "read" + +[child.network] +enabled = false +allow_local_binding = true + +[child.network.domains] +"child.example.com" = "allow" +"shared.example.com" = "allow" + +[child.network.unix_sockets] +"/tmp/child.sock" = "allow" +"#, + ) + .expect("permissions should deserialize"); + + let resolved = permissions + .resolve_profile("child", |_| None) + .expect("child profile should resolve"); + let expected_profile = toml::from_str::( + r#" +extends = "base" + +[filesystem] +glob_scan_max_depth = 3 +"/tmp/base" = "read" +"/tmp/shared" = "write" + +[filesystem.":project_roots"] +"**/*.env" = "deny" +docs = "write" +src = "read" + +[network] +enabled = false +allow_local_binding = true + +[network.domains] +"base.example.com" = "allow" +"child.example.com" = "allow" +"shared.example.com" = "allow" + +[network.unix_sockets] +"/tmp/base.sock" = "allow" +"/tmp/child.sock" = "allow" +"#, + ) + .expect("expected profile should deserialize"); + + assert_eq!(resolved.profile, expected_profile); + assert_eq!(resolved.inherited_profile_names, vec!["base".to_string()]); +} + +#[test] +fn permissions_profiles_reject_undefined_extends_parent() { + let permissions = toml::from_str::( + r#" +[child] +extends = "base" +"#, + ) + .expect("permissions should deserialize"); + + let err = permissions + .resolve_profile("child", |_| None) + .expect_err("missing parent should be rejected"); + + assert_eq!( + err.to_string(), + "permissions profile `child` extends undefined profile `base`" + ); +} + +#[test] +fn permissions_profiles_reject_unsupported_builtin_extends_parent() { + let permissions = toml::from_str::( + r#" +[child] +extends = ":danger-full-access" +"#, + ) + .expect("permissions should deserialize"); + + let err = permissions + .resolve_profile("child", |_| None) + .expect_err("unsupported built-in parent should be rejected"); + + assert_eq!( + err.to_string(), + "permissions profile `child` cannot extend unsupported built-in profile `:danger-full-access`" + ); +} + +#[test] +fn permissions_profiles_reject_extends_cycles() { + let permissions = toml::from_str::( + r#" +[alpha] +extends = "beta" + +[beta] +extends = "alpha" +"#, + ) + .expect("permissions should deserialize"); + + let err = permissions + .resolve_profile("alpha", |_| None) + .expect_err("cycle should be rejected"); + + assert_eq!( + err.to_string(), + "permissions profile inheritance cycle detected: alpha -> beta -> alpha" + ); +} + #[test] fn profile_network_proxy_config_keeps_proxy_disabled_for_bare_network_access() { let config = network_proxy_config_from_profile_network(Some(&NetworkToml { @@ -287,6 +439,7 @@ fn compile_permission_profile_workspace_roots_resolves_enabled_entries() -> std: "workspace".to_string(), PermissionProfileToml { description: None, + extends: None, workspace_roots: Some(WorkspaceRootsToml { entries: BTreeMap::from([ ("backend".to_string(), true), @@ -397,6 +550,7 @@ fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()> "workspace".to_string(), PermissionProfileToml { description: None, + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 1c49be2daf..dc4b5b6b16 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -1,4 +1,6 @@ use crate::config::find_codex_home; +use crate::config::is_builtin_permission_profile_name; +use crate::config::reject_unknown_builtin_permission_profile; use crate::config::resolve_permission_profile; use crate::exec_policy::ExecPolicyError; use crate::exec_policy::format_exec_policy_error_with_source; @@ -13,6 +15,7 @@ use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; use codex_config::LoaderOverrides; use codex_config::loader::load_config_layers_state; +use codex_config::merge_toml_values; use codex_config::permissions_toml::NetworkToml; use codex_config::permissions_toml::PermissionsToml; use codex_config::permissions_toml::overlay_network_domain_permissions; @@ -117,6 +120,7 @@ fn network_constraints_from_trusted_layers( layers: &ConfigLayerStack, ) -> Result { let mut constraints = NetworkProxyConstraints::default(); + let mut merged = toml::Value::Table(toml::map::Map::new()); for layer in layers.get_layers( ConfigLayerStackOrdering::LowestPrecedenceFirst, /*include_disabled*/ false, @@ -125,10 +129,12 @@ fn network_constraints_from_trusted_layers( continue; } - let parsed = network_tables_from_toml(&layer.config)?; - if let Some(network) = selected_network_from_tables(parsed)? { - apply_network_constraints(network, &mut constraints); - } + merge_toml_values(&mut merged, &layer.config); + } + + let parsed = network_tables_from_toml(&merged)?; + if let Some(network) = selected_network_from_tables(parsed)? { + apply_network_constraints(network, &mut constraints); } Ok(constraints) } @@ -189,13 +195,17 @@ fn selected_network_from_tables(parsed: NetworkTablesToml) -> Result Result<()> { @@ -210,13 +220,15 @@ fn config_from_layers( exec_policy: &codex_execpolicy::Policy, ) -> Result { let mut config = NetworkProxyConfig::default(); + let mut merged = toml::Value::Table(toml::map::Map::new()); for layer in layers.get_layers( ConfigLayerStackOrdering::LowestPrecedenceFirst, /*include_disabled*/ false, ) { - let parsed = network_tables_from_toml(&layer.config)?; - apply_network_tables(&mut config, parsed)?; + merge_toml_values(&mut merged, &layer.config); } + let parsed = network_tables_from_toml(&merged)?; + apply_network_tables(&mut config, parsed)?; apply_exec_policy_network_rules(&mut config, exec_policy); Ok(config) } diff --git a/codex-rs/core/src/network_proxy_loader_tests.rs b/codex-rs/core/src/network_proxy_loader_tests.rs index b5adb935ec..e2f3e7b98b 100644 --- a/codex-rs/core/src/network_proxy_loader_tests.rs +++ b/codex-rs/core/src/network_proxy_loader_tests.rs @@ -1,19 +1,27 @@ use super::*; +use codex_app_server_protocol::ConfigLayerSource; +use codex_config::ConfigLayerEntry; +use codex_config::ConfigLayerStack; +use codex_config::ConfigRequirements; +use codex_config::ConfigRequirementsToml; +use codex_config::permissions_toml::NetworkDomainPermissionToml; +use codex_config::permissions_toml::NetworkDomainPermissionsToml; use codex_execpolicy::Decision; use codex_execpolicy::NetworkRuleProtocol; use codex_execpolicy::Policy; use pretty_assertions::assert_eq; +use std::collections::BTreeMap; #[test] fn higher_precedence_profile_network_overlays_domain_entries() { let lower_network: toml::Value = toml::from_str( r#" -default_permissions = "workspace" +default_permissions = "dev" -[permissions.workspace.network] +[permissions.dev.network] -[permissions.workspace.network.domains] +[permissions.dev.network.domains] "lower.example.com" = "allow" "blocked.example.com" = "deny" "#, @@ -21,11 +29,11 @@ default_permissions = "workspace" .expect("lower layer should parse"); let higher_network: toml::Value = toml::from_str( r#" -default_permissions = "workspace" +default_permissions = "dev" -[permissions.workspace.network] +[permissions.dev.network] -[permissions.workspace.network.domains] +[permissions.dev.network.domains] "higher.example.com" = "allow" "#, ) @@ -60,11 +68,11 @@ default_permissions = "workspace" fn higher_precedence_profile_network_overrides_matching_domain_entries() { let lower_network: toml::Value = toml::from_str( r#" -default_permissions = "workspace" +default_permissions = "dev" -[permissions.workspace.network] +[permissions.dev.network] -[permissions.workspace.network.domains] +[permissions.dev.network.domains] "shared.example.com" = "deny" "other.example.com" = "allow" "#, @@ -72,11 +80,11 @@ default_permissions = "workspace" .expect("lower layer should parse"); let higher_network: toml::Value = toml::from_str( r#" -default_permissions = "workspace" +default_permissions = "dev" -[permissions.workspace.network] +[permissions.dev.network] -[permissions.workspace.network.domains] +[permissions.dev.network.domains] "shared.example.com" = "allow" "#, ) @@ -151,9 +159,9 @@ fn execpolicy_network_rules_overlay_network_lists() { fn apply_network_constraints_includes_allow_all_unix_sockets_flag() { let config: toml::Value = toml::from_str( r#" -default_permissions = "workspace" +default_permissions = "dev" -[permissions.workspace.network] +[permissions.dev.network] dangerously_allow_all_unix_sockets = true "#, ) @@ -170,15 +178,345 @@ dangerously_allow_all_unix_sockets = true assert_eq!(constraints.dangerously_allow_all_unix_sockets, Some(true)); } +#[test] +fn selected_network_from_tables_ignores_builtin_profile_without_permissions_table() { + let config: toml::Value = toml::from_str( + r#" +default_permissions = ":workspace" +"#, + ) + .expect("built-in profile config should parse"); + + let network = selected_network_from_tables( + network_tables_from_toml(&config).expect("built-in profile config should deserialize"), + ) + .expect("built-in profile selection should not require permissions tables"); + + assert_eq!(network, None); +} + +#[test] +fn selected_network_from_tables_rejects_unknown_builtin_profile_without_permissions_table() { + let config: toml::Value = toml::from_str( + r#" +default_permissions = ":unknown" +"#, + ) + .expect("unknown built-in config should parse"); + + let err = selected_network_from_tables( + network_tables_from_toml(&config).expect("unknown built-in config should deserialize"), + ) + .expect_err("unknown built-in profile should be rejected"); + + assert_eq!( + err.to_string(), + "default_permissions refers to unknown built-in profile `:unknown`" + ); +} + +#[test] +fn selected_network_from_tables_resolves_builtin_workspace_parent() { + let config: toml::Value = toml::from_str( + r#" +default_permissions = "dev" + +[permissions.dev] +extends = ":workspace" + +[permissions.dev.network] +enabled = true + +[permissions.dev.network.domains] +"child.example.com" = "allow" +"#, + ) + .expect("dev extension config should parse"); + + let network = selected_network_from_tables( + network_tables_from_toml(&config).expect("dev extension config should deserialize"), + ) + .expect("dev extension should resolve") + .expect("dev extension should expose child network config"); + + assert_eq!( + network, + NetworkToml { + enabled: Some(true), + domains: Some(NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "child.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + }), + ..Default::default() + } + ); +} + +#[test] +fn selected_network_from_tables_resolves_permission_profile_inheritance() { + let config: toml::Value = toml::from_str( + r#" +default_permissions = "dev" + +[permissions.base.network] +enabled = true +dangerously_allow_all_unix_sockets = true + +[permissions.base.network.domains] +"base.example.com" = "allow" +"shared.example.com" = "deny" + +[permissions.dev] +extends = "base" + +[permissions.dev.network] +allow_local_binding = true + +[permissions.dev.network.domains] +"child.example.com" = "allow" +"shared.example.com" = "allow" +"#, + ) + .expect("permissions profiles should parse"); + + let network = selected_network_from_tables( + network_tables_from_toml(&config).expect("permissions profiles should deserialize"), + ) + .expect("permissions profiles should select a network table") + .expect("network table should be present"); + + assert_eq!( + network, + NetworkToml { + enabled: Some(true), + dangerously_allow_all_unix_sockets: Some(true), + allow_local_binding: Some(true), + domains: Some(NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "base.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "child.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "shared.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ]), + }), + ..Default::default() + } + ); +} + +#[test] +fn config_from_layers_resolves_inherited_profiles_across_layers() { + let lower_layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::toml! { + [permissions.base.network.domains] + "base.example.com" = "allow" + } + .into(), + ); + let higher_layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::toml! { + default_permissions = "dev" + + [permissions.dev] + extends = "base" + + [permissions.dev.network.domains] + "child.example.com" = "allow" + } + .into(), + ); + let layers = ConfigLayerStack::new( + vec![lower_layer, higher_layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("layer stack should be valid"); + + let config = + config_from_layers(&layers, &Policy::empty()).expect("inherited profiles should load"); + + assert_eq!( + config.network.allowed_domains(), + Some(vec![ + "base.example.com".to_string(), + "child.example.com".to_string(), + ]) + ); +} + +#[test] +fn config_from_layers_normalizes_profile_network_domains_before_merging_layers() { + let lower_layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::toml! { + default_permissions = "dev" + + [permissions.dev.network.domains] + "example.com" = "deny" + } + .into(), + ); + let higher_layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::toml! { + [permissions.dev.network.domains] + "EXAMPLE.COM" = "allow" + } + .into(), + ); + let layers = ConfigLayerStack::new( + vec![lower_layer, higher_layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("layer stack should be valid"); + + let config = config_from_layers(&layers, &Policy::empty()) + .expect("network domain layer precedence should load"); + + assert_eq!( + config.network.allowed_domains(), + Some(vec!["example.com".to_string()]) + ); + assert_eq!(config.network.denied_domains(), None); +} + +#[test] +fn config_from_layers_uses_only_the_final_selected_profile_network() { + let lower_layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::toml! { + default_permissions = "dev" + + [permissions.dev.network.domains] + "lower.example.com" = "allow" + } + .into(), + ); + let higher_layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::toml! { + default_permissions = ":workspace" + } + .into(), + ); + let layers = ConfigLayerStack::new( + vec![lower_layer, higher_layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("layer stack should be valid"); + + let config = config_from_layers(&layers, &Policy::empty()) + .expect("final built-in profile selection should load"); + + assert_eq!(config.network.allowed_domains(), None); + assert_eq!(config.network.denied_domains(), None); +} + +#[test] +fn trusted_constraints_use_only_the_final_selected_profile_network() { + let lower_layer = ConfigLayerEntry::new( + ConfigLayerSource::System { + file: AbsolutePathBuf::try_from(std::path::PathBuf::from("/tmp/system.toml")) + .expect("system config path should be absolute"), + }, + toml::toml! { + default_permissions = "dev" + + [permissions.dev.network.domains] + "managed.example.com" = "allow" + } + .into(), + ); + let higher_layer = ConfigLayerEntry::new( + ConfigLayerSource::LegacyManagedConfigTomlFromFile { + file: AbsolutePathBuf::try_from(std::path::PathBuf::from("/tmp/managed.toml")) + .expect("managed config path should be absolute"), + }, + toml::toml! { + default_permissions = ":workspace" + } + .into(), + ); + let layers = ConfigLayerStack::new( + vec![lower_layer, higher_layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("layer stack should be valid"); + + let constraints = network_constraints_from_trusted_layers(&layers) + .expect("final built-in trusted selection should load"); + + assert_eq!(constraints.allowed_domains, None); + assert_eq!(constraints.denied_domains, None); +} + +#[test] +fn trusted_constraints_normalize_profile_network_domains_before_merging_layers() { + let lower_layer = ConfigLayerEntry::new( + ConfigLayerSource::System { + file: AbsolutePathBuf::try_from(std::path::PathBuf::from("/tmp/system.toml")) + .expect("system config path should be absolute"), + }, + toml::toml! { + default_permissions = "dev" + + [permissions.dev.network.domains] + "example.com" = "deny" + } + .into(), + ); + let higher_layer = ConfigLayerEntry::new( + ConfigLayerSource::LegacyManagedConfigTomlFromFile { + file: AbsolutePathBuf::try_from(std::path::PathBuf::from("/tmp/managed.toml")) + .expect("managed config path should be absolute"), + }, + toml::toml! { + [permissions.dev.network.domains] + "EXAMPLE.COM" = "allow" + } + .into(), + ); + let layers = ConfigLayerStack::new( + vec![lower_layer, higher_layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("layer stack should be valid"); + + let constraints = network_constraints_from_trusted_layers(&layers) + .expect("trusted network domain layer precedence should load"); + + assert_eq!( + constraints.allowed_domains, + Some(vec!["example.com".to_string()]) + ); + assert_eq!(constraints.denied_domains, None); +} + #[test] fn apply_network_constraints_skips_empty_domain_sides() { let config: toml::Value = toml::from_str( r#" -default_permissions = "workspace" +default_permissions = "dev" -[permissions.workspace.network] +[permissions.dev.network] -[permissions.workspace.network.domains] +[permissions.dev.network.domains] "managed.example.com" = "allow" "#, ) @@ -203,22 +541,22 @@ default_permissions = "workspace" fn apply_network_constraints_overlay_domain_entries() { let lower_network: toml::Value = toml::from_str( r#" -default_permissions = "workspace" +default_permissions = "dev" -[permissions.workspace.network] +[permissions.dev.network] -[permissions.workspace.network.domains] +[permissions.dev.network.domains] "blocked.example.com" = "deny" "#, ) .expect("lower layer should parse"); let higher_network: toml::Value = toml::from_str( r#" -default_permissions = "workspace" +default_permissions = "dev" -[permissions.workspace.network] +[permissions.dev.network] -[permissions.workspace.network.domains] +[permissions.dev.network.domains] "api.example.com" = "allow" "#, ) diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 2927cb1cd1..28a7d6522c 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -339,8 +339,8 @@ pub struct ActivePermissionProfile { /// profile. pub id: String, - /// Optional parent profile identifier once permissions profiles support - /// inheritance. This is always `None` until that config feature exists. + /// Optional parent profile identifier from the selected permissions + /// profile's `extends` setting. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub extends: Option, From d1e3d54192b18665533fb4f61e7cb94480828035 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 20 May 2026 13:12:31 -0700 Subject: [PATCH 58/63] cli: add strict config to exec-server (#23719) ## Why PR #20559 added opt-in strict config parsing to the config-loading command surfaces, but `codex exec-server` was left out. That meant `codex exec-server --strict-config` was rejected even though the command can load config for remote registration, and local server startup had no way to fail fast on misspelled config keys. ## What Changed - Added `--strict-config` to `codex exec-server`. - Allowed root-level inheritance from `codex --strict-config exec-server`. - Validated config before local exec-server startup when strict mode is requested. - Reused the loaded strict-config-aware config for remote exec-server registration auth. - Added CLI coverage showing `codex exec-server --strict-config` rejects unknown config fields. ## Verification - `cargo test -p codex-cli` - New integration test: `strict_config_rejects_unknown_config_fields_for_exec_server` ## Documentation Any strict-config command list on developers.openai.com/codex should include `codex exec-server` with the other supported config-loading entry points. --- codex-rs/cli/src/main.rs | 72 ++++++++++++++++++++++--------- codex-rs/cli/tests/exec_server.rs | 35 +++++++++++++++ 2 files changed, 87 insertions(+), 20 deletions(-) create mode 100644 codex-rs/cli/tests/exec_server.rs diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index f4127c437c..95873191b9 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -478,6 +478,10 @@ struct AppServerCommand { #[derive(Debug, Parser)] struct ExecServerCommand { + /// Error out when config.toml contains fields that are not recognized by this version of Codex. + #[arg(long = "strict-config", default_value_t = false)] + strict_config: bool, + /// Transport endpoint URL. Supported values: `ws://IP:PORT` (default), `stdio`, `stdio://`. #[arg(long = "listen", value_name = "URL", conflicts_with = "remote")] listen: Option, @@ -1376,11 +1380,13 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { root_remote_auth_token_env.as_deref(), "exec-server", )?; + let strict_config = cmd.strict_config || root_strict_config; run_exec_server_command( cmd, &arg0_paths, &root_config_overrides, interactive.config_profile.clone(), + strict_config, ) .await?; } @@ -1481,6 +1487,7 @@ async fn run_exec_server_command( arg0_paths: &Arg0DispatchPaths, root_config_overrides: &CliConfigOverrides, config_profile: Option, + strict_config: bool, ) -> anyhow::Result<()> { let codex_self_exe = arg0_paths .codex_self_exe @@ -1494,12 +1501,10 @@ async fn run_exec_server_command( let environment_id = cmd .environment_id .ok_or_else(|| anyhow::anyhow!("--environment-id is required when --remote is set"))?; - let auth_provider = load_exec_server_remote_auth_provider( - root_config_overrides, - config_profile, - cmd.use_agent_identity_auth, - ) - .await?; + let config = + load_exec_server_config(root_config_overrides, config_profile, strict_config).await?; + let auth_provider = + load_exec_server_remote_auth_provider(&config, cmd.use_agent_identity_auth).await?; let mut remote_config = codex_exec_server::RemoteEnvironmentConfig::new( base_url, environment_id, @@ -1509,23 +1514,29 @@ async fn run_exec_server_command( remote_config.name = name; } codex_exec_server::run_remote_environment(remote_config, runtime_paths).await?; - return Ok(()); + 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, config_profile, strict_config) + .await?; + } + let listen_url = cmd + .listen + .as_deref() + .unwrap_or(codex_exec_server::DEFAULT_LISTEN_URL); + codex_exec_server::run_main(listen_url, runtime_paths) + .await + .map_err(anyhow::Error::from_boxed) } - let listen_url = cmd - .listen - .as_deref() - .unwrap_or(codex_exec_server::DEFAULT_LISTEN_URL); - codex_exec_server::run_main(listen_url, runtime_paths) - .await - .map_err(anyhow::Error::from_boxed) } async fn load_exec_server_remote_auth_provider( - root_config_overrides: &CliConfigOverrides, - config_profile: Option, + config: &codex_core::config::Config, use_agent_identity_auth: bool, ) -> anyhow::Result { - let config = load_exec_server_remote_config(root_config_overrides, config_profile).await?; if use_agent_identity_auth { 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") @@ -1537,7 +1548,7 @@ async fn load_exec_server_remote_auth_provider( } let auth = load_exec_server_remote_auth( - &config, + config, "remote exec-server registration requires ChatGPT authentication; run `codex login` first", ) .await?; @@ -1551,9 +1562,10 @@ async fn load_exec_server_remote_auth_provider( Ok(codex_model_provider::auth_provider_from_auth(&auth)) } -async fn load_exec_server_remote_config( +async fn load_exec_server_config( root_config_overrides: &CliConfigOverrides, config_profile: Option, + strict_config: bool, ) -> anyhow::Result { let cli_kv_overrides = root_config_overrides .parse_overrides() @@ -1564,6 +1576,7 @@ async fn load_exec_server_remote_config( config_profile, ..Default::default() }) + .strict_config(strict_config) .build() .await?) } @@ -1869,6 +1882,7 @@ fn unsupported_subcommand_name_for_strict_config( | Some(Subcommand::Exec(_)) | Some(Subcommand::Review(_)) | Some(Subcommand::McpServer(_)) + | Some(Subcommand::ExecServer(_)) | Some(Subcommand::Resume(_)) | Some(Subcommand::Fork(_)) | Some(Subcommand::Doctor(_)) => None, @@ -1892,7 +1906,6 @@ fn unsupported_subcommand_name_for_strict_config( Some(Subcommand::Apply(_)) => Some("apply"), Some(Subcommand::ResponsesApiProxy(_)) => Some("responses-api-proxy"), Some(Subcommand::StdioToUds(_)) => Some("stdio-to-uds"), - Some(Subcommand::ExecServer(_)) => Some("exec-server"), Some(Subcommand::Features(_)) => Some("features"), } } @@ -2897,6 +2910,25 @@ mod tests { .. })) ); + + let cli = MultitoolCli::try_parse_from(["codex", "exec-server", "--strict-config"]) + .expect("parse"); + assert_matches!( + cli.subcommand, + Some(Subcommand::ExecServer(ExecServerCommand { + strict_config: true, + .. + })) + ); + } + + #[test] + fn root_strict_config_is_supported_for_exec_server() { + let cli = MultitoolCli::try_parse_from(["codex", "--strict-config", "exec-server"]) + .expect("parse"); + + reject_root_strict_config_for_subcommand(cli.interactive.strict_config, &cli.subcommand) + .expect("exec-server should support root --strict-config"); } #[test] diff --git a/codex-rs/cli/tests/exec_server.rs b/codex-rs/cli/tests/exec_server.rs new file mode 100644 index 0000000000..0afe0d37b5 --- /dev/null +++ b/codex-rs/cli/tests/exec_server.rs @@ -0,0 +1,35 @@ +use std::path::Path; + +use anyhow::Result; +use predicates::str::contains; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +#[test] +fn strict_config_rejects_unknown_config_fields_for_exec_server() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +foo = "bar" +"#, + )?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args([ + "exec-server", + "--strict-config", + "--listen", + "http://127.0.0.1:0", + ]) + .assert() + .failure() + .stderr(contains("unknown configuration field")); + + Ok(()) +} From 9265701b7ff81d83d813eb3d4f6aeb2e0f9a6583 Mon Sep 17 00:00:00 2001 From: Casey Chow Date: Wed, 20 May 2026 16:44:41 -0400 Subject: [PATCH 59/63] [skills] Create a personal update flow for plugin creator (#23542) ## Summary Creates a personal-marketplace update flow for the plugin-creator skill when iterating on an existing local plugin. ## Context Plugin creation already had a scaffold path, but the follow-up story for updating an existing local plugin during development was not explicit. The goal of this change is to make that default personal-marketplace update loop legible at the point of use instead of leaving it implied or hidden behind a larger helper. ## Decision Keep the scaffold flow intact, add a dedicated update/reinstall reference centered on the personal marketplace, document the actual `codex plugin add` and marketplace-check commands directly, and keep helper automation narrowly scoped to the repetitive local-update steps. ## Changes - update plugin-creator to point existing-plugin iteration at a personal-marketplace update flow - add `references/installing-and-updating.md` with the explicit marketplace check and reinstall sequence - add small helper scripts for reading marketplace names and updating plugin versions during local iteration ## Tests - `python3 codex-rs/skills/src/assets/samples/skill-creator/scripts/quick_validate.py codex-rs/skills/src/assets/samples/plugin-creator` - `python3 -m py_compile codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py codex-rs/skills/src/assets/samples/plugin-creator/scripts/read_marketplace_name.py codex-rs/skills/src/assets/samples/plugin-creator/scripts/update_plugin_cachebuster.py` --- .../assets/samples/plugin-creator/SKILL.md | 86 ++++++++--- .../references/installing-and-updating.md | 143 ++++++++++++++++++ .../references/plugin-json-spec.md | 5 +- .../scripts/create_basic_plugin.py | 48 +++++- .../scripts/read_marketplace_name.py | 48 ++++++ .../scripts/update_plugin_cachebuster.py | 78 ++++++++++ 6 files changed, 382 insertions(+), 26 deletions(-) create mode 100644 codex-rs/skills/src/assets/samples/plugin-creator/references/installing-and-updating.md create mode 100644 codex-rs/skills/src/assets/samples/plugin-creator/scripts/read_marketplace_name.py create mode 100644 codex-rs/skills/src/assets/samples/plugin-creator/scripts/update_plugin_cachebuster.py diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md index dde5779e6c..af960ec50c 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md +++ b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md @@ -1,6 +1,6 @@ --- name: plugin-creator -description: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, valid manifest defaults, and personal-marketplace entries by default. Use when Codex needs to create a new personal plugin, add optional plugin structure, or generate or update marketplace entries for plugin ordering and availability metadata. +description: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, valid manifest defaults, and personal-marketplace entries by default. Use when Codex needs to create a new personal plugin, add optional plugin structure, generate or update marketplace entries for plugin ordering and availability metadata, or update an existing local plugin during development with the CLI-driven cachebuster and reinstall flow. --- # Plugin Creator @@ -10,11 +10,11 @@ description: Create and scaffold plugin directories for Codex with a required `. 1. Run the scaffold script: ```bash - # Plugin names are normalized to lower-case hyphen-case and must be <= 64 chars. - # The generated folder and plugin.json name are always the same. -# Run from repo root (or replace .agents/... with the absolute path to this SKILL). -# By default creates in ~/plugins/. -python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py +# Plugin names are normalized to lower-case hyphen-case and must be <= 64 chars. +# The generated folder and plugin.json name are always the same. +# Run from the skill root (the directory containing this `SKILL.md`). +# By default creates in `~/plugins/`. +python3 scripts/create_basic_plugin.py ``` 2. Edit `/.codex-plugin/plugin.json` when the request gives specific metadata. @@ -23,40 +23,66 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py ` when the default `personal` marketplace name is already +taken or installed and you need to seed a different new marketplace file: + +```bash +python3 scripts/create_basic_plugin.py my-plugin \ + --with-marketplace \ + --marketplace-name team-local ``` Only use a repo/team marketplace when the user specifically asks for that destination: ```bash -python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \ +python3 scripts/create_basic_plugin.py my-plugin \ --path /plugins \ --marketplace-path /.agents/plugins/marketplace.json \ --with-marketplace ``` +When the user specifies a marketplace path, make sure that marketplace is actually installed before +telling the user to reinstall from it. The default personal marketplace file at +`~/.agents/plugins/marketplace.json` is discovered implicitly, but other marketplace paths are not. +On Windows, use the equivalent path under the user profile. + 4. Generate/adjust optional companion folders as needed: ```bash -python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \ +python3 scripts/create_basic_plugin.py my-plugin \ --path \ --marketplace-path \ --with-skills --with-hooks --with-scripts --with-assets --with-mcp --with-apps --with-marketplace ``` -`` is the directory where the plugin folder `` will be created (for example `~/code/plugins`). +`` is the directory where the plugin folder `` will be +created (for example `~/plugins`). 5. Before handing back a generated plugin, run: ```bash -python3 .agents/skills/plugin-creator/scripts/validate_plugin.py +python3 scripts/validate_plugin.py ``` +For updates to an existing local plugin during development, keep the scaffold flow as-is and use the +reference instead of hand-editing marketplace files: + +```bash +python3 scripts/update_plugin_cachebuster.py +``` + +Prefer the helper default cachebuster unless the user explicitly asks for a specific override. +See `references/installing-and-updating.md` for the expected cachebuster and reinstall flow while iterating on an existing local plugin. + ## What this skill creates -- Default marketplace-backed scaffolds are personal: `~/plugins//` plus - `~/.agents/plugins/marketplace.json`. +- Default marketplace-backed scaffolds use the personal marketplace file at + `~/.agents/plugins/marketplace.json`, with plugins generally being stored in + `~/plugins//`. - Creates plugin root at `///`. - Always creates `///.codex-plugin/plugin.json`. - Fills the manifest with the validated schema shape that the ingestion path accepts. @@ -77,9 +103,18 @@ python3 .agents/skills/plugin-creator/scripts/validate_plugin.py ## Marketplace workflow -- Personal creation defaults to `~/.agents/plugins/marketplace.json`. +- Personal-marketplace creation defaults to `~/.agents/plugins/marketplace.json`. Here, + "personal marketplace" means the marketplace whose file is at that path. - Repo/team marketplace creation is opt-in through both `--path` and `--marketplace-path`, only when the user specifically requests it. +- `--marketplace-name` is an exception path. Use it only when the default `personal` marketplace + name is already taken and you need to seed a different new marketplace file. +- Do not use `--marketplace-name` to rename an existing marketplace file in place. If the file + already exists, its top-level `name` must already match. +- If the user specifies a different marketplace path, treat that marketplace as needing explicit installation via `codex plugin marketplace add`. +- Prefer `scripts/read_marketplace_name.py` when you need the marketplace name from any + `marketplace.json` file. With no argument it reads the default personal marketplace; with an + explicit path it works for repo/team marketplaces too. - In either location, the generated source path remains `./plugins/`. - Marketplace root metadata supports top-level `name` plus optional `interface.displayName`. - Treat plugin order in `plugins[]` as render order in Codex. Append new entries unless a user explicitly asks to reorder the list. @@ -157,6 +192,21 @@ python3 .agents/skills/plugin-creator/scripts/validate_plugin.py - When generating marketplace entries, always write `policy.installation`, `policy.authentication`, and `category` even if their values are defaults. - Add `policy.products` only when the user explicitly asks for that override. - Keep marketplace `source.path` relative to the selected marketplace root as `./plugins/`. +- Only use `--marketplace-name` when creating a new marketplace file whose name should not be + `personal` because that name is already taken or installed elsewhere. +- If Codex would need approval to write the marketplace file, ask for that approval before + proceeding. If the user prefers to run the write themselves, provide the exact scaffold command + and then continue from validation or subsequent plugin edits instead of leaving the workflow + vague. +- For updates to an existing local plugin during development, do not hand-edit marketplace config + or `marketplace.json`. Use the update flow documented in + `references/installing-and-updating.md` and `scripts/update_plugin_cachebuster.py`. +- Do not tell the user to run `codex plugin marketplace add` for the default personal-marketplace + flow. That command is for explicit non-default marketplace configuration, not for the standard + `~/.agents/plugins/marketplace.json` path. +- If the user provided a non-default `--marketplace-path`, make sure that marketplace is installed + before giving reinstall instructions. Use `codex plugin marketplace add ` + when that explicit marketplace has not been configured yet. - When the workflow created or updated a marketplace-backed plugin, end the final user-facing response with a short Codex app handoff. Say `To view this in the Codex app:` and write `View ` and `Share ` as Markdown links, not raw @@ -175,17 +225,19 @@ python3 .agents/skills/plugin-creator/scripts/validate_plugin.py For the exact canonical sample JSON for both plugin manifests and marketplace entries, use: - `references/plugin-json-spec.md` +- `references/installing-and-updating.md` for update/reinstall guidance while + iterating on an existing local plugin, plus the new-thread pickup behavior after reinstall ## Validation After editing `SKILL.md`, run: ```bash -python3 /scripts/quick_validate.py .agents/skills/plugin-creator +python3 ../skill-creator/scripts/quick_validate.py . ``` Before handing back a generated plugin, run: ```bash -python3 .agents/skills/plugin-creator/scripts/validate_plugin.py +python3 scripts/validate_plugin.py ``` diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/references/installing-and-updating.md b/codex-rs/skills/src/assets/samples/plugin-creator/references/installing-and-updating.md new file mode 100644 index 0000000000..28b3b88908 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/plugin-creator/references/installing-and-updating.md @@ -0,0 +1,143 @@ +# Updating Existing Local Plugins + +Use this reference when a plugin already exists and the request is about updating the plugin during +local development. + +All scripts here are specified relative to the skill root. Update the path for running the scripts +depending on your current working directory. + +## When To Use This Flow + +Use this flow when all of the following are true: + +- the plugin already exists locally +- the marketplace entry already points at the plugin source you are editing +- the user wants Codex to see the updated plugin without manually editing marketplace files + +If the user still needs the initial plugin entry or marketplace structure created, use the scaffold +flow first and only then switch to this reinstall flow. + +## Update Loop + +1. Update the plugin manifest to a single Codex cachebuster suffix: + +```bash +python3 scripts/update_plugin_cachebuster.py \ + +``` + +Prefer the default helper behavior here. If you omit `--cachebuster`, the helper uses a UTC +timestamp down to seconds, which is the recommended path for routine local iteration. + +Only use a manual cachebuster override when the user explicitly asks for one or when a workflow +outside Codex depends on a specific token: + +```bash +python3 scripts/update_plugin_cachebuster.py \ + \ + --cachebuster local-20260519-184516 +``` + +2. For the default scaffolded flow, read the marketplace name from the personal marketplace file: + +```bash +python3 scripts/read_marketplace_name.py +``` + +Here, "personal marketplace" means the marketplace whose file is at +`~/.agents/plugins/marketplace.json`. On Windows, use the equivalent path under the user profile. +The helper uses Python's home-directory resolution and prints the marketplace name to use when +constructing the install command. + +To read the name from a different marketplace file, pass the path directly: + +```bash +python3 scripts/read_marketplace_name.py --marketplace-path +``` + +3. Reinstall from that marketplace name: + +```bash +codex plugin add @ +``` + +The default personal marketplace is discovered implicitly from +`~/.agents/plugins/marketplace.json`. You do not need `codex plugin marketplace add` for that +path, and `codex plugin marketplace list` is not the right check for whether that default +marketplace exists. + +4. If the plugin is not using the personal marketplace file, check which configured local + marketplace is actually surfacing that plugin: + +```bash +codex plugin list +``` + +If the plugin is not in the personal marketplace file, confirm which marketplace entry points at +the plugin source you are editing and make sure that marketplace is still local. If it is a +different local marketplace, reinstall from that marketplace name instead of forcing the personal +marketplace flow. If it is not local, stop and help the user resolve the mismatch before +continuing. + +5. If the plugin lives in a different confirmed local marketplace, substitute that marketplace + name: + +```bash +codex plugin add @ +``` + +6. Prompt the user to use a new thread to try the updated plugin, so that Codex picks up new skills + and tools. + +## Cachebuster Policy + +- Preserve the existing version prefix and replace only the suffix. +- Treat the preserved prefix as everything before `+`. +- Use the format: + +```text ++codex. +``` + +Examples: + +- `0.1.0` → `0.1.0+codex.local-20260519-184516` +- `0.1.0+codex.old-token` → `0.1.0+codex.local-20260519-184516` +- `1.2.3-beta.1+codex.prev` → `1.2.3-beta.1+codex.local-20260519-184516` +- `dev-build+other-tag` → `dev-build+codex.local-20260519-184516` + +Replace the existing Codex cachebuster instead of appending another one. Do not keep incrementing +numeric version components just to trigger reinstall behavior. + +## Marketplace Rules + +- Marketplace manipulation should happen through commands, not by hand-editing `marketplace.json` + or `config.toml` during this update/reinstall flow. +- Prefer the personal marketplace file for the default scaffolded flow. +- Read the personal marketplace name with + `python3 scripts/read_marketplace_name.py` and use the printed value when constructing + `codex plugin add @`. +- For non-default marketplace files, use + `python3 scripts/read_marketplace_name.py --marketplace-path ` to read + the name before constructing reinstall commands. +- Do not tell the user to run `codex plugin marketplace add` for the default personal-marketplace + flow. That marketplace is discovered implicitly by Codex. +- If the user specified a different marketplace path, make sure that marketplace is installed + before giving install or reinstall instructions. Non-default marketplace paths are not + discovered implicitly. +- Use `codex plugin list` when the plugin lives in a different configured marketplace and you need + to confirm which marketplace is surfacing that plugin. +- If a non-default local marketplace has not been configured yet, install it with + `codex plugin marketplace add ` before telling the user to run + `codex plugin add @`. +- If the plugin is not in the personal marketplace file, confirm that the selected marketplace is + local before telling the user to reinstall from it. +- If the selected marketplace is not local, stop and help the user resolve that mismatch rather + than pretending the normal local reinstall flow applies. +- If the plugin source is not already the source referenced by the chosen marketplace entry, stop + and fix that first. This update flow does not rewrite marketplace entries. + +## After Reinstall + +After reinstalling, prompt the user to start a new thread for testing. That is the safe boundary for +picking up the updated plugin and its MCP tools. 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 0fd444e64b..5eb2251a0e 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 @@ -147,7 +147,8 @@ personal marketplace unless the caller explicitly requests a repo-local destinat - Personal plugin in `~/.agents/plugins/marketplace.json`: `./plugins/` - Repo/team plugin: `./plugins/` - The same relative path convention is used for both personal and repo/team marketplaces. - - Example: with `~/.agents/plugins/marketplace.json`, `./plugins/` resolves to `~/plugins/`. + - Example: with `~/.agents/plugins/marketplace.json`, `./plugins/` resolves to + `~/plugins/`. - `policy` (`object`): Marketplace policy block. Always include it. - `installation` (`string`): Availability policy. - Allowed values: `NOT_AVAILABLE`, `AVAILABLE`, `INSTALLED_BY_DEFAULT` @@ -168,6 +169,8 @@ personal marketplace unless the caller explicitly requests a repo-local destinat - Replace an existing entry for the same plugin only when overwrite is intentional. - Default new plugin creation to the personal marketplace. - Use a repo/team marketplace only when the user specifically requests that destination. +- Only override the marketplace `name` when the default `personal` name is already taken or + installed and you need to seed a different new marketplace file. - Choose marketplace location to match the selected destination: - Personal plugin: `~/.agents/plugins/marketplace.json` - Repo/team plugin: `/.agents/plugins/marketplace.json` diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py index fb882ce99a..78b9fca8fc 100755 --- a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py @@ -11,15 +11,14 @@ from typing import Any MAX_PLUGIN_NAME_LENGTH = 64 -DEFAULT_PLUGIN_PARENT = Path.home() / "plugins" -DEFAULT_MARKETPLACE_PATH = Path.home() / ".agents" / "plugins" / "marketplace.json" DEFAULT_INSTALL_POLICY = "AVAILABLE" DEFAULT_AUTH_POLICY = "ON_INSTALL" DEFAULT_CATEGORY = "Productivity" DEFAULT_MARKETPLACE_NAME = "personal" -DEFAULT_MARKETPLACE_DISPLAY_NAME = "Personal" VALID_INSTALL_POLICIES = {"NOT_AVAILABLE", "AVAILABLE", "INSTALLED_BY_DEFAULT"} VALID_AUTH_POLICIES = {"ON_INSTALL", "ON_USE"} +DEFAULT_PLUGIN_PARENT = Path.home() / "plugins" +DEFAULT_MARKETPLACE_PATH = Path.home() / ".agents" / "plugins" / "marketplace.json" def normalize_plugin_name(plugin_name: str) -> str: @@ -41,8 +40,17 @@ def validate_plugin_name(plugin_name: str) -> None: ) +def validate_marketplace_name(marketplace_name: str) -> None: + if not marketplace_name: + raise ValueError("Marketplace name must include at least one letter or digit.") + if re.fullmatch(r"[A-Za-z0-9_-]+", marketplace_name) is None: + raise ValueError( + "Marketplace name may only contain ASCII letters, digits, `_`, and `-`." + ) + + def display_name_from_plugin_name(plugin_name: str) -> str: - return " ".join(part.capitalize() for part in plugin_name.split("-")) + return " ".join(part.capitalize() for part in re.split(r"[-_]+", plugin_name)) def build_plugin_json(plugin_name: str, *, with_mcp: bool, with_apps: bool) -> dict[str, Any]: @@ -97,11 +105,11 @@ def load_json(path: Path) -> dict[str, Any]: return json.load(handle) -def build_default_marketplace() -> dict[str, Any]: +def build_default_marketplace(marketplace_name: str) -> dict[str, Any]: return { - "name": DEFAULT_MARKETPLACE_NAME, + "name": marketplace_name, "interface": { - "displayName": DEFAULT_MARKETPLACE_DISPLAY_NAME, + "displayName": display_name_from_plugin_name(marketplace_name), }, "plugins": [], } @@ -115,6 +123,7 @@ def validate_marketplace_interface(payload: dict[str, Any]) -> None: def update_marketplace_json( marketplace_path: Path, + marketplace_name: str | None, plugin_name: str, install_policy: str, auth_policy: str, @@ -124,13 +133,24 @@ def update_marketplace_json( if marketplace_path.exists(): payload = load_json(marketplace_path) else: - payload = build_default_marketplace() + payload = build_default_marketplace(marketplace_name or DEFAULT_MARKETPLACE_NAME) if not isinstance(payload, dict): raise ValueError(f"{marketplace_path} must contain a JSON object.") validate_marketplace_interface(payload) + existing_marketplace_name = payload.get("name") + if marketplace_name is not None: + if not isinstance(existing_marketplace_name, str) or not existing_marketplace_name.strip(): + raise ValueError(f"{marketplace_path} must contain a non-empty string 'name'.") + if existing_marketplace_name != marketplace_name: + raise ValueError( + f"{marketplace_path} already uses marketplace name " + f"'{existing_marketplace_name}'. Create a new marketplace file to use " + f"'{marketplace_name}' instead." + ) + plugins = payload.setdefault("plugins", []) if not isinstance(plugins, list): raise ValueError(f"{marketplace_path} field 'plugins' must be an array.") @@ -206,6 +226,13 @@ def parse_args() -> argparse.Namespace: "Pass a repo-rooted marketplace path only when a repo/team plugin is intended." ), ) + parser.add_argument( + "--marketplace-name", + help=( + "Marketplace name to seed into a new marketplace.json. Use this only when the default " + "'personal' marketplace name is already taken and you need a different new marketplace." + ), + ) parser.add_argument( "--install-policy", default=DEFAULT_INSTALL_POLICY, @@ -234,6 +261,10 @@ def main() -> None: if plugin_name != raw_plugin_name: print(f"Note: Normalized plugin name from '{raw_plugin_name}' to '{plugin_name}'.") validate_plugin_name(plugin_name) + marketplace_name = None + if args.marketplace_name is not None: + marketplace_name = args.marketplace_name.strip() + validate_marketplace_name(marketplace_name) plugin_root = (Path(args.path).expanduser().resolve() / plugin_name) plugin_root.mkdir(parents=True, exist_ok=True) @@ -275,6 +306,7 @@ def main() -> None: marketplace_path = Path(args.marketplace_path).expanduser().resolve() update_marketplace_json( marketplace_path, + marketplace_name, plugin_name, args.install_policy, args.auth_policy, diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/read_marketplace_name.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/read_marketplace_name.py new file mode 100644 index 0000000000..597e9f7a42 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/read_marketplace_name.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Print the top-level marketplace name from any marketplace.json file.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + + +def default_marketplace_path() -> Path: + return Path.home() / ".agents" / "plugins" / "marketplace.json" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Print the top-level marketplace name from marketplace.json. Defaults to the personal " + "marketplace path under the current home directory." + ) + ) + parser.add_argument( + "--marketplace-path", + default=str(default_marketplace_path()), + help="Path to marketplace.json", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + marketplace_path = Path(args.marketplace_path).expanduser().resolve() + payload = json.loads(marketplace_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"{marketplace_path} must contain a JSON object.") + name = payload.get("name") + if not isinstance(name, str) or not name.strip(): + raise ValueError(f"{marketplace_path} must contain a non-empty string 'name'.") + print(name.strip()) + + +if __name__ == "__main__": + try: + main() + except Exception as err: # noqa: BLE001 - CLI should surface a single clear message. + print(str(err), file=sys.stderr) + raise SystemExit(1) from err diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/update_plugin_cachebuster.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/update_plugin_cachebuster.py new file mode 100644 index 0000000000..82d19e5dfd --- /dev/null +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/update_plugin_cachebuster.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Rewrite a local plugin version to a single Codex cachebuster suffix.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from datetime import datetime, timezone +from pathlib import Path + + +CACHEBUSTER_PREFIX = "codex" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Rewrite a local plugin's version so it preserves everything before '+' and uses " + "a single +codex. suffix." + ) + ) + parser.add_argument("plugin_path", help="Path to the plugin root directory") + parser.add_argument( + "--cachebuster", + help="Optional cachebuster token to embed in the plugin version", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + plugin_root = Path(args.plugin_path).expanduser().resolve() + manifest_path = plugin_root / ".codex-plugin" / "plugin.json" + manifest = load_manifest(manifest_path) + + version = manifest.get("version") + if not isinstance(version, str) or not version.strip(): + raise ValueError(f"{manifest_path} must contain a non-empty string 'version'.") + cachebuster = sanitize_cachebuster(args.cachebuster or default_cachebuster()) + next_version = with_cachebuster(version, cachebuster) + manifest["version"] = next_version + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + + print(f"Updated plugin version: {version} -> {next_version}") + + +def load_manifest(manifest_path: Path) -> dict[str, object]: + if not manifest_path.is_file(): + raise FileNotFoundError(f"missing manifest: {manifest_path}") + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"{manifest_path} must contain a JSON object.") + return payload +def sanitize_cachebuster(value: str) -> str: + sanitized = re.sub(r"[^a-z0-9-]+", "-", value.strip().lower()) + sanitized = re.sub(r"-{2,}", "-", sanitized).strip("-") + if not sanitized: + raise ValueError("Cachebuster must contain at least one letter or digit.") + return sanitized + + +def default_cachebuster() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + + +def with_cachebuster(version: str, cachebuster: str) -> str: + version_prefix = version.split("+", 1)[0] + return f"{version_prefix}+{CACHEBUSTER_PREFIX}.{cachebuster}" + + +if __name__ == "__main__": + try: + main() + except Exception as err: # noqa: BLE001 - CLI should surface a single clear message. + print(str(err), file=sys.stderr) + raise SystemExit(1) from err From af49d38373d637c858b3510d9f0cd3da96c52856 Mon Sep 17 00:00:00 2001 From: Abhinav Date: Wed, 20 May 2026 13:46:19 -0700 Subject: [PATCH 60/63] Support compact SessionStart hooks (#21272) # Why Compaction replaces the live conversation history, so hooks that use `SessionStart` to re-inject durable model context need a way to run again after that rewrite. Related - #19905 adds dedicated compact lifecycle hooks # What - add `compact` as a supported `SessionStart` source and matcher value - change pending `SessionStart` state from a single slot to a small FIFO queue so `resume` / `startup` / `clear` can be preserved alongside a later `compact` - drain all queued `SessionStart` sources before the next model request, preserving their original order # Testing The new integration coverage verifies both the basic `compact` matcher path and the stacked `resume` -> `compact` case where both hooks contribute `additionalContext` to the next model turn. --- codex-rs/core/src/hook_runtime.rs | 95 ++-- codex-rs/core/src/session/mod.rs | 4 + codex-rs/core/src/session/session.rs | 2 +- codex-rs/core/src/state/session.rs | 13 +- codex-rs/core/tests/suite/hooks.rs | 410 ++++++++++++++++++ .../session-start.command.input.schema.json | 3 +- codex-rs/hooks/src/events/session_start.rs | 2 + codex-rs/hooks/src/schema.rs | 2 +- 8 files changed, 476 insertions(+), 55 deletions(-) diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 72bfc1de1c..3708ea6089 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -99,54 +99,57 @@ pub(crate) async fn run_pending_session_start_hooks( sess: &Arc, turn_context: &Arc, ) -> bool { - let Some(session_start_source) = sess.take_pending_session_start_source().await else { - return false; - }; - - // Pending session-start hooks are reused to dispatch thread-spawn subagent - // starts. Other subagent sessions are internal/system work and do not run - // start hooks. - let target = match &turn_context.session_source { - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. }) - if matches!( - session_start_source, - codex_hooks::SessionStartSource::Startup - ) => - { - let agent_type = agent_role - .clone() - .unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string()); - StartHookTarget::SubagentStart { - turn_id: turn_context.sub_id.clone(), - agent_id: sess.thread_id().to_string(), - agent_type, + while let Some(session_start_source) = sess.take_pending_session_start_source().await { + // Pending session-start hooks are reused to dispatch thread-spawn subagent + // starts. Other subagent sessions are internal/system work and do not run + // start hooks. + let target = match &turn_context.session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. }) + if matches!( + session_start_source, + codex_hooks::SessionStartSource::Startup + ) => + { + let agent_type = agent_role + .clone() + .unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string()); + StartHookTarget::SubagentStart { + turn_id: turn_context.sub_id.clone(), + agent_id: sess.thread_id().to_string(), + agent_type, + } } + SessionSource::SubAgent(_) => return false, + _ => StartHookTarget::SessionStart { + source: session_start_source, + }, + }; + let request = codex_hooks::SessionStartRequest { + session_id: sess.session_id().into(), + #[allow(deprecated)] + cwd: turn_context.cwd.clone(), + transcript_path: sess.hook_transcript_path().await, + model: turn_context.model_info.slug.clone(), + permission_mode: hook_permission_mode(turn_context), + target, + }; + let hooks = sess.hooks(); + let preview_runs = hooks.preview_session_start(&request); + if run_context_injecting_hook( + sess, + turn_context, + preview_runs, + hooks.run_session_start(request, Some(turn_context.sub_id.clone())), + ) + .await + .record_additional_contexts(sess, turn_context) + .await + { + return true; } - SessionSource::SubAgent(_) => return false, - _ => StartHookTarget::SessionStart { - source: session_start_source, - }, - }; - let request = codex_hooks::SessionStartRequest { - session_id: sess.session_id().into(), - #[allow(deprecated)] - cwd: turn_context.cwd.clone(), - transcript_path: sess.hook_transcript_path().await, - model: turn_context.model_info.slug.clone(), - permission_mode: hook_permission_mode(turn_context), - target, - }; - let hooks = sess.hooks(); - let preview_runs = hooks.preview_session_start(&request); - run_context_injecting_hook( - sess, - turn_context, - preview_runs, - hooks.run_session_start(request, Some(turn_context.sub_id.clone())), - ) - .await - .record_additional_contexts(sess, turn_context) - .await + } + + false } /// Runs matching `PreToolUse` hooks before a tool executes. diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index bdffd4e3ca..b710e095ef 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2609,6 +2609,10 @@ impl Session { self.persist_rollout_items(&[RolloutItem::TurnContext(turn_context_item)]) .await; } + { + let mut state = self.state.lock().await; + state.queue_pending_session_start_source(codex_hooks::SessionStartSource::Compact); + } self.services.model_client.advance_window_generation(); } diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 8945880c33..88958b39ef 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1184,7 +1184,7 @@ impl Session { Box::pin(sess.record_initial_history(initial_history)).await; { let mut state = sess.state.lock().await; - state.set_pending_session_start_source(Some(session_start_source)); + state.queue_pending_session_start_source(session_start_source); } Ok(sess) diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 0903d0d073..baeaddbbf9 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -4,6 +4,7 @@ use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::ResponseItem; use codex_sandboxing::policy_transforms::merge_permission_profiles; use std::collections::HashSet; +use std::collections::VecDeque; use super::auto_compact_window::AutoCompactWindow; use super::auto_compact_window::AutoCompactWindowSnapshot; @@ -33,7 +34,7 @@ pub(crate) struct SessionState { /// Startup prewarmed session prepared during session initialization. pub(crate) startup_prewarm: Option, pub(crate) active_connector_selection: HashSet, - pub(crate) pending_session_start_source: Option, + pub(crate) pending_session_start_sources: VecDeque, granted_permissions: Option, next_turn_is_first: bool, } @@ -52,7 +53,7 @@ impl SessionState { auto_compact_window: AutoCompactWindow::new(), startup_prewarm: None, active_connector_selection: HashSet::new(), - pending_session_start_source: None, + pending_session_start_sources: VecDeque::new(), granted_permissions: None, next_turn_is_first: true, } @@ -218,17 +219,17 @@ impl SessionState { self.active_connector_selection.clear(); } - pub(crate) fn set_pending_session_start_source( + pub(crate) fn queue_pending_session_start_source( &mut self, - value: Option, + value: codex_hooks::SessionStartSource, ) { - self.pending_session_start_source = value; + self.pending_session_start_sources.push_back(value); } pub(crate) fn take_pending_session_start_source( &mut self, ) -> Option { - self.pending_session_start_source.take() + self.pending_session_start_sources.pop_front() } pub(crate) fn record_granted_permissions(&mut self, permissions: AdditionalPermissionProfile) { diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 4f376573b3..e9067b8a43 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -6,6 +6,8 @@ use anyhow::Result; use codex_core::config::Config; use codex_core::config::Constrained; use codex_features::Feature; +use codex_model_provider_info::ModelProviderInfo; +use codex_model_provider_info::built_in_model_providers; use codex_plugin::PluginHookSource; use codex_plugin::PluginId; use codex_protocol::items::parse_hook_prompt_fragment; @@ -26,11 +28,13 @@ use core_test_support::managed_network_requirements_loader; use core_test_support::responses::ev_apply_patch_custom_tool_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_custom_tool_call; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_message_item_added; use core_test_support::responses::ev_output_text_delta; use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_compact_json_once; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; @@ -74,6 +78,15 @@ fn network_workspace_write_profile() -> PermissionProfile { ) } +fn non_openai_model_provider(server: &wiremock::MockServer) -> ModelProviderInfo { + let mut provider = + built_in_model_providers(/* openai_base_url */ /*openai_base_url*/ None)["openai"].clone(); + provider.name = "OpenAI (test)".into(); + provider.base_url = Some(format!("{}/v1", server.uri())); + provider.supports_websockets = false; + provider +} + fn trust_plugin_hooks(config: &mut Config, plugin_hook_sources: Vec) { if let Err(err) = config.features.enable(Feature::CodexHooks) { panic!("test config should allow feature update: {err}"); @@ -229,6 +242,66 @@ if payload.get("prompt") == {blocked_prompt_json}: Ok(()) } +fn write_session_start_and_user_prompt_submit_order_hooks(home: &Path) -> Result<()> { + let session_start_script_path = home.join("session_start_order_hook.py"); + let user_prompt_submit_script_path = home.join("user_prompt_submit_order_hook.py"); + let log_path = home.join("hook_order_log.jsonl"); + + let session_start_script = format!( + r#"import json +from pathlib import Path +import sys + +payload = json.load(sys.stdin) +with Path(r"{log_path}").open("a", encoding="utf-8") as handle: + handle.write(json.dumps({{ + "hook_event_name": payload.get("hook_event_name"), + "source": payload.get("source"), + }}) + "\n") +"#, + log_path = log_path.display(), + ); + let user_prompt_submit_script = format!( + r#"import json +from pathlib import Path +import sys + +payload = json.load(sys.stdin) +with Path(r"{log_path}").open("a", encoding="utf-8") as handle: + handle.write(json.dumps({{ + "hook_event_name": payload.get("hook_event_name"), + "prompt": payload.get("prompt"), + }}) + "\n") +"#, + log_path = log_path.display(), + ); + let hooks = serde_json::json!({ + "hooks": { + "SessionStart": [{ + "hooks": [{ + "type": "command", + "command": format!("python3 {}", session_start_script_path.display()), + "statusMessage": "running session start order hook", + }] + }], + "UserPromptSubmit": [{ + "hooks": [{ + "type": "command", + "command": format!("python3 {}", user_prompt_submit_script_path.display()), + "statusMessage": "running user prompt submit order hook", + }] + }] + } + }); + + fs::write(&session_start_script_path, session_start_script) + .context("write session start order hook script")?; + fs::write(&user_prompt_submit_script_path, user_prompt_submit_script) + .context("write user prompt submit order hook script")?; + fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?; + Ok(()) +} + fn write_pre_tool_use_hook( home: &Path, matcher: Option<&str>, @@ -705,6 +778,109 @@ print(json.dumps({{ Ok(()) } +fn write_compact_session_start_hook_with_context( + home: &Path, + additional_context: &str, +) -> Result<()> { + let script_path = home.join("compact_session_start_hook.py"); + let log_path = home.join("session_start_hook_log.jsonl"); + let additional_context_json = serde_json::to_string(additional_context) + .context("serialize compact session start additional context for test")?; + let script = format!( + r#"import json +from pathlib import Path +import sys + +payload = json.load(sys.stdin) +with Path(r"{log_path}").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") + +print(json.dumps({{ + "hookSpecificOutput": {{ + "hookEventName": "SessionStart", + "additionalContext": {additional_context_json} + }} +}})) +"#, + log_path = log_path.display(), + ); + let hooks = serde_json::json!({ + "hooks": { + "SessionStart": [{ + "matcher": "compact", + "hooks": [{ + "type": "command", + "command": format!("python3 {}", script_path.display()), + "statusMessage": "running compact session start hook", + }] + }] + } + }); + + fs::write(&script_path, script).context("write compact session start hook script")?; + fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?; + Ok(()) +} + +fn write_resume_and_compact_session_start_hook_with_context( + home: &Path, + resume_context: &str, + compact_context: &str, +) -> Result<()> { + let script_path = home.join("resume_and_compact_session_start_hook.py"); + let log_path = home.join("session_start_hook_log.jsonl"); + let resume_context_json = serde_json::to_string(resume_context) + .context("serialize resume session start additional context for test")?; + let compact_context_json = serde_json::to_string(compact_context) + .context("serialize compact session start additional context for test")?; + let script = format!( + r#"import json +from pathlib import Path +import sys + +payload = json.load(sys.stdin) +with Path(r"{log_path}").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") + +contexts = {{ + "resume": {resume_context_json}, + "compact": {compact_context_json}, +}} +print(json.dumps({{ + "hookSpecificOutput": {{ + "hookEventName": "SessionStart", + "additionalContext": contexts[payload["source"]] + }} +}})) +"#, + log_path = log_path.display(), + ); + let hooks = serde_json::json!({ + "hooks": { + "SessionStart": [{ + "matcher": "resume", + "hooks": [{ + "type": "command", + "command": format!("python3 {}", script_path.display()), + "statusMessage": "running resume session start hook", + }] + }, { + "matcher": "compact", + "hooks": [{ + "type": "command", + "command": format!("python3 {}", script_path.display()), + "statusMessage": "running compact session start hook", + }] + }] + } + }); + + fs::write(&script_path, script) + .context("write resume and compact session start hook script")?; + fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?; + Ok(()) +} + fn rollout_hook_prompt_texts(text: &str) -> Result> { let mut texts = Vec::new(); for line in text.lines() { @@ -837,6 +1013,10 @@ fn read_user_prompt_submit_hook_inputs(home: &Path) -> Result Result> { + read_hook_inputs_from_log(home.join("hook_order_log.jsonl").as_path()) +} + fn ev_message_item_done(id: &str, text: &str) -> Value { serde_json::json!({ "type": "response.output_item.done", @@ -1020,6 +1200,54 @@ async fn session_start_hook_sees_materialized_transcript_path() -> Result<()> { Ok(()) } +#[tokio::test] +async fn session_start_runs_before_user_prompt_submit_on_first_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let _response = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "hello after hooks"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = write_session_start_and_user_prompt_submit_order_hooks(home) { + panic!("failed to write hook ordering fixtures: {error}"); + } + }) + .with_config(trust_discovered_hooks); + let test = builder.build(&server).await?; + + test.submit_turn("hello").await?; + + let hook_inputs = read_hook_order_inputs(test.codex_home_path())?; + assert_eq!( + hook_inputs + .iter() + .map(|input| input["hook_event_name"] + .as_str() + .expect("hook input event name")) + .collect::>(), + vec!["SessionStart", "UserPromptSubmit"], + ); + assert_eq!( + hook_inputs[0].get("source").and_then(Value::as_str), + Some("startup") + ); + assert_eq!( + hook_inputs[1].get("prompt").and_then(Value::as_str), + Some("hello") + ); + + Ok(()) +} + #[tokio::test] async fn session_start_hook_spills_large_additional_context() -> Result<()> { skip_if_no_network!(Ok(())); @@ -1125,6 +1353,188 @@ async fn pre_tool_use_hook_spills_large_additional_context() -> Result<()> { Ok(()) } +#[tokio::test] +async fn compact_session_start_hook_records_additional_context_for_next_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "hello before compact"), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-2", "summary after compact"), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-3", "hello after compact"), + ev_completed("resp-3"), + ]), + ], + ) + .await; + let additional_context = "remember the compacted reef"; + let model_provider = non_openai_model_provider(&server); + + let mut builder = test_codex() + .with_pre_build_hook(move |home| { + if let Err(error) = + write_compact_session_start_hook_with_context(home, additional_context) + { + panic!("failed to write compact session start hook fixture: {error}"); + } + }) + .with_config(move |config| { + config.model_provider = model_provider; + trust_discovered_hooks(config); + }); + let test = builder.build(&server).await?; + + test.submit_turn("hello before compact").await?; + test.codex.submit(Op::Compact).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.submit_turn("hello after compact").await?; + + let requests = request_log.requests(); + assert_eq!(requests.len(), 3); + assert!( + !requests[0] + .message_input_texts("developer") + .iter() + .any(|message| message == additional_context), + "compact matcher should not run for initial startup", + ); + assert!( + requests[2] + .message_input_texts("developer") + .iter() + .any(|message| message == additional_context), + "compact matcher should inject additional context before the next model turn", + ); + + let hook_inputs = read_session_start_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!( + hook_inputs[0].get("source").and_then(Value::as_str), + Some("compact") + ); + + Ok(()) +} + +#[tokio::test] +async fn resumed_thread_runs_resume_then_compact_session_start_hooks() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let limit = 200_000; + let over_limit_tokens = 250_000; + let remote_summary = "remote compact summary"; + let resume_context = "remember the resumed reef"; + let compact_context = "remember the compacted reef"; + let compacted_history = vec![ + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: remote_summary.to_string(), + }], + phase: None, + }, + ResponseItem::Compaction { + encrypted_content: "encrypted compact summary".to_string(), + }, + ]; + let compact_mock = + mount_compact_json_once(&server, serde_json::json!({ "output": compacted_history })).await; + + let mut builder = test_codex() + .with_pre_build_hook(move |home| { + if let Err(error) = write_resume_and_compact_session_start_hook_with_context( + home, + resume_context, + compact_context, + ) { + panic!("failed to write resume/compact session start hook fixture: {error}"); + } + }) + .with_config(move |config| { + config.model_auto_compact_token_limit = Some(limit); + trust_discovered_hooks(config); + }); + let initial = builder.build(&server).await?; + let home = initial.home.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .context("rollout path")?; + + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "hello before resume"), + ev_completed_with_tokens("resp-1", over_limit_tokens), + ]), + ) + .await; + initial.submit_turn("hello before resume").await?; + assert!(compact_mock.requests().is_empty()); + + let mut resume_builder = test_codex().with_config(move |config| { + config.model_auto_compact_token_limit = Some(limit); + trust_discovered_hooks(config); + }); + let resumed = resume_builder.resume(&server, home, rollout_path).await?; + let follow_up = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-2", "hello after resume"), + ev_completed("resp-2"), + ]), + ) + .await; + + resumed.submit_turn("hello after resume").await?; + + assert_eq!(compact_mock.requests().len(), 1); + let developer_messages = follow_up.single_request().message_input_texts("developer"); + assert!( + developer_messages + .iter() + .any(|message| message == resume_context), + "resume matcher should inject additional context before the next model turn", + ); + assert!( + developer_messages + .iter() + .any(|message| message == compact_context), + "compact matcher should inject additional context before the next model turn", + ); + + let hook_inputs = read_session_start_hook_inputs(resumed.codex_home_path())?; + assert_eq!( + hook_inputs + .iter() + .filter_map(|input| input.get("source").and_then(Value::as_str)) + .collect::>(), + vec!["resume", "compact"], + ); + + Ok(()) +} + #[tokio::test] async fn stop_hook_spills_large_continuation_prompt() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/hooks/schema/generated/session-start.command.input.schema.json b/codex-rs/hooks/schema/generated/session-start.command.input.schema.json index 385932c364..d460fdb583 100644 --- a/codex-rs/hooks/schema/generated/session-start.command.input.schema.json +++ b/codex-rs/hooks/schema/generated/session-start.command.input.schema.json @@ -37,7 +37,8 @@ "enum": [ "startup", "resume", - "clear" + "clear", + "compact" ], "type": "string" }, diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index 2904497b40..bd1aa2096f 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -24,6 +24,7 @@ pub enum SessionStartSource { Startup, Resume, Clear, + Compact, } impl SessionStartSource { @@ -32,6 +33,7 @@ impl SessionStartSource { Self::Startup => "startup", Self::Resume => "resume", Self::Clear => "clear", + Self::Compact => "compact", } } } diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index 96d5f790be..15a655c976 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -673,7 +673,7 @@ fn permission_mode_schema(_gen: &mut SchemaGenerator) -> Schema { } fn session_start_source_schema(_gen: &mut SchemaGenerator) -> Schema { - string_enum_schema(&["startup", "resume", "clear"]) + string_enum_schema(&["startup", "resume", "clear", "compact"]) } fn compaction_trigger_schema(_gen: &mut SchemaGenerator) -> Schema { From f6970214d2802ffae0c55e3f30bbc051c1482c1d Mon Sep 17 00:00:00 2001 From: evawong-oai Date: Wed, 20 May 2026 14:08:14 -0700 Subject: [PATCH 61/63] Wire MITM hooks into runtime enforcement (#20659) ## Stack 1. Parent PR: #18868 adds MITM hook config and model only. 2. This PR wires runtime enforcement. 3. User facing config follow up: #18240 moves MITM policy into the PermissionProfile network tree. ## Why 1. After the hook model exists, the proxy needs a separate behavior change that can be tested at the request path. 2. This PR makes hooked HTTPS hosts require MITM, evaluates inner requests after CONNECT, mutates headers for matching hooks, and blocks hooked hosts when no hook matches. 3. It also fixes the activation path so a permission profile with MITM hook policy starts the managed proxy. 4. Keeping this separate from #18868 lets reviewers focus on runtime effects, telemetry, and request mutation. ## Summary 1. Store compiled MITM hooks in network proxy state. 2. Require MITM for hooked hosts even when network mode is full. 3. Evaluate inner HTTPS requests against host specific hooks. 4. Apply hook actions by replacing request headers before forwarding. 5. Block hooked hosts when no hook matches and record block telemetry. 6. Treat profile MITM hook policy as managed proxy policy so the proxy starts when needed. 7. Keep the duplicate authorization header replacement and query preserving request rebuild in this runtime PR. 8. Add runtime tests and README guidance for hook enforcement. ## Validation 1. Ran the network proxy MITM policy tests. 2. Ran the hooked host CONNECT test. 3. Ran the authorization header replacement test. 4. Ran the core permission profile proxy activation test for MITM hooks. 5. Ran the scoped Clippy fixer for the network proxy crate. 6. Ran the scoped Clippy fixer for the core crate. --- codex-rs/network-proxy/src/http_proxy.rs | 66 +++++++- codex-rs/network-proxy/src/mitm.rs | 94 +++++++++-- codex-rs/network-proxy/src/mitm_tests.rs | 192 +++++++++++++++++++++++ codex-rs/network-proxy/src/reasons.rs | 1 + codex-rs/network-proxy/src/responses.rs | 3 + codex-rs/network-proxy/src/runtime.rs | 38 ++++- codex-rs/network-proxy/src/socks5.rs | 107 +++++++++++++ codex-rs/network-proxy/src/state.rs | 3 + 8 files changed, 484 insertions(+), 20 deletions(-) diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index fd42fc92e0..f20c01b906 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -80,6 +80,9 @@ use tracing::error; use tracing::info; use tracing::warn; +#[derive(Clone, Copy, Debug)] +struct ConnectMitmEnabled(bool); + pub async fn run_http_proxy( state: Arc, addr: SocketAddr, @@ -256,10 +259,18 @@ async fn http_connect_accept( return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error")); } }; + let host_has_mitm_hooks = match app_state.host_has_mitm_hooks(&host).await { + Ok(has_hooks) => has_hooks, + Err(err) => { + error!("failed to inspect MITM hooks for {host}: {err}"); + return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error")); + } + }; + let connect_needs_mitm = mode == NetworkMode::Limited || host_has_mitm_hooks; - if mode == NetworkMode::Limited && mitm_state.is_none() { - // Limited mode is designed to be read-only. Without MITM, a CONNECT tunnel would hide the - // inner HTTP method/headers from the proxy, effectively bypassing method policy. + if connect_needs_mitm && mitm_state.is_none() { + // CONNECT needs MITM whenever HTTPS policy depends on inner-request inspection, either for + // limited-mode method enforcement or for host-specific MITM hooks. emit_http_block_decision_audit_event( &app_state, BlockDecisionAuditEventArgs { @@ -286,7 +297,7 @@ async fn http_connect_accept( reason: REASON_MITM_REQUIRED.to_string(), client: client.clone(), method: Some("CONNECT".to_string()), - mode: Some(NetworkMode::Limited), + mode: Some(mode), protocol: "http-connect".to_string(), decision: Some(details.decision.as_str().to_string()), source: Some(details.source.as_str().to_string()), @@ -295,14 +306,16 @@ async fn http_connect_accept( .await; let client = client.as_deref().unwrap_or_default(); warn!( - "CONNECT blocked; MITM required for read-only HTTPS in limited mode (client={client}, host={host}, mode=limited, allowed_methods=GET, HEAD, OPTIONS)" + "CONNECT blocked; MITM required to enforce HTTPS policy (client={client}, host={host}, mode={mode:?}, hooked_host={host_has_mitm_hooks})" ); return Err(blocked_text_with_details(REASON_MITM_REQUIRED, &details)); } req.extensions_mut().insert(ProxyTarget(authority)); + req.extensions_mut() + .insert(ConnectMitmEnabled(connect_needs_mitm)); req.extensions_mut().insert(mode); - if let Some(mitm_state) = mitm_state { + if connect_needs_mitm && let Some(mitm_state) = mitm_state { req.extensions_mut().insert(mitm_state); } @@ -331,7 +344,10 @@ async fn http_connect_proxy(upgraded: Upgraded) -> Result<(), Infallible> { return Ok(()); }; - if mode == NetworkMode::Limited + if upgraded + .extensions() + .get::() + .is_some_and(|enabled| enabled.0) && upgraded .extensions() .get::>() @@ -1094,6 +1110,42 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); } + #[tokio::test] + async fn http_connect_accept_blocks_hooked_host_in_full_mode_without_mitm_state() { + let mut policy = NetworkProxySettings { + mitm: true, + mitm_hooks: vec![crate::mitm_hook::MitmHookConfig { + host: "api.github.com".to_string(), + matcher: crate::mitm_hook::MitmHookMatchConfig { + methods: vec!["POST".to_string()], + path_prefixes: vec!["/repos/openai/".to_string()], + ..crate::mitm_hook::MitmHookMatchConfig::default() + }, + actions: crate::mitm_hook::MitmHookActionsConfig::default(), + }], + ..Default::default() + }; + policy.set_allowed_domains(vec!["api.github.com".to_string()]); + let state = Arc::new(network_proxy_state_for_policy(policy)); + + let mut req = Request::builder() + .method(Method::CONNECT) + .uri("https://api.github.com:443") + .header("host", "api.github.com:443") + .body(Body::empty()) + .unwrap(); + req.extensions_mut().insert(state); + + let response = http_connect_accept(/*policy_decider*/ None, req) + .await + .unwrap_err(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + response.headers().get("x-proxy-error").unwrap(), + "blocked-by-mitm-required" + ); + } + #[tokio::test] async fn http_proxy_listener_accepts_plain_http1_connect_requests() { let target_listener = TokioTcpListener::bind((Ipv4Addr::LOCALHOST, 0)) diff --git a/codex-rs/network-proxy/src/mitm.rs b/codex-rs/network-proxy/src/mitm.rs index 7be700b1df..fdcb9d0aa2 100644 --- a/codex-rs/network-proxy/src/mitm.rs +++ b/codex-rs/network-proxy/src/mitm.rs @@ -1,7 +1,10 @@ use crate::certs::ManagedMitmCa; use crate::config::NetworkMode; +use crate::mitm_hook::HookEvaluation; +use crate::mitm_hook::MitmHookActions; use crate::policy::normalize_host; use crate::reasons::REASON_METHOD_NOT_ALLOWED; +use crate::reasons::REASON_MITM_HOOK_DENIED; use crate::responses::blocked_text_response; use crate::responses::text_response; use crate::runtime::HostBlockDecision; @@ -23,6 +26,7 @@ use rama_core::rt::Executor; use rama_core::service::service_fn; use rama_http::Body; use rama_http::BodyDataStream; +use rama_http::HeaderMap; use rama_http::HeaderValue; use rama_http::Request; use rama_http::Response; @@ -71,6 +75,13 @@ struct MitmRequestContext { mitm: Arc, } +enum MitmPolicyDecision { + Allow { + hook_actions: Option, + }, + Block(Response), +} + const MITM_INSPECT_BODIES: bool = false; const MITM_MAX_BODY_BYTES: usize = 4096; @@ -86,9 +97,10 @@ impl std::fmt::Debug for MitmState { impl MitmState { pub(crate) fn new(config: MitmUpstreamConfig) -> Result { - // MITM exists to make limited-mode HTTPS enforceable: once CONNECT is established, plain - // proxying would lose visibility into the inner HTTP request. We generate/load a local CA - // and issue per-host leaf certs so we can terminate TLS and apply policy. + // MITM exists when HTTPS policy depends on the inner request: limited-mode method clamps + // and host-specific hooks both need visibility after CONNECT is established. We + // generate/load a local CA and issue per-host leaf certs so we can terminate TLS and + // apply policy. let ca = ManagedMitmCa::load_or_create()?; let upstream = if config.allow_upstream_proxy { @@ -200,9 +212,10 @@ async fn handle_mitm_request( } async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Result { - if let Some(response) = mitm_blocking_response(&req, &request_ctx.policy).await? { - return Ok(response); - } + let hook_actions = match evaluate_mitm_policy(&req, &request_ctx.policy).await? { + MitmPolicyDecision::Allow { hook_actions } => hook_actions, + MitmPolicyDecision::Block(response) => return Ok(response), + }; let target_host = request_ctx.policy.target_host.clone(); let target_port = request_ctx.policy.target_port; @@ -213,6 +226,7 @@ async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Resu let log_path = path_for_log(req.uri()); let (mut parts, body) = req.into_parts(); + apply_mitm_hook_actions(&mut parts.headers, hook_actions.as_ref()); let authority = authority_header_value(&target_host, target_port); parts.uri = build_https_uri(&authority, &path)?; parts @@ -247,12 +261,23 @@ async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Resu ) } +#[cfg_attr(not(test), allow(dead_code))] async fn mitm_blocking_response( req: &Request, policy: &MitmPolicyContext, ) -> Result> { + match evaluate_mitm_policy(req, policy).await? { + MitmPolicyDecision::Allow { .. } => Ok(None), + MitmPolicyDecision::Block(response) => Ok(Some(response)), + } +} + +async fn evaluate_mitm_policy( + req: &Request, + policy: &MitmPolicyContext, +) -> Result { if req.method().as_str() == "CONNECT" { - return Ok(Some(text_response( + return Ok(MitmPolicyDecision::Block(text_response( StatusCode::METHOD_NOT_ALLOWED, "CONNECT not supported inside MITM", ))); @@ -272,7 +297,7 @@ async fn mitm_blocking_response( "MITM host mismatch (target={}, request_host={normalized})", policy.target_host ); - return Ok(Some(text_response( + return Ok(MitmPolicyDecision::Block(text_response( StatusCode::BAD_REQUEST, "host mismatch", ))); @@ -307,9 +332,41 @@ async fn mitm_blocking_response( "MITM blocked local/private target after CONNECT (host={}, port={}, method={method}, path={log_path})", policy.target_host, policy.target_port ); - return Ok(Some(blocked_text_response(reason))); + return Ok(MitmPolicyDecision::Block(blocked_text_response(reason))); } + let hook_actions = match policy + .app_state + .evaluate_mitm_hook_request(&policy.target_host, req) + .await? + { + HookEvaluation::Matched { actions } => Some(actions), + HookEvaluation::HookedHostNoMatch => { + let _ = policy + .app_state + .record_blocked(BlockedRequest::new(BlockedRequestArgs { + host: policy.target_host.clone(), + reason: REASON_MITM_HOOK_DENIED.to_string(), + client: client.clone(), + method: Some(method.clone()), + mode: Some(policy.mode), + protocol: "https".to_string(), + decision: None, + source: None, + port: Some(policy.target_port), + })) + .await; + warn!( + "MITM blocked by hook policy (host={}, method={method}, mode={:?})", + policy.target_host, policy.mode + ); + return Ok(MitmPolicyDecision::Block(blocked_text_response( + REASON_MITM_HOOK_DENIED, + ))); + } + HookEvaluation::NoHooksForHost => None, + }; + if !policy.mode.allows_method(&method) { let _ = policy .app_state @@ -329,10 +386,25 @@ async fn mitm_blocking_response( "MITM blocked by method policy (host={}, method={method}, path={log_path}, mode={:?}, allowed_methods=GET, HEAD, OPTIONS)", policy.target_host, policy.mode ); - return Ok(Some(blocked_text_response(REASON_METHOD_NOT_ALLOWED))); + return Ok(MitmPolicyDecision::Block(blocked_text_response( + REASON_METHOD_NOT_ALLOWED, + ))); } - Ok(None) + Ok(MitmPolicyDecision::Allow { hook_actions }) +} + +fn apply_mitm_hook_actions(headers: &mut HeaderMap, actions: Option<&MitmHookActions>) { + let Some(actions) = actions else { + return; + }; + + for header_name in &actions.strip_request_headers { + headers.remove(header_name); + } + for injected_header in &actions.inject_request_headers { + headers.insert(injected_header.name.clone(), injected_header.value.clone()); + } } fn respond_with_inspection( diff --git a/codex-rs/network-proxy/src/mitm_tests.rs b/codex-rs/network-proxy/src/mitm_tests.rs index 8623720752..098eb745b1 100644 --- a/codex-rs/network-proxy/src/mitm_tests.rs +++ b/codex-rs/network-proxy/src/mitm_tests.rs @@ -2,13 +2,39 @@ use super::*; use crate::config::NetworkProxySettings; use crate::reasons::REASON_METHOD_NOT_ALLOWED; +use crate::reasons::REASON_MITM_HOOK_DENIED; use crate::reasons::REASON_NOT_ALLOWED_LOCAL; use crate::runtime::network_proxy_state_for_policy; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use rama_http::Body; +use rama_http::HeaderMap; +use rama_http::HeaderValue; use rama_http::Method; use rama_http::Request; use rama_http::StatusCode; +use rama_http::header::HeaderName; +use tempfile::NamedTempFile; + +fn github_write_hook() -> crate::mitm_hook::MitmHookConfig { + crate::mitm_hook::MitmHookConfig { + host: "api.github.com".to_string(), + matcher: crate::mitm_hook::MitmHookMatchConfig { + methods: vec!["POST".to_string(), "PUT".to_string()], + path_prefixes: vec!["/repos/openai/".to_string()], + ..crate::mitm_hook::MitmHookMatchConfig::default() + }, + actions: crate::mitm_hook::MitmHookActionsConfig { + strip_request_headers: vec!["authorization".to_string()], + inject_request_headers: vec![crate::mitm_hook::InjectedHeaderConfig { + name: "authorization".to_string(), + secret_env_var: Some("CODEX_GITHUB_TOKEN".to_string()), + secret_file: None, + prefix: Some("Bearer ".to_string()), + }], + }, + } +} fn policy_ctx( app_state: Arc, @@ -126,3 +152,169 @@ async fn mitm_policy_rechecks_local_private_target_after_connect() { assert_eq!(blocked[0].host, "10.0.0.1"); assert_eq!(blocked[0].port, Some(443)); } + +#[tokio::test] +async fn mitm_policy_allows_matching_hooked_write_in_full_mode() { + let secret_file = NamedTempFile::new().unwrap(); + std::fs::write(secret_file.path(), "ghp-secret\n").unwrap(); + let mut hook = github_write_hook(); + hook.actions.inject_request_headers[0].secret_env_var = None; + hook.actions.inject_request_headers[0].secret_file = + Some(secret_file.path().display().to_string()); + let mut network = NetworkProxySettings { + mitm: true, + mitm_hooks: vec![hook], + mode: NetworkMode::Full, + ..NetworkProxySettings::default() + }; + network.set_allowed_domains(vec!["api.github.com".to_string()]); + let app_state = Arc::new(network_proxy_state_for_policy(network)); + let ctx = policy_ctx( + app_state.clone(), + NetworkMode::Full, + "api.github.com", + /*target_port*/ 443, + ); + let req = Request::builder() + .method(Method::POST) + .uri("/repos/openai/codex/issues") + .header(HOST, "api.github.com") + .body(Body::empty()) + .unwrap(); + + let response = mitm_blocking_response(&req, &ctx).await.unwrap(); + + assert!( + response.is_none(), + "matching hook should be allowed in full mode" + ); + assert_eq!(app_state.blocked_snapshot().await.unwrap().len(), 0); +} + +#[tokio::test] +async fn mitm_policy_blocks_matching_hooked_write_in_limited_mode() { + let mut hook = github_write_hook(); + hook.actions.inject_request_headers.clear(); + let mut network = NetworkProxySettings { + mitm: true, + mitm_hooks: vec![hook], + mode: NetworkMode::Limited, + ..NetworkProxySettings::default() + }; + network.set_allowed_domains(vec!["api.github.com".to_string()]); + let app_state = Arc::new(network_proxy_state_for_policy(network)); + let ctx = policy_ctx( + app_state.clone(), + NetworkMode::Limited, + "api.github.com", + /*target_port*/ 443, + ); + let req = Request::builder() + .method(Method::POST) + .uri("/repos/openai/codex/issues") + .header(HOST, "api.github.com") + .body(Body::empty()) + .unwrap(); + + let response = mitm_blocking_response(&req, &ctx) + .await + .unwrap() + .expect("matching POST hook should still be blocked in limited mode"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + response.headers().get("x-proxy-error").unwrap(), + "blocked-by-method-policy" + ); + + let blocked = app_state.drain_blocked().await.unwrap(); + assert_eq!(blocked.len(), 1); + assert_eq!(blocked[0].reason, REASON_METHOD_NOT_ALLOWED); + assert_eq!(blocked[0].method.as_deref(), Some("POST")); + assert_eq!(blocked[0].host, "api.github.com"); + assert_eq!(blocked[0].port, Some(443)); +} + +#[tokio::test] +async fn mitm_policy_blocks_hook_miss_for_hooked_host_and_records_telemetry_in_full_mode() { + let secret_file = NamedTempFile::new().unwrap(); + std::fs::write(secret_file.path(), "ghp-secret\n").unwrap(); + let mut hook = github_write_hook(); + hook.actions.inject_request_headers[0].secret_env_var = None; + hook.actions.inject_request_headers[0].secret_file = + Some(secret_file.path().display().to_string()); + let mut network = NetworkProxySettings { + mitm: true, + mitm_hooks: vec![hook], + mode: NetworkMode::Full, + ..NetworkProxySettings::default() + }; + network.set_allowed_domains(vec!["api.github.com".to_string()]); + let app_state = Arc::new(network_proxy_state_for_policy(network)); + let ctx = policy_ctx( + app_state.clone(), + NetworkMode::Full, + "api.github.com", + /*target_port*/ 443, + ); + let req = Request::builder() + .method(Method::GET) + .uri("/repos/openai/codex/issues?token=secret") + .header(HOST, "api.github.com") + .header("authorization", "Bearer user-supplied") + .body(Body::empty()) + .unwrap(); + + let response = mitm_blocking_response(&req, &ctx) + .await + .unwrap() + .expect("hook miss should be blocked"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + response.headers().get("x-proxy-error").unwrap(), + "blocked-by-mitm-hook" + ); + + let blocked = app_state.drain_blocked().await.unwrap(); + assert_eq!(blocked.len(), 1); + assert_eq!(blocked[0].reason, REASON_MITM_HOOK_DENIED); + assert_eq!(blocked[0].method.as_deref(), Some("GET")); + assert_eq!(blocked[0].host, "api.github.com"); + assert_eq!(blocked[0].port, Some(443)); +} + +#[test] +fn apply_mitm_hook_actions_replaces_authorization_header() { + let mut headers = HeaderMap::new(); + headers.append( + HeaderName::from_static("authorization"), + HeaderValue::from_static("Bearer user-supplied"), + ); + headers.append( + HeaderName::from_static("x-request-id"), + HeaderValue::from_static("req_123"), + ); + + let actions = crate::mitm_hook::MitmHookActions { + strip_request_headers: vec![HeaderName::from_static("authorization")], + inject_request_headers: vec![crate::mitm_hook::ResolvedInjectedHeader { + name: HeaderName::from_static("authorization"), + value: HeaderValue::from_static("Bearer secret-token"), + source: crate::mitm_hook::SecretSource::File( + AbsolutePathBuf::try_from("/tmp/github-token").unwrap(), + ), + }], + }; + + apply_mitm_hook_actions(&mut headers, Some(&actions)); + + assert_eq!( + headers.get("authorization"), + Some(&HeaderValue::from_static("Bearer secret-token")) + ); + assert_eq!( + headers.get("x-request-id"), + Some(&HeaderValue::from_static("req_123")) + ); +} diff --git a/codex-rs/network-proxy/src/reasons.rs b/codex-rs/network-proxy/src/reasons.rs index b844eadf3d..67f570f128 100644 --- a/codex-rs/network-proxy/src/reasons.rs +++ b/codex-rs/network-proxy/src/reasons.rs @@ -1,5 +1,6 @@ pub(crate) const REASON_DENIED: &str = "denied"; pub(crate) const REASON_METHOD_NOT_ALLOWED: &str = "method_not_allowed"; +pub(crate) const REASON_MITM_HOOK_DENIED: &str = "mitm_hook_denied"; pub(crate) const REASON_MITM_REQUIRED: &str = "mitm_required"; pub(crate) const REASON_NOT_ALLOWED: &str = "not_allowed"; pub(crate) const REASON_NOT_ALLOWED_LOCAL: &str = "not_allowed_local"; diff --git a/codex-rs/network-proxy/src/responses.rs b/codex-rs/network-proxy/src/responses.rs index c0418c72b5..d2aeb990e8 100644 --- a/codex-rs/network-proxy/src/responses.rs +++ b/codex-rs/network-proxy/src/responses.rs @@ -3,6 +3,7 @@ use crate::network_policy::NetworkPolicyDecision; use crate::network_policy::NetworkProtocol; use crate::reasons::REASON_DENIED; use crate::reasons::REASON_METHOD_NOT_ALLOWED; +use crate::reasons::REASON_MITM_HOOK_DENIED; use crate::reasons::REASON_MITM_REQUIRED; use crate::reasons::REASON_NOT_ALLOWED; use crate::reasons::REASON_NOT_ALLOWED_LOCAL; @@ -53,6 +54,7 @@ pub fn blocked_header_value(reason: &str) -> &'static str { REASON_NOT_ALLOWED | REASON_NOT_ALLOWED_LOCAL => "blocked-by-allowlist", REASON_DENIED => "blocked-by-denylist", REASON_METHOD_NOT_ALLOWED => "blocked-by-method-policy", + REASON_MITM_HOOK_DENIED => "blocked-by-mitm-hook", REASON_MITM_REQUIRED => "blocked-by-mitm-required", _ => "blocked-by-policy", } @@ -64,6 +66,7 @@ pub fn blocked_message(reason: &str) -> &'static str { REASON_NOT_ALLOWED_LOCAL => "Sandbox policy blocks local/private network addresses.", REASON_DENIED => "Domain denied by the sandbox policy.", REASON_METHOD_NOT_ALLOWED => "Method not allowed in limited mode.", + REASON_MITM_HOOK_DENIED => "HTTPS request denied by MITM hook policy.", REASON_MITM_REQUIRED => "MITM required for limited HTTPS.", REASON_PROXY_DISABLED => "network proxy is disabled", _ => "Request blocked by network policy.", diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index 11e3804baf..60894d5d5b 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -3,6 +3,9 @@ use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; use crate::config::ValidatedUnixSocketPath; use crate::mitm::MitmState; +use crate::mitm_hook::HookEvaluation; +use crate::mitm_hook::MitmHooksByHost; +use crate::mitm_hook::evaluate_mitm_hooks; use crate::policy::Host; use crate::policy::is_loopback_host; use crate::policy::is_non_public_ip; @@ -159,6 +162,7 @@ pub struct ConfigState { pub allow_set: GlobSet, pub deny_set: GlobSet, pub mitm: Option>, + pub mitm_hooks: MitmHooksByHost, pub constraints: NetworkProxyConstraints, pub blocked: VecDeque, pub blocked_total: u64, @@ -585,6 +589,22 @@ impl NetworkProxyState { Ok(guard.mitm.clone()) } + pub(crate) async fn evaluate_mitm_hook_request( + &self, + host: &str, + req: &rama_http::Request, + ) -> Result { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(evaluate_mitm_hooks(&guard.mitm_hooks, host, req)) + } + + pub async fn host_has_mitm_hooks(&self, host: &str) -> Result { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(guard.mitm_hooks.contains_key(&normalize_host(host))) + } + pub async fn add_allowed_domain(&self, host: &str) -> Result<()> { self.update_domain_list(host, DomainListKind::Allow).await } @@ -846,9 +866,23 @@ pub(crate) fn network_proxy_state_for_policy( mut network: crate::config::NetworkProxySettings, ) -> NetworkProxyState { network.enabled = true; - network.mode = NetworkMode::Full; let config = NetworkProxyConfig { network }; - let state = build_config_state(config, NetworkProxyConstraints::default()).unwrap(); + let state = ConfigState { + allow_set: crate::policy::compile_allowlist_globset( + &config.network.allowed_domains().unwrap_or_default(), + ) + .unwrap(), + blocked: VecDeque::new(), + blocked_total: 0, + config: config.clone(), + constraints: NetworkProxyConstraints::default(), + deny_set: crate::policy::compile_denylist_globset( + &config.network.denied_domains().unwrap_or_default(), + ) + .unwrap(), + mitm: None, + mitm_hooks: crate::mitm_hook::compile_mitm_hooks(&config).unwrap(), + }; NetworkProxyState::with_reloader(state, Arc::new(NoopReloader)) } diff --git a/codex-rs/network-proxy/src/socks5.rs b/codex-rs/network-proxy/src/socks5.rs index a1c430c7db..8369c4da80 100644 --- a/codex-rs/network-proxy/src/socks5.rs +++ b/codex-rs/network-proxy/src/socks5.rs @@ -12,6 +12,7 @@ use crate::network_policy::emit_block_decision_audit_event; use crate::network_policy::evaluate_host_policy; use crate::policy::normalize_host; use crate::reasons::REASON_METHOD_NOT_ALLOWED; +use crate::reasons::REASON_MITM_REQUIRED; use crate::reasons::REASON_PROXY_DISABLED; use crate::responses::PolicyDecisionDetails; use crate::responses::blocked_message_with_policy; @@ -240,6 +241,51 @@ async fn handle_socks5_tcp( } } + match app_state.host_has_mitm_hooks(&host).await { + Ok(true) => { + emit_socks_block_decision_audit_event( + &app_state, + NetworkDecisionSource::ModeGuard, + REASON_MITM_REQUIRED, + NetworkProtocol::Socks5Tcp, + host.as_str(), + port, + client.as_deref(), + ); + let details = PolicyDecisionDetails { + decision: NetworkPolicyDecision::Deny, + reason: REASON_MITM_REQUIRED, + source: NetworkDecisionSource::ModeGuard, + protocol: NetworkProtocol::Socks5Tcp, + host: &host, + port, + }; + let _ = app_state + .record_blocked(BlockedRequest::new(BlockedRequestArgs { + host: host.clone(), + reason: REASON_MITM_REQUIRED.to_string(), + client: client.clone(), + method: None, + mode: Some(NetworkMode::Full), + protocol: "socks5".to_string(), + decision: Some(details.decision.as_str().to_string()), + source: Some(details.source.as_str().to_string()), + port: Some(port), + })) + .await; + let client = client.as_deref().unwrap_or_default(); + warn!( + "SOCKS blocked; MITM required to enforce HTTPS policy (client={client}, host={host}, mode=full)" + ); + return Err(policy_denied_error(REASON_MITM_REQUIRED, &details).into()); + } + Ok(false) => {} + Err(err) => { + error!("failed to inspect MITM hooks for {host}: {err}"); + return Err(io::Error::other("proxy error").into()); + } + } + let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs { protocol: NetworkProtocol::Socks5Tcp, host: host.clone(), @@ -501,11 +547,14 @@ mod tests { use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; use crate::config::NetworkProxySettings; + use crate::mitm_hook::MitmHookConfig; + use crate::mitm_hook::MitmHookMatchConfig; use crate::network_policy::test_support::POLICY_DECISION_EVENT_NAME; use crate::network_policy::test_support::capture_events; use crate::network_policy::test_support::find_event_by_name; use crate::runtime::ConfigReloader; use crate::runtime::ConfigState; + use crate::runtime::network_proxy_state_for_policy; use crate::state::NetworkProxyConstraints; use crate::state::build_config_state; use async_trait::async_trait; @@ -589,6 +638,64 @@ mod tests { assert_eq!(event.field("client.address"), Some("unknown")); } + #[tokio::test(flavor = "current_thread")] + async fn handle_socks5_tcp_blocks_hooked_host_in_full_mode() { + let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings { + enabled: true, + mode: NetworkMode::Full, + mitm: true, + mitm_hooks: vec![MitmHookConfig { + host: "api.github.com".to_string(), + matcher: MitmHookMatchConfig { + methods: vec!["GET".to_string()], + path_prefixes: vec!["/".to_string()], + ..MitmHookMatchConfig::default() + }, + ..MitmHookConfig::default() + }], + ..NetworkProxySettings::default() + })); + let mut request = + TcpRequest::new(HostWithPort::try_from("api.github.com:443").expect("valid authority")); + request.extensions_mut().insert(state.clone()); + + let (result, events) = capture_events(|| async { + handle_socks5_tcp( + request, + TargetCheckedTcpConnector::new(state.clone()), + /*policy_decider*/ None, + ) + .await + }) + .await; + assert!(result.is_err(), "hooked host should require MITM"); + + let blocked = state.drain_blocked().await.unwrap(); + assert_eq!(blocked.len(), 1); + assert_eq!(blocked[0].reason, REASON_MITM_REQUIRED); + assert_eq!(blocked[0].host, "api.github.com"); + assert_eq!(blocked[0].port, Some(443)); + assert_eq!(blocked[0].protocol, "socks5"); + + let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME) + .expect("expected policy decision event"); + assert_eq!(event.field("network.policy.scope"), Some("non_domain")); + assert_eq!(event.field("network.policy.decision"), Some("deny")); + assert_eq!(event.field("network.policy.source"), Some("mode_guard")); + assert_eq!( + event.field("network.policy.reason"), + Some(REASON_MITM_REQUIRED) + ); + assert_eq!( + event.field("network.transport.protocol"), + Some("socks5_tcp") + ); + assert_eq!(event.field("server.address"), Some("api.github.com")); + assert_eq!(event.field("server.port"), Some("443")); + assert_eq!(event.field("http.request.method"), Some("none")); + assert_eq!(event.field("client.address"), Some("unknown")); + } + #[tokio::test(flavor = "current_thread")] async fn inspect_socks5_udp_emits_block_decision_for_mode_guard_deny() { let state = state_for_settings(NetworkProxySettings { diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index 67a10d3bf5..32cdfab149 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -5,6 +5,7 @@ use crate::config::NetworkUnixSocketPermissions; use crate::mitm::MitmState; use crate::mitm::MitmUpstreamConfig; use crate::mitm_hook::MitmHookConfig; +use crate::mitm_hook::compile_mitm_hooks; use crate::mitm_hook::validate_mitm_hook_config; use crate::policy::DomainPattern; use crate::policy::compile_allowlist_globset; @@ -71,6 +72,7 @@ pub fn build_config_state( .map_err(NetworkProxyConstraintError::into_anyhow)?; let deny_set = compile_denylist_globset(&denied_domains)?; let allow_set = compile_allowlist_globset(&allowed_domains)?; + let mitm_hooks = compile_mitm_hooks(&config)?; let mitm = if config.network.mitm { Some(Arc::new(MitmState::new(MitmUpstreamConfig { allow_upstream_proxy: config.network.allow_upstream_proxy, @@ -84,6 +86,7 @@ pub fn build_config_state( allow_set, deny_set, mitm, + mitm_hooks, constraints, blocked: std::collections::VecDeque::new(), blocked_total: 0, From f48be015d602da25e11d0f3e0f0b3efdda7e271f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 20 May 2026 14:28:11 -0700 Subject: [PATCH 62/63] release: use DotSlash zstd for package archives (#23752) ## Why The Windows release job installed DotSlash successfully, but package archive creation still failed while writing `codex-package-*.tar.zst`. The Python archiver used `shutil.which("zstd")`, which does not reliably find the extensionless DotSlash manifest at `.github/workflows/zstd` from native Windows Python. That left release packaging dependent on a command named exactly `zstd` being discoverable on `PATH`, even though the repository already carries a DotSlash wrapper for Windows runners. ## What changed - Add `resolve_zstd_command()` to prefer a real `zstd` binary when present. - Fall back to invoking `dotslash .github/workflows/zstd` when `zstd` is not on `PATH`. - Keep the error explicit when neither `zstd` nor the DotSlash fallback is available. - Add unit coverage for direct `zstd`, DotSlash fallback, and missing-tool error paths. ## Verification - `python3 -m unittest discover -s scripts/codex_package -p 'test_*.py'` - `python3 -m py_compile scripts/codex_package/*.py` --- scripts/codex_package/archive.py | 33 ++++++++++++++++--- scripts/codex_package/test_archive.py | 47 +++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 scripts/codex_package/test_archive.py diff --git a/scripts/codex_package/archive.py b/scripts/codex_package/archive.py index fe09c0a4f7..08944a650f 100644 --- a/scripts/codex_package/archive.py +++ b/scripts/codex_package/archive.py @@ -5,8 +5,14 @@ import subprocess import tarfile import tempfile import zipfile +from collections.abc import Callable from pathlib import Path +from .targets import REPO_ROOT + + +ZSTD_DOTSLASH = REPO_ROOT / ".github" / "workflows" / "zstd" + def write_archive(package_dir: Path, archive_path: Path, *, force: bool) -> None: if is_relative_to(archive_path, package_dir): @@ -63,14 +69,33 @@ def write_tar_archive(package_dir: Path, archive_path: Path, *, mode: str) -> No def write_tar_zst_archive(package_dir: Path, archive_path: Path) -> None: - zstd = shutil.which("zstd") - if zstd is None: - raise RuntimeError("zstd is required to write .tar.zst archives.") + zstd_command = resolve_zstd_command() with tempfile.TemporaryDirectory(prefix="codex-package-archive-") as temp_dir_str: tar_path = Path(temp_dir_str) / "package.tar" write_tar_archive(package_dir, tar_path, mode="w") - subprocess.check_call([zstd, "-T0", "-19", "-f", str(tar_path), "-o", str(archive_path)]) + subprocess.check_call( + [*zstd_command, "-T0", "-19", "-f", str(tar_path), "-o", str(archive_path)] + ) + + +def resolve_zstd_command( + *, + dotslash_manifest: Path = ZSTD_DOTSLASH, + which: Callable[[str], str | None] = shutil.which, +) -> list[str]: + zstd = which("zstd") + if zstd is not None: + return [zstd] + + dotslash = which("dotslash") + if dotslash is not None and dotslash_manifest.is_file(): + return [dotslash, str(dotslash_manifest)] + + raise RuntimeError( + "zstd is required to write .tar.zst archives. Install zstd, or install " + f"DotSlash so the repository wrapper can run: {dotslash_manifest}" + ) def write_zip_archive(package_dir: Path, archive_path: Path) -> None: diff --git a/scripts/codex_package/test_archive.py b/scripts/codex_package/test_archive.py new file mode 100644 index 0000000000..cade7d12f6 --- /dev/null +++ b/scripts/codex_package/test_archive.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +from pathlib import Path +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from codex_package.archive import resolve_zstd_command + + +class ResolveZstdCommandTest(unittest.TestCase): + def test_prefers_zstd_from_path(self) -> None: + def which(name: str) -> str | None: + return {"zstd": "/usr/bin/zstd", "dotslash": "/usr/bin/dotslash"}.get(name) + + self.assertEqual(resolve_zstd_command(which=which), ["/usr/bin/zstd"]) + + def test_falls_back_to_dotslash_manifest(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + manifest = Path(temp_dir) / "zstd" + manifest.write_text("#!/usr/bin/env dotslash\n{}\n", encoding="utf-8") + + def which(name: str) -> str | None: + return {"dotslash": "/usr/bin/dotslash"}.get(name) + + self.assertEqual( + resolve_zstd_command(dotslash_manifest=manifest, which=which), + ["/usr/bin/dotslash", str(manifest)], + ) + + def test_errors_when_no_zstd_or_dotslash_manifest_is_available(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + missing_manifest = Path(temp_dir) / "zstd" + + with self.assertRaisesRegex(RuntimeError, "zstd is required"): + resolve_zstd_command( + dotslash_manifest=missing_manifest, + which=lambda _name: None, + ) + + +if __name__ == "__main__": + unittest.main() From e1ec0eee5f54c61a9a9a9d448a443f1248d2dba0 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 20 May 2026 14:30:42 -0700 Subject: [PATCH 63/63] windows-sandbox: drive write roots from resolved permissions (#22923) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why This is the third PR in the Windows sandbox `SandboxPolicy` -> `PermissionProfile` migration stack. #22896 introduced `ResolvedWindowsSandboxPermissions`, and #22918 moved elevated runner IPC to carry `PermissionProfile`. This PR starts moving the remaining setup/spawn helpers away from asking legacy enum questions like “is this `WorkspaceWrite`?” and toward resolved runtime permission questions like “does this profile require write capability roots?” ## What changed - Added resolved-permissions helpers for network identity and write-capability detection. - Moved setup write-root gathering to operate on `ResolvedWindowsSandboxPermissions`, with the legacy `SandboxPolicy` wrapper left in place for existing call sites. - Updated identity setup, elevated capture setup, and world-writable audit denies to use resolved write roots. - Updated spawn preparation to carry resolved permissions in `SpawnContext` and use them for network blocking, setup write roots, elevated capability SID selection, and legacy capability roots. - Removed a now-unused legacy write-root helper. ## Verification - `cargo test -p codex-windows-sandbox` - `just fix -p codex-windows-sandbox` - Existing stack checks are green on #22896 and #22918; CI has started for this PR. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22923). * #23715 * #23714 * #23167 * __->__ #22923 --- codex-rs/core/tests/suite/windows_sandbox.rs | 48 +++++- codex-rs/windows-sandbox-rs/src/allow.rs | 33 +++- codex-rs/windows-sandbox-rs/src/audit.rs | 29 ++-- .../windows-sandbox-rs/src/elevated_impl.rs | 51 +++--- codex-rs/windows-sandbox-rs/src/identity.rs | 9 +- codex-rs/windows-sandbox-rs/src/lib.rs | 20 ++- .../src/resolved_permissions.rs | 50 +++++- codex-rs/windows-sandbox-rs/src/setup.rs | 84 +++++++-- codex-rs/windows-sandbox-rs/src/spawn_prep.rs | 161 +++++++++++------- .../src/unified_exec/backends/legacy.rs | 10 +- 10 files changed, 361 insertions(+), 134 deletions(-) diff --git a/codex-rs/core/tests/suite/windows_sandbox.rs b/codex-rs/core/tests/suite/windows_sandbox.rs index 189248be3a..155c701d42 100644 --- a/codex-rs/core/tests/suite/windows_sandbox.rs +++ b/codex-rs/core/tests/suite/windows_sandbox.rs @@ -18,6 +18,7 @@ use serial_test::serial; use std::collections::HashMap; use std::ffi::OsString; use std::path::Path; +use std::path::PathBuf; use tempfile::TempDir; struct EnvVarGuard { @@ -46,13 +47,49 @@ impl Drop for EnvVarGuard { } } +enum TestCodexHome { + Persistent(PathBuf), + Temporary(TempDir), +} + +impl TestCodexHome { + fn path(&self) -> &Path { + match self { + Self::Persistent(path) => path.as_path(), + Self::Temporary(temp_dir) => temp_dir.path(), + } + } +} + +fn codex_home_for_windows_sandbox_test(name: &str) -> anyhow::Result { + if let Some(test_tmpdir) = std::env::var_os("TEST_TMPDIR") { + // The elevated backend provisions machine-local sandbox users. Bazel + // retries run in the same Windows VM, so keep CODEX_HOME stable within + // the test temp root and let setup reconcile its persisted ACL state. + let codex_home = PathBuf::from(test_tmpdir).join(name); + std::fs::create_dir_all(&codex_home) + .with_context(|| format!("create stable test CODEX_HOME {}", codex_home.display()))?; + return Ok(TestCodexHome::Persistent(codex_home)); + } + + Ok(TestCodexHome::Temporary(TempDir::new()?)) +} + fn stage_windows_sandbox_helpers() -> anyhow::Result<()> { let test_exe = std::env::current_exe().context("resolve current Windows test executable")?; let test_exe_dir = test_exe .parent() .context("Windows test executable should have a parent directory")?; let resources_dir = test_exe_dir.join("codex-resources"); - std::fs::create_dir_all(&resources_dir)?; + match std::fs::create_dir_all(&resources_dir) { + Ok(()) => {} + Err(err) + if err.kind() == std::io::ErrorKind::PermissionDenied && resources_dir.is_dir() => {} + Err(err) => { + return Err(err) + .with_context(|| format!("create resources dir {}", resources_dir.display())); + } + } for helper_name in ["codex-windows-sandbox-setup", "codex-command-runner"] { let helper = codex_utils_cargo_bin::cargo_bin(helper_name)?; let file_name = Path::new(helper_name).with_extension("exe"); @@ -64,8 +101,9 @@ fn stage_windows_sandbox_helpers() -> anyhow::Result<()> { #[tokio::test] #[serial(codex_home)] async fn windows_restricted_token_rejects_exact_and_glob_deny_read_policy() -> anyhow::Result<()> { - let temp_home = TempDir::new()?; - let _codex_home_guard = EnvVarGuard::set("CODEX_HOME", temp_home.path().as_os_str()); + let codex_home = + codex_home_for_windows_sandbox_test("windows-restricted-token-deny-read-codex-home")?; + let _codex_home_guard = EnvVarGuard::set("CODEX_HOME", codex_home.path().as_os_str()); let workspace = TempDir::new()?; let cwd = dunce::canonicalize(workspace.path())?.abs(); let secret = cwd.join("secret.env"); @@ -144,8 +182,8 @@ async fn windows_restricted_token_rejects_exact_and_glob_deny_read_policy() -> a #[tokio::test] #[serial(codex_home)] async fn windows_elevated_enforces_exact_and_glob_deny_read_policy() -> anyhow::Result<()> { - let temp_home = TempDir::new()?; - let _codex_home_guard = EnvVarGuard::set("CODEX_HOME", temp_home.path().as_os_str()); + let codex_home = codex_home_for_windows_sandbox_test("windows-elevated-deny-read-codex-home")?; + let _codex_home_guard = EnvVarGuard::set("CODEX_HOME", codex_home.path().as_os_str()); stage_windows_sandbox_helpers()?; let workspace = TempDir::new()?; let cwd = dunce::canonicalize(workspace.path())?.abs(); diff --git a/codex-rs/windows-sandbox-rs/src/allow.rs b/codex-rs/windows-sandbox-rs/src/allow.rs index e82cf8cd62..b13081cdb5 100644 --- a/codex-rs/windows-sandbox-rs/src/allow.rs +++ b/codex-rs/windows-sandbox-rs/src/allow.rs @@ -14,12 +14,12 @@ pub struct AllowDenyPaths { pub(crate) fn compute_allow_paths( policy: &SandboxPolicy, - _policy_cwd: &Path, + policy_cwd: &Path, command_cwd: &Path, env_map: &HashMap, ) -> AllowDenyPaths { let permissions = - ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd(policy, command_cwd); + ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd(policy, policy_cwd); compute_allow_paths_for_permissions(&permissions, command_cwd, env_map) } @@ -91,6 +91,35 @@ mod tests { assert!(paths.deny.is_empty(), "no deny paths expected"); } + #[test] + fn uses_policy_cwd_for_legacy_workspace_root() { + let tmp = TempDir::new().expect("tempdir"); + let policy_cwd = tmp.path().join("workspace"); + let command_cwd = policy_cwd.join("subdir"); + fs::create_dir_all(&command_cwd).expect("create command cwd"); + + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + + let paths = compute_allow_paths(&policy, &policy_cwd, &command_cwd, &HashMap::new()); + + assert!( + paths + .allow + .contains(&dunce::canonicalize(&policy_cwd).unwrap()) + ); + assert!( + !paths + .allow + .contains(&dunce::canonicalize(&command_cwd).unwrap()) + ); + assert!(paths.deny.is_empty(), "no deny paths expected"); + } + #[test] fn excludes_tmp_env_vars_when_requested() { let tmp = TempDir::new().expect("tempdir"); diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index c7e7fc0b11..cab0a06adf 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -8,7 +8,8 @@ use crate::logging::debug_log; use crate::logging::log_note; use crate::path_normalization::canonical_path_key; use crate::policy::SandboxPolicy; -use crate::setup::effective_write_roots_for_setup; +use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; +use crate::setup::effective_write_roots_for_permissions; use crate::token::LocalSid; use crate::token::world_sid; use anyhow::Result; @@ -259,11 +260,18 @@ pub fn apply_capability_denies_for_world_writable( let cap_path = cap_sid_file(codex_home); let caps = load_or_create_cap_sids(codex_home)?; std::fs::write(&cap_path, serde_json::to_string(&caps)?)?; - let (active_sids, workspace_roots): (Vec, Vec) = match sandbox_policy { - SandboxPolicy::WorkspaceWrite { .. } => { - let roots = effective_write_roots_for_setup( - sandbox_policy, - cwd, + if matches!( + sandbox_policy, + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } + ) { + return Ok(()); + } + let permissions = + ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd(sandbox_policy, cwd); + let (active_sids, workspace_roots): (Vec, Vec) = + if permissions.uses_write_capabilities_for_cwd(cwd, env_map) { + let roots = effective_write_roots_for_permissions( + &permissions, cwd, env_map, codex_home, @@ -277,14 +285,9 @@ pub fn apply_capability_denies_for_world_writable( }) .collect::>>()?; (active_sids, roots) - } - SandboxPolicy::ReadOnly { .. } => { + } else { (vec![LocalSid::from_string(&caps.readonly)?], Vec::new()) - } - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { - return Ok(()); - } - }; + }; for path in flagged { if workspace_roots .iter() diff --git a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs index 5861317376..b012fd8af5 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs @@ -39,10 +39,11 @@ mod windows_impl { use crate::logging::log_success; use crate::policy::SandboxPolicy; use crate::policy::parse_policy; + use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; use crate::runner_client::spawn_runner_transport; use crate::sandbox_utils::ensure_codex_home_exists; use crate::sandbox_utils::inject_git_safe_directory; - use crate::setup::effective_write_roots_for_setup; + use crate::setup::effective_write_roots_for_permissions; use crate::token::LocalSid; use anyhow::Result; use codex_protocol::models::PermissionProfile; @@ -81,6 +82,10 @@ mod windows_impl { .map(AbsolutePathBuf::to_path_buf) .collect::>(); let policy = parse_policy(policy_json_or_preset)?; + let permissions = ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd( + &policy, + sandbox_policy_cwd, + ); normalize_null_device_env(&mut env_map); ensure_non_interactive_pager(&mut env_map); inherit_path_env(&mut env_map); @@ -112,32 +117,26 @@ mod windows_impl { anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing") } let caps = load_or_create_cap_sids(codex_home)?; - let (sid_for_null, cap_sids) = match &policy { - SandboxPolicy::ReadOnly { .. } => { - let sid = LocalSid::from_string(&caps.readonly)?; - (sid, vec![caps.readonly]) - } - SandboxPolicy::WorkspaceWrite { .. } => { - let write_roots = effective_write_roots_for_setup( - &policy, - sandbox_policy_cwd, - cwd, - &env_map, - codex_home, - write_roots_override, - ); - let cap_sids = write_roots - .iter() - .map(|root| workspace_write_cap_sid_for_root(codex_home, cwd, root)) - .collect::>>()?; - if cap_sids.is_empty() { - anyhow::bail!("workspace-write sandbox has no writable root capability SIDs"); - } - (LocalSid::from_string(&cap_sids[0])?, cap_sids) - } - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { - unreachable!("DangerFullAccess handled above") + let (sid_for_null, cap_sids) = if permissions.uses_write_capabilities_for_cwd(cwd, &env_map) + { + let write_roots = effective_write_roots_for_permissions( + &permissions, + cwd, + &env_map, + codex_home, + write_roots_override, + ); + let cap_sids = write_roots + .iter() + .map(|root| workspace_write_cap_sid_for_root(codex_home, cwd, root)) + .collect::>>()?; + if cap_sids.is_empty() { + anyhow::bail!("workspace-write sandbox has no writable root capability SIDs"); } + (LocalSid::from_string(&cap_sids[0])?, cap_sids) + } else { + let sid = LocalSid::from_string(&caps.readonly)?; + (sid, vec![caps.readonly]) }; unsafe { diff --git a/codex-rs/windows-sandbox-rs/src/identity.rs b/codex-rs/windows-sandbox-rs/src/identity.rs index e49af3017b..30bf7ef493 100644 --- a/codex-rs/windows-sandbox-rs/src/identity.rs +++ b/codex-rs/windows-sandbox-rs/src/identity.rs @@ -1,12 +1,13 @@ use crate::dpapi; use crate::logging::debug_log; use crate::policy::SandboxPolicy; +use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; use crate::setup::SandboxNetworkIdentity; use crate::setup::SandboxUserRecord; use crate::setup::SandboxUsersFile; use crate::setup::SetupMarker; use crate::setup::gather_read_roots; -use crate::setup::gather_write_roots; +use crate::setup::gather_write_roots_for_permissions; use crate::setup::offline_proxy_settings_from_env; use crate::setup::run_elevated_setup; use crate::setup::run_setup_refresh_with_overrides; @@ -143,14 +144,16 @@ pub fn require_logon_sandbox_creds( deny_write_paths_override: &[PathBuf], proxy_enforced: bool, ) -> Result { + let permissions = + ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd(policy, policy_cwd); let sandbox_dir = crate::setup::sandbox_dir(codex_home); let needed_read = read_roots_override .map(<[PathBuf]>::to_vec) .unwrap_or_else(|| gather_read_roots(command_cwd, policy, codex_home)); let needed_write = write_roots_override .map(<[PathBuf]>::to_vec) - .unwrap_or_else(|| gather_write_roots(policy, policy_cwd, command_cwd, env_map)); - let network_identity = SandboxNetworkIdentity::from_policy(policy, proxy_enforced); + .unwrap_or_else(|| gather_write_roots_for_permissions(&permissions, command_cwd, env_map)); + let network_identity = SandboxNetworkIdentity::from_permissions(&permissions, proxy_enforced); let desired_offline_proxy_settings = offline_proxy_settings_from_env(env_map, network_identity); // NOTE: Do not add CODEX_HOME/.sandbox to `needed_write`; it must remain non-writable by the // restricted capability token. The setup helper's `lock_sandbox_dir` is responsible for diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index d662a62db0..0276d61616 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -293,6 +293,7 @@ mod windows_impl { use super::process::create_process_as_user; use super::sandbox_utils::ensure_codex_home_exists; use super::spawn_prep::LegacyAclSids; + use super::spawn_prep::SpawnPrepOptions; use super::spawn_prep::allow_null_device_for_workspace_write; use super::spawn_prep::apply_legacy_session_acl_rules; use super::spawn_prep::legacy_session_capability_roots; @@ -400,17 +401,20 @@ mod windows_impl { .collect::>(); let common = prepare_legacy_spawn_context( policy_json_or_preset, + sandbox_policy_cwd, codex_home, cwd, &mut env_map, &command, - /*inherit_path*/ false, - /*add_git_safe_directory*/ false, + SpawnPrepOptions { + inherit_path: false, + add_git_safe_directory: false, + }, )?; let policy = common.policy; let current_dir = common.current_dir; let logs_base_dir = common.logs_base_dir.as_deref(); - let is_workspace_write = common.is_workspace_write; + let uses_write_capabilities = common.uses_write_capabilities; if !policy.has_full_disk_read_access() { anyhow::bail!( "Restricted read-only access requires the elevated Windows sandbox backend" @@ -429,7 +433,7 @@ mod windows_impl { codex_home, ); let security = prepare_legacy_session_security(&policy, codex_home, cwd, capability_roots)?; - allow_null_device_for_workspace_write(is_workspace_write); + allow_null_device_for_workspace_write(uses_write_capabilities); apply_legacy_session_acl_rules( &policy, sandbox_policy_cwd, @@ -618,7 +622,8 @@ mod windows_impl { #[cfg(test)] mod tests { use crate::policy::SandboxPolicy; - use crate::spawn_prep::should_apply_network_block; + use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; + use std::path::Path; fn workspace_policy(network_access: bool) -> SandboxPolicy { SandboxPolicy::WorkspaceWrite { @@ -629,6 +634,11 @@ mod windows_impl { } } + fn should_apply_network_block(policy: &SandboxPolicy) -> bool { + ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd(policy, Path::new(".")) + .should_apply_network_block() + } + #[test] fn applies_network_block_when_access_is_disabled() { assert!(should_apply_network_block(&workspace_policy( diff --git a/codex-rs/windows-sandbox-rs/src/resolved_permissions.rs b/codex-rs/windows-sandbox-rs/src/resolved_permissions.rs index 5c6b0cdffe..6a43c99d8f 100644 --- a/codex-rs/windows-sandbox-rs/src/resolved_permissions.rs +++ b/codex-rs/windows-sandbox-rs/src/resolved_permissions.rs @@ -56,16 +56,10 @@ pub fn token_mode_for_permission_profile( } impl ResolvedWindowsSandboxPermissions { - pub(crate) fn from_legacy_policy(policy: &SandboxPolicy) -> Self { - Self { - file_system: FileSystemSandboxPolicy::from(policy), - network: NetworkSandboxPolicy::from(policy), - } - } - pub(crate) fn from_legacy_policy_for_cwd(policy: &SandboxPolicy, cwd: &Path) -> Self { Self { - file_system: FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(policy, cwd), + file_system: FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(policy, cwd) + .materialize_project_roots_with_cwd(cwd), network: NetworkSandboxPolicy::from(policy), } } @@ -94,6 +88,18 @@ impl ResolvedWindowsSandboxPermissions { !self.network.is_enabled() } + pub(crate) fn network_policy(&self) -> NetworkSandboxPolicy { + self.network + } + + pub(crate) fn uses_write_capabilities_for_cwd( + &self, + cwd: &Path, + env_map: &HashMap, + ) -> bool { + !self.writable_roots_for_cwd(cwd, env_map).is_empty() + } + pub(crate) fn writable_roots_for_cwd( &self, cwd: &Path, @@ -207,6 +213,34 @@ mod tests { assert_eq!(expected_roots, roots); } + #[test] + fn legacy_workspace_root_stays_bound_to_policy_cwd() { + let tmp = TempDir::new().expect("tempdir"); + let policy_cwd = tmp.path().join("workspace"); + let command_cwd = policy_cwd.join("subdir"); + std::fs::create_dir_all(&command_cwd).expect("create command cwd"); + + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + let permissions = + ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd(&policy, &policy_cwd); + + let roots = permissions + .writable_roots_for_cwd(&command_cwd, &HashMap::new()) + .into_iter() + .map(|root| root.root) + .collect::>(); + + assert_eq!( + roots, + vec![dunce::canonicalize(&policy_cwd).expect("canonical policy cwd")] + ); + } + #[test] fn token_mode_for_profile_without_writable_roots_uses_readonly_capability() { let tmp = TempDir::new().expect("tempdir"); diff --git a/codex-rs/windows-sandbox-rs/src/setup.rs b/codex-rs/windows-sandbox-rs/src/setup.rs index 4c3d0d6f8a..d53aacd957 100644 --- a/codex-rs/windows-sandbox-rs/src/setup.rs +++ b/codex-rs/windows-sandbox-rs/src/setup.rs @@ -18,6 +18,7 @@ use crate::logging::log_note; use crate::path_normalization::canonical_path_key; use crate::path_normalization::canonicalize_path; use crate::policy::SandboxPolicy; +use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; use crate::setup_error::SetupErrorCode; use crate::setup_error::SetupFailure; use crate::setup_error::clear_setup_error_report; @@ -175,8 +176,12 @@ fn run_setup_refresh_inner( let (read_roots, write_roots) = build_payload_roots(&request, &overrides); let deny_read_paths = build_payload_deny_read_paths(overrides.deny_read_paths); let deny_write_paths = build_payload_deny_write_paths(&request, overrides.deny_write_paths); + let permissions = ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd( + request.policy, + request.policy_cwd, + ); let network_identity = - SandboxNetworkIdentity::from_policy(request.policy, request.proxy_enforced); + SandboxNetworkIdentity::from_permissions(&permissions, request.proxy_enforced); let offline_proxy_settings = offline_proxy_settings_from_env(request.env_map, network_identity); let payload = ElevationPayload { version: SETUP_VERSION, @@ -391,15 +396,16 @@ pub(crate) fn gather_read_roots( gather_legacy_full_read_roots(command_cwd, policy, codex_home) } -pub(crate) fn gather_write_roots( - policy: &SandboxPolicy, - policy_cwd: &Path, +pub(crate) fn gather_write_roots_for_permissions( + permissions: &ResolvedWindowsSandboxPermissions, command_cwd: &Path, env_map: &HashMap, ) -> Vec { - let AllowDenyPaths { allow, .. } = - compute_allow_paths(policy, policy_cwd, command_cwd, env_map); - let roots: Vec = allow.into_iter().collect(); + let roots = permissions + .writable_roots_for_cwd(command_cwd, env_map) + .into_iter() + .map(|root| root.root) + .collect::>(); let mut dedup: HashSet = HashSet::new(); let mut out: Vec = Vec::new(); for r in canonical_existing(&roots) { @@ -417,11 +423,29 @@ pub(crate) fn effective_write_roots_for_setup( env_map: &HashMap, codex_home: &Path, write_roots_override: Option<&[PathBuf]>, +) -> Vec { + let permissions = + ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd(policy, policy_cwd); + effective_write_roots_for_permissions( + &permissions, + command_cwd, + env_map, + codex_home, + write_roots_override, + ) +} + +pub(crate) fn effective_write_roots_for_permissions( + permissions: &ResolvedWindowsSandboxPermissions, + command_cwd: &Path, + env_map: &HashMap, + codex_home: &Path, + write_roots_override: Option<&[PathBuf]>, ) -> Vec { let write_roots = if let Some(roots) = write_roots_override { canonical_existing(roots) } else { - gather_write_roots(policy, policy_cwd, command_cwd, env_map) + gather_write_roots_for_permissions(permissions, command_cwd, env_map) }; let write_roots = expand_user_profile_root(write_roots); let write_roots = filter_user_profile_root(write_roots); @@ -465,8 +489,11 @@ pub(crate) enum SandboxNetworkIdentity { } impl SandboxNetworkIdentity { - pub(crate) fn from_policy(policy: &SandboxPolicy, proxy_enforced: bool) -> Self { - if proxy_enforced || !policy.has_full_network_access() { + pub(crate) fn from_permissions( + permissions: &ResolvedWindowsSandboxPermissions, + proxy_enforced: bool, + ) -> Self { + if proxy_enforced || !permissions.network_policy().is_enabled() { Self::Offline } else { Self::Online @@ -737,8 +764,12 @@ pub fn run_elevated_setup( let (read_roots, write_roots) = build_payload_roots(&request, &overrides); let deny_read_paths = build_payload_deny_read_paths(overrides.deny_read_paths); let deny_write_paths = build_payload_deny_write_paths(&request, overrides.deny_write_paths); + let permissions = ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd( + request.policy, + request.policy_cwd, + ); let network_identity = - SandboxNetworkIdentity::from_policy(request.policy, request.proxy_enforced); + SandboxNetworkIdentity::from_permissions(&permissions, request.proxy_enforced); let offline_proxy_settings = offline_proxy_settings_from_env(request.env_map, network_identity); let payload = ElevationPayload { version: SETUP_VERSION, @@ -1487,6 +1518,37 @@ mod tests { assert!(!effective_write_roots.contains(&forbidden_sandbox)); } + #[test] + fn effective_write_roots_use_policy_cwd_for_legacy_workspace_root() { + let tmp = TempDir::new().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + let policy_cwd = tmp.path().join("workspace"); + let command_cwd = policy_cwd.join("subdir"); + fs::create_dir_all(&codex_home).expect("create codex home"); + fs::create_dir_all(&command_cwd).expect("create command cwd"); + + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + + let effective_write_roots = super::effective_write_roots_for_setup( + &policy, + &policy_cwd, + &command_cwd, + &HashMap::new(), + &codex_home, + /*write_roots_override*/ None, + ); + + assert_eq!( + effective_write_roots, + vec![dunce::canonicalize(&policy_cwd).expect("canonical policy cwd")] + ); + } + #[test] fn payload_deny_write_paths_merge_explicit_and_protected_children() { let tmp = TempDir::new().expect("tempdir"); diff --git a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs index 8df1ba804f..efe7a7c7cc 100644 --- a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs +++ b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs @@ -3,6 +3,7 @@ use crate::acl::add_deny_write_ace; use crate::acl::allow_null_device; use crate::allow::AllowDenyPaths; use crate::allow::compute_allow_paths; +use crate::allow::compute_allow_paths_for_permissions; use crate::cap::load_or_create_cap_sids; use crate::cap::workspace_write_cap_sid_for_root; use crate::cap::workspace_write_root_contains_path; @@ -22,7 +23,7 @@ use crate::policy::parse_policy; use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; use crate::sandbox_utils::ensure_codex_home_exists; use crate::sandbox_utils::inject_git_safe_directory; -use crate::setup::effective_write_roots_for_setup; +use crate::setup::effective_write_roots_for_permissions; use crate::token::LocalSid; use crate::token::create_readonly_token_with_cap; use crate::token::create_workspace_write_token_with_caps_from; @@ -42,10 +43,11 @@ use windows_sys::Win32::Foundation::HANDLE; pub(crate) struct SpawnContext { pub(crate) policy: SandboxPolicy, + pub(crate) permissions: ResolvedWindowsSandboxPermissions, pub(crate) current_dir: PathBuf, pub(crate) sandbox_base: PathBuf, pub(crate) logs_base_dir: Option, - pub(crate) is_workspace_write: bool, + pub(crate) uses_write_capabilities: bool, } pub(crate) struct ElevatedSpawnContext { @@ -54,6 +56,12 @@ pub(crate) struct ElevatedSpawnContext { pub(crate) cap_sids: Vec, } +#[derive(Debug, Clone, Copy)] +pub(crate) struct SpawnPrepOptions { + pub(crate) inherit_path: bool, + pub(crate) add_git_safe_directory: bool, +} + pub(crate) struct LegacySessionSecurity { pub(crate) h_token: HANDLE, pub(crate) readonly_sid: Option, @@ -73,18 +81,14 @@ pub(crate) struct LegacyAclSids<'a> { pub(crate) write_root_sids: &'a [RootCapabilitySid], } -pub(crate) fn should_apply_network_block(policy: &SandboxPolicy) -> bool { - ResolvedWindowsSandboxPermissions::from_legacy_policy(policy).should_apply_network_block() -} - fn prepare_spawn_context_common( policy_json_or_preset: &str, + policy_cwd: &Path, codex_home: &Path, cwd: &Path, env_map: &mut HashMap, command: &[String], - inherit_path: bool, - add_git_safe_directory: bool, + options: SpawnPrepOptions, ) -> Result { let policy = parse_policy(policy_json_or_preset)?; if matches!( @@ -96,10 +100,10 @@ fn prepare_spawn_context_common( normalize_null_device_env(env_map); ensure_non_interactive_pager(env_map); - if inherit_path { + if options.inherit_path { inherit_path_env(env_map); } - if add_git_safe_directory { + if options.add_git_safe_directory { inject_git_safe_directory(env_map, cwd); } @@ -109,36 +113,39 @@ fn prepare_spawn_context_common( let logs_base_dir = Some(sandbox_base.clone()); log_start(command, logs_base_dir.as_deref()); - let is_workspace_write = matches!(&policy, SandboxPolicy::WorkspaceWrite { .. }); + let permissions = + ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd(&policy, policy_cwd); + let uses_write_capabilities = permissions.uses_write_capabilities_for_cwd(cwd, env_map); Ok(SpawnContext { policy, + permissions, current_dir: cwd.to_path_buf(), sandbox_base, logs_base_dir, - is_workspace_write, + uses_write_capabilities, }) } pub(crate) fn prepare_legacy_spawn_context( policy_json_or_preset: &str, + policy_cwd: &Path, codex_home: &Path, cwd: &Path, env_map: &mut HashMap, command: &[String], - inherit_path: bool, - add_git_safe_directory: bool, + options: SpawnPrepOptions, ) -> Result { let common = prepare_spawn_context_common( policy_json_or_preset, + policy_cwd, codex_home, cwd, env_map, command, - inherit_path, - add_git_safe_directory, + options, )?; - if should_apply_network_block(&common.policy) { + if common.permissions.should_apply_network_block() { apply_no_network_to_env(env_map)?; } Ok(common) @@ -195,14 +202,15 @@ pub(crate) fn legacy_session_capability_roots( env_map: &HashMap, codex_home: &Path, ) -> Vec { - let allow_paths = compute_allow_paths(policy, policy_cwd, current_dir, env_map) + let permissions = + ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd(policy, policy_cwd); + let allow_paths = compute_allow_paths_for_permissions(&permissions, current_dir, env_map) .allow .into_iter() .collect::>(); - if matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) { - effective_write_roots_for_setup( - policy, - policy_cwd, + if permissions.uses_write_capabilities_for_cwd(current_dir, env_map) { + effective_write_roots_for_permissions( + &permissions, current_dir, env_map, codex_home, @@ -341,7 +349,7 @@ pub(crate) fn apply_legacy_session_acl_rules( if let Some(readonly_sid) = acl_sids.readonly_sid { allow_null_device(readonly_sid.as_ptr()); } - if matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) + if !acl_sids.write_root_sids.is_empty() && let Some(workspace_sid) = matching_root_capability(current_dir, acl_sids.write_root_sids) { @@ -371,32 +379,30 @@ pub(crate) fn prepare_elevated_spawn_context( ) -> Result { let common = prepare_spawn_context_common( policy_json_or_preset, + sandbox_policy_cwd, codex_home, cwd, env_map, command, - /*inherit_path*/ true, - /*add_git_safe_directory*/ true, + SpawnPrepOptions { + inherit_path: true, + add_git_safe_directory: true, + }, )?; - let AllowDenyPaths { allow, deny } = compute_allow_paths( - &common.policy, - sandbox_policy_cwd, - &common.current_dir, - env_map, - ); + let AllowDenyPaths { allow, deny } = + compute_allow_paths_for_permissions(&common.permissions, &common.current_dir, env_map); let write_roots: Vec = allow.into_iter().collect(); let deny_write_paths: Vec = deny.into_iter().collect(); - let computed_write_roots_override = if common.is_workspace_write { + let computed_write_roots_override = if common.uses_write_capabilities { Some(write_roots.as_slice()) } else { None }; let write_roots_for_setup = write_roots_override.or(computed_write_roots_override); - let effective_write_roots = if common.is_workspace_write { - effective_write_roots_for_setup( - &common.policy, - sandbox_policy_cwd, + let effective_write_roots = if common.uses_write_capabilities { + effective_write_roots_for_permissions( + &common.permissions, &common.current_dir, env_map, codex_home, @@ -405,7 +411,7 @@ pub(crate) fn prepare_elevated_spawn_context( } else { Vec::new() }; - let setup_write_roots_override = if common.is_workspace_write { + let setup_write_roots_override = if common.uses_write_capabilities { Some(effective_write_roots.as_slice()) } else { write_roots_override @@ -428,24 +434,20 @@ pub(crate) fn prepare_elevated_spawn_context( /*proxy_enforced*/ false, )?; let caps = load_or_create_cap_sids(codex_home)?; - let (psid_to_use, cap_sids) = match &common.policy { - SandboxPolicy::ReadOnly { .. } => ( + let (psid_to_use, cap_sids) = if common.uses_write_capabilities { + let cap_sids = root_capability_sids(codex_home, cwd, effective_write_roots)? + .into_iter() + .map(|root_sid| root_sid.sid_str) + .collect::>(); + if cap_sids.is_empty() { + anyhow::bail!("workspace-write sandbox has no writable root capability SIDs"); + } + (LocalSid::from_string(&cap_sids[0])?, cap_sids) + } else { + ( LocalSid::from_string(&caps.readonly)?, vec![caps.readonly.clone()], - ), - SandboxPolicy::WorkspaceWrite { .. } => { - let cap_sids = root_capability_sids(codex_home, cwd, effective_write_roots)? - .into_iter() - .map(|root_sid| root_sid.sid_str) - .collect::>(); - if cap_sids.is_empty() { - anyhow::bail!("workspace-write sandbox has no writable root capability SIDs"); - } - (LocalSid::from_string(&cap_sids[0])?, cap_sids) - } - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { - unreachable!("dangerous policies rejected before elevated session prep") - } + ) }; unsafe { @@ -462,19 +464,26 @@ pub(crate) fn prepare_elevated_spawn_context( #[cfg(test)] mod tests { use super::SandboxPolicy; + use super::SpawnPrepOptions; use super::deny_root_capabilities_for_path; use super::legacy_session_capability_roots; use super::prepare_legacy_spawn_context; use super::prepare_spawn_context_common; use super::root_capability_sids; - use super::should_apply_network_block; use crate::cap::load_or_create_cap_sids; use crate::cap::workspace_write_cap_sid_for_root; + use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::collections::HashMap; + use std::path::Path; use tempfile::TempDir; + fn should_apply_network_block(policy: &SandboxPolicy) -> bool { + ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd(policy, Path::new(".")) + .should_apply_network_block() + } + #[test] fn no_network_env_rewrite_applies_for_workspace_write() { assert!(should_apply_network_block( @@ -502,12 +511,15 @@ mod tests { let _context = prepare_legacy_spawn_context( "workspace-write", + cwd.path(), codex_home.path(), cwd.path(), &mut env_map, &["cmd.exe".to_string()], - /*inherit_path*/ true, - /*add_git_safe_directory*/ false, + SpawnPrepOptions { + inherit_path: true, + add_git_safe_directory: false, + }, ) .expect("legacy env prep"); @@ -529,12 +541,15 @@ mod tests { let context = prepare_spawn_context_common( "workspace-write", + cwd.path(), codex_home.path(), cwd.path(), &mut env_map, &["cmd.exe".to_string()], - /*inherit_path*/ true, - /*add_git_safe_directory*/ true, + SpawnPrepOptions { + inherit_path: true, + add_git_safe_directory: true, + }, ) .expect("preserve existing env prep"); assert_eq!(context.policy, SandboxPolicy::new_workspace_write_policy()); @@ -546,6 +561,36 @@ mod tests { ); } + #[test] + fn legacy_session_capability_roots_use_policy_cwd_for_workspace_root() { + let tmp = TempDir::new().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + let policy_cwd = tmp.path().join("workspace"); + let command_cwd = policy_cwd.join("subdir"); + std::fs::create_dir_all(&codex_home).expect("create codex home"); + std::fs::create_dir_all(&command_cwd).expect("create command cwd"); + + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + + let roots = legacy_session_capability_roots( + &policy, + &policy_cwd, + &command_cwd, + &HashMap::new(), + &codex_home, + ); + + assert_eq!( + roots, + vec![dunce::canonicalize(&policy_cwd).expect("canonical policy cwd")] + ); + } + #[test] fn root_capability_sids_only_include_active_roots() { let temp = TempDir::new().expect("tempdir"); diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs index 2cb12bfd71..7d4ccc0404 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs @@ -10,6 +10,7 @@ use crate::process::StdinMode; use crate::process::read_handle_loop; use crate::process::spawn_process_with_pipes; use crate::spawn_prep::LegacyAclSids; +use crate::spawn_prep::SpawnPrepOptions; use crate::spawn_prep::allow_null_device_for_workspace_write; use crate::spawn_prep::apply_legacy_session_acl_rules; use crate::spawn_prep::legacy_session_capability_roots; @@ -283,12 +284,15 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( ) -> Result { let common = prepare_legacy_spawn_context( policy_json_or_preset, + sandbox_policy_cwd, codex_home, cwd, &mut env_map, &command, - /*inherit_path*/ false, - /*add_git_safe_directory*/ false, + SpawnPrepOptions { + inherit_path: false, + add_git_safe_directory: false, + }, )?; if !common.policy.has_full_disk_read_access() { anyhow::bail!("Restricted read-only access requires the elevated Windows sandbox backend"); @@ -311,7 +315,7 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( ); let security = prepare_legacy_session_security(&common.policy, codex_home, cwd, capability_roots)?; - allow_null_device_for_workspace_write(common.is_workspace_write); + allow_null_device_for_workspace_write(common.uses_write_capabilities); apply_legacy_session_acl_rules( &common.policy,