From 0002316687f63f3b9b0155cb9272eca13aae63e3 Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Mon, 1 Jun 2026 18:05:50 -0700 Subject: [PATCH 01/59] feat(remote-control): add pairing start (#25675) ## Why Remote control enrollment authorizes a desktop server, but app-server v2 did not expose the follow-up pairing operation needed to mint a short-lived controller pairing artifact from that enrolled server. Clients need a narrow RPC that starts pairing without exposing the backend `serverId` or conflating pairing with websocket connection state. Issue: N/A; internal remote-control pairing API change. ## What Changed Added experimental app-server v2 `remoteControl/pairing/start` with `manualCode` input and `pairingCode`, nullable `manualPairingCode`, `environmentId`, and Unix-seconds `expiresAt` output. The method serializes under its own `global("remote-control-pairing")` scope and is documented in `app-server/README.md`. Extended the remote-control transport with private `/server/pair` request/response types and normalized `pair_url` handling. Pairing uses the current enrolled server bearer, refreshes that bearer when needed, keeps backend `server_id` private, validates returned `server_id` and `environment_id` against the current enrollment, and preserves backend status/header/body context for failures and malformed responses. Wired the request through `RemoteControlRequestProcessor` and `MessageProcessor`, mapping unavailable/disabled pairing to `invalid_request` and backend failures to internal errors. ## Verification - `just test -p codex-app-server-transport` - `just test -p codex-app-server remote_control_pairing_start_returns_pairing_artifacts` --- codex-rs/app-server-protocol/src/export.rs | 19 + .../src/protocol/common.rs | 17 + .../src/protocol/v2/remote_control.rs | 18 + .../src/transport/remote_control/enroll.rs | 128 ++++- .../src/transport/remote_control/mod.rs | 221 ++++++++ .../src/transport/remote_control/protocol.rs | 28 + .../src/transport/remote_control/tests.rs | 49 ++ .../remote_control/tests/pairing_tests.rs | 494 ++++++++++++++++++ .../src/transport/remote_control/websocket.rs | 88 +++- codex-rs/app-server/README.md | 1 + codex-rs/app-server/src/message_processor.rs | 5 + .../remote_control_processor.rs | 24 + .../remote_control_processor_tests.rs | 48 ++ .../tests/common/test_app_server.rs | 11 + .../tests/suite/v2/remote_control.rs | 199 ++++++- 15 files changed, 1302 insertions(+), 48 deletions(-) create mode 100644 codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs create mode 100644 codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 56cf7038f2..b9c44fe9a6 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -2924,4 +2924,23 @@ permissionProfile?: string | null}; let _cleanup = fs::remove_dir_all(&output_dir); Ok(()) } + + #[test] + fn generate_json_includes_remote_control_pairing_start_with_experimental_api() -> Result<()> { + let output_dir = std::env::temp_dir().join(format!("codex_schema_{}", Uuid::now_v7())); + fs::create_dir(&output_dir)?; + generate_json_with_experimental(&output_dir, /*experimental_api*/ true)?; + + let client_request_json = fs::read_to_string(output_dir.join("ClientRequest.json"))?; + assert!(client_request_json.contains("remoteControl/pairing/start")); + for schema in [ + "RemoteControlPairingStartParams.json", + "RemoteControlPairingStartResponse.json", + ] { + assert!(output_dir.join("v2").join(schema).exists()); + } + + let _cleanup = fs::remove_dir_all(&output_dir); + Ok(()) + } } diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index d532952f6c..961a4a227c 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -843,6 +843,12 @@ client_request_definitions! { serialization: global_shared_read("remote-control"), response: v2::RemoteControlStatusReadResponse, }, + #[experimental("remoteControl/pairing/start")] + RemoteControlPairingStart => "remoteControl/pairing/start" { + params: v2::RemoteControlPairingStartParams, + serialization: global("remote-control-pairing"), + response: v2::RemoteControlPairingStartResponse, + }, #[experimental("collaborationMode/list")] /// Lists collaboration mode presets. CollaborationModeList => "collaborationMode/list" { @@ -1977,6 +1983,17 @@ mod tests { }, }; assert_eq!(mcp_resource_read.serialization_scope(), None); + + let remote_control_pairing_start = ClientRequest::RemoteControlPairingStart { + request_id: request_id(), + params: v2::RemoteControlPairingStartParams::default(), + }; + assert_eq!( + remote_control_pairing_start.serialization_scope(), + Some(ClientRequestSerializationScope::Global( + "remote-control-pairing" + )) + ); } #[test] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs index c8ad617e7d..e5c39a7773 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs @@ -44,6 +44,24 @@ pub struct RemoteControlStatusReadResponse { pub environment_id: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlPairingStartParams { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub manual_code: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlPairingStartResponse { + pub pairing_code: String, + pub manual_pairing_code: Option, + pub environment_id: String, + pub expires_at: i64, +} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase", export_to = "v2/")] diff --git a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs index 6037caa651..ac67726371 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs @@ -1,9 +1,13 @@ +use super::pairing_unavailable_error; use super::protocol::EnrollRemoteServerRequest; use super::protocol::EnrollRemoteServerResponse; use super::protocol::RefreshRemoteServerRequest; use super::protocol::RemoteControlTarget; +use super::protocol::StartRemoteControlPairingRequest; +use super::protocol::StartRemoteControlPairingResponse; use axum::http::HeaderMap; use codex_api::SharedAuthProvider; +use codex_app_server_protocol::RemoteControlPairingStartResponse; use codex_login::default_client::build_reqwest_client; use codex_state::RemoteControlEnrollmentRecord; use codex_state::StateRuntime; @@ -17,6 +21,7 @@ use tracing::info; use tracing::warn; const REMOTE_CONTROL_ENROLL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +const REMOTE_CONTROL_PAIRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); const REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES: usize = 4096; const REMOTE_CONTROL_SERVER_TOKEN_REFRESH_SKEW_SECS: i64 = 30; @@ -28,6 +33,7 @@ pub(super) const REMOTE_CONTROL_INSTALLATION_ID_HEADER: &str = "x-codex-installa #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct RemoteControlEnrollment { + pub(super) remote_control_target: RemoteControlTarget, pub(super) account_id: String, pub(super) environment_id: String, pub(super) server_id: String, @@ -37,6 +43,99 @@ pub(super) struct RemoteControlEnrollment { } impl RemoteControlEnrollment { + pub(super) async fn start_pairing( + &self, + request: StartRemoteControlPairingRequest, + ) -> io::Result { + if self.should_refresh_server_token() { + return Err(pairing_unavailable_error()); + } + let remote_control_token = self + .remote_control_token + .as_deref() + .ok_or_else(pairing_unavailable_error)?; + + let response = build_reqwest_client() + .post(&self.remote_control_target.pair_url) + .timeout(REMOTE_CONTROL_PAIRING_TIMEOUT) + .bearer_auth(remote_control_token) + .json(&request) + .send() + .await + .map_err(|err| { + io::Error::other(format!( + "failed to start remote control pairing at `{}`: {err}", + self.remote_control_target.pair_url + )) + })?; + let headers = response.headers().clone(); + let status = response.status(); + let body = response.bytes().await.map_err(|err| { + io::Error::other(format!( + "failed to read remote control pairing response from `{}`: {err}", + self.remote_control_target.pair_url + )) + })?; + let body_preview = preview_remote_control_response_body(&body); + if !status.is_success() { + let error_kind = match status.as_u16() { + 401 | 403 => ErrorKind::PermissionDenied, + 404 => ErrorKind::NotFound, + _ => ErrorKind::Other, + }; + return Err(io::Error::new( + error_kind, + format!( + "remote control pairing failed at `{}`: HTTP {status}, {}, body: {body_preview}", + self.remote_control_target.pair_url, + format_headers(&headers) + ), + )); + } + + let pairing = serde_json::from_slice::(&body).map_err( + |err| { + io::Error::other(format!( + "failed to parse remote control pairing response from `{}`: HTTP {status}, {}, body: {body_preview}, decode error: {err}", + self.remote_control_target.pair_url, + format_headers(&headers) + )) + }, + )?; + let StartRemoteControlPairingResponse { + pairing_code, + manual_pairing_code, + server_id, + environment_id, + expires_at, + } = pairing; + if server_id != self.server_id || environment_id != self.environment_id { + return Err(io::Error::other(format!( + "remote control pairing returned mismatched enrollment: expected server_id={}, environment_id={}; got server_id={}, environment_id={}", + self.server_id, self.environment_id, server_id, environment_id + ))); + } + let expires_at = OffsetDateTime::parse(&expires_at, &Rfc3339) + .map_err(|err| { + io::Error::new( + ErrorKind::InvalidData, + format!( + "failed to parse remote control pairing response from `{}`: HTTP {status}, {}, body: {body_preview}, expires_at parse error: {err}", + self.remote_control_target.pair_url, + format_headers(&headers) + ), + ) + })? + .unix_timestamp(); + + Ok(RemoteControlPairingStartResponse { + pairing_code, + manual_pairing_code, + environment_id, + expires_at, + }) + } + pub(super) fn should_refresh_server_token(&self) -> bool { self.remote_control_token.is_none() || self.expires_at.is_none_or(|expires_at| { @@ -101,6 +200,7 @@ pub(super) async fn load_persisted_remote_control_enrollment( enrollment.environment_id ); Ok(Some(RemoteControlEnrollment { + remote_control_target: remote_control_target.clone(), account_id: enrollment.account_id, environment_id: enrollment.environment_id, server_id: enrollment.server_id, @@ -211,8 +311,14 @@ fn redact_remote_control_response_body(body: &str) -> String { let Some(body_object) = body_json.as_object_mut() else { return body.to_string(); }; - if let Some(remote_control_token) = body_object.get_mut("remote_control_token") { - *remote_control_token = serde_json::Value::String("".to_string()); + for sensitive_field in [ + "remote_control_token", + "pairing_code", + "manual_pairing_code", + ] { + if let Some(value) = body_object.get_mut(sensitive_field) { + *value = serde_json::Value::String("".to_string()); + } } body_json.to_string() } @@ -254,6 +360,7 @@ pub(super) async fn enroll_remote_control_server( ) .await?; let mut enrollment = RemoteControlEnrollment { + remote_control_target: remote_control_target.clone(), account_id: auth.account_id.clone(), environment_id: enrollment_response.environment_id, server_id: enrollment_response.server_id, @@ -271,18 +378,17 @@ pub(super) async fn enroll_remote_control_server( } pub(super) async fn refresh_remote_control_server( - remote_control_target: &RemoteControlTarget, auth: &RemoteControlConnectionAuth, installation_id: &str, enrollment: &mut RemoteControlEnrollment, ) -> io::Result<()> { - let refresh_url = &remote_control_target.refresh_url; + let refresh_url = enrollment.remote_control_target.refresh_url.clone(); let request = RefreshRemoteServerRequest { server_id: enrollment.server_id.clone(), installation_id: installation_id.to_string(), }; let refreshed = send_remote_control_server_request::<_, EnrollRemoteServerResponse>( - refresh_url, + &refresh_url, auth, installation_id, &request, @@ -304,7 +410,7 @@ pub(super) async fn refresh_remote_control_server( update_remote_control_server_token( enrollment, - refresh_url, + &refresh_url, refreshed.remote_control_token, refreshed.expires_at, ) @@ -412,6 +518,8 @@ mod tests { #[test] fn remote_control_enrollment_refreshes_server_token_before_expiry() { let expires_soon = RemoteControlEnrollment { + remote_control_target: normalize_remote_control_url("http://localhost/backend-api/") + .expect("target should normalize"), account_id: "account-a".to_string(), environment_id: "env_first".to_string(), server_id: "srv_e_first".to_string(), @@ -433,12 +541,14 @@ mod tests { fn preview_remote_control_response_body_redacts_server_token() { assert_eq!( serde_json::from_str::(&preview_remote_control_response_body( - br#"{"server_id":"srv_e_test","remote_control_token":"secret"}"# + br#"{"server_id":"srv_e_test","remote_control_token":"secret","pairing_code":"pairing-code","manual_pairing_code":"ABCD-EFGH"}"# )) .expect("redacted response preview should stay valid json"), json!({ "server_id": "srv_e_test", "remote_control_token": "", + "pairing_code": "", + "manual_pairing_code": "", }) ); } @@ -453,6 +563,7 @@ mod tests { normalize_remote_control_url("https://api.chatgpt-staging.com/other/control") .expect("second target should parse"); let first_enrollment = RemoteControlEnrollment { + remote_control_target: first_target.clone(), account_id: "account-a".to_string(), environment_id: "env_first".to_string(), server_id: "srv_e_first".to_string(), @@ -461,6 +572,7 @@ mod tests { expires_at: None, }; let second_enrollment = RemoteControlEnrollment { + remote_control_target: second_target.clone(), account_id: "account-a".to_string(), environment_id: "env_second".to_string(), server_id: "srv_e_second".to_string(), @@ -533,6 +645,7 @@ mod tests { normalize_remote_control_url("https://api.chatgpt-staging.com/other/control") .expect("second target should parse"); let first_enrollment = RemoteControlEnrollment { + remote_control_target: first_target.clone(), account_id: "account-a".to_string(), environment_id: "env_first".to_string(), server_id: "srv_e_first".to_string(), @@ -541,6 +654,7 @@ mod tests { expires_at: None, }; let second_enrollment = RemoteControlEnrollment { + remote_control_target: second_target.clone(), account_id: "account-a".to_string(), environment_id: "env_second".to_string(), server_id: "srv_e_second".to_string(), diff --git a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs index 357a98a30d..a55667886f 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs @@ -4,6 +4,8 @@ mod protocol; mod segment; mod websocket; +use self::enroll::RemoteControlEnrollment; +use self::enroll::refresh_remote_control_server; use crate::transport::remote_control::websocket::RemoteControlChannels; use crate::transport::remote_control::websocket::RemoteControlStatusPublisher; use crate::transport::remote_control::websocket::RemoteControlWebsocket; @@ -16,6 +18,8 @@ use super::CHANNEL_CAPACITY; use super::TransportEvent; use super::next_connection_id; use codex_app_server_protocol::RemoteControlConnectionStatus; +use codex_app_server_protocol::RemoteControlPairingStartParams; +use codex_app_server_protocol::RemoteControlPairingStartResponse; use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_login::AuthManager; use codex_state::StateRuntime; @@ -26,6 +30,7 @@ use std::fmt; use std::io; use std::panic::AssertUnwindSafe; use std::sync::Arc; +use std::sync::Mutex as StdMutex; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::sync::watch; @@ -52,8 +57,12 @@ pub struct RemoteControlHandle { enabled_tx: Arc>, status_tx: Arc>, state_db_available: bool, + current_enrollment: CurrentRemoteControlEnrollment, + auth_manager: Arc, } +type CurrentRemoteControlEnrollment = Arc>>; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RemoteControlUnavailable; @@ -108,6 +117,7 @@ impl RemoteControlHandle { *state = false; changed }); + clear_current_enrollment(&self.current_enrollment); let status = self.status(); info!( @@ -129,6 +139,88 @@ impl RemoteControlHandle { self.status_tx.subscribe() } + pub async fn start_pairing( + &self, + params: RemoteControlPairingStartParams, + ) -> io::Result { + if !*self.enabled_tx.borrow() { + return Err(Self::pairing_disabled_error()); + } + let mut auth = websocket::load_remote_control_auth(&self.auth_manager) + .await + .map_err(|_| pairing_unavailable_error())?; + let mut enrollment = { + let current_enrollment = self + .current_enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + current_enrollment + .as_ref() + .filter(|enrollment| enrollment.account_id == auth.account_id) + .cloned() + } + .ok_or_else(pairing_unavailable_error)?; + let installation_id = self.status().installation_id; + if enrollment.should_refresh_server_token() { + refresh_pairing_enrollment( + &self.current_enrollment, + &self.auth_manager, + &mut auth, + &installation_id, + &mut enrollment, + ) + .await?; + } + let pairing_request = || protocol::StartRemoteControlPairingRequest { + manual_code: params.manual_code, + }; + let pairing_response = match enrollment.start_pairing(pairing_request()).await { + Err(err) if err.kind() == io::ErrorKind::PermissionDenied => { + clear_pairing_server_token(&self.current_enrollment, &mut enrollment)?; + refresh_pairing_enrollment( + &self.current_enrollment, + &self.auth_manager, + &mut auth, + &installation_id, + &mut enrollment, + ) + .await?; + enrollment.start_pairing(pairing_request()).await + } + pairing_response => pairing_response, + }; + if let Err(err) = &pairing_response { + match err.kind() { + io::ErrorKind::NotFound => { + clear_current_enrollment_if_matches(&self.current_enrollment, &enrollment); + return Err(pairing_unavailable_error()); + } + io::ErrorKind::PermissionDenied => { + clear_pairing_server_token(&self.current_enrollment, &mut enrollment)?; + return Err(pairing_unavailable_error()); + } + _ => {} + } + } + if !*self.enabled_tx.borrow() { + return Err(Self::pairing_disabled_error()); + } + let current_auth = websocket::load_remote_control_auth(&self.auth_manager) + .await + .map_err(|_| pairing_unavailable_error())?; + if current_auth.account_id != auth.account_id { + return Err(pairing_unavailable_error()); + } + pairing_response + } + + fn pairing_disabled_error() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + "remote control pairing requires remote control to be enabled", + ) + } + fn publish_status( &self, connection_status: RemoteControlConnectionStatus, @@ -160,6 +252,71 @@ impl RemoteControlHandle { } } +async fn refresh_pairing_enrollment( + current_enrollment: &CurrentRemoteControlEnrollment, + auth_manager: &Arc, + auth: &mut enroll::RemoteControlConnectionAuth, + installation_id: &str, + enrollment: &mut RemoteControlEnrollment, +) -> io::Result<()> { + if let Err(err) = refresh_remote_control_server(auth, installation_id, enrollment).await { + if err.kind() != io::ErrorKind::PermissionDenied { + return handle_pairing_refresh_error(current_enrollment, enrollment, err); + } + let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut auth_change_rx = auth_manager.auth_change_receiver(); + if !websocket::recover_remote_control_auth(&mut auth_recovery, &mut auth_change_rx).await { + return Err(err); + } + *auth = websocket::load_remote_control_auth(auth_manager) + .await + .map_err(|_| pairing_unavailable_error())?; + if auth.account_id != enrollment.account_id { + return Err(pairing_unavailable_error()); + } + if let Err(err) = refresh_remote_control_server(auth, installation_id, enrollment).await { + return handle_pairing_refresh_error(current_enrollment, enrollment, err); + } + } + if replace_current_enrollment(current_enrollment, enrollment) { + Ok(()) + } else { + Err(pairing_unavailable_error()) + } +} + +fn handle_pairing_refresh_error( + current_enrollment: &CurrentRemoteControlEnrollment, + enrollment: &RemoteControlEnrollment, + err: io::Error, +) -> io::Result<()> { + if err.kind() == io::ErrorKind::NotFound { + clear_current_enrollment_if_matches(current_enrollment, enrollment); + Err(pairing_unavailable_error()) + } else { + Err(err) + } +} + +fn clear_pairing_server_token( + current_enrollment: &CurrentRemoteControlEnrollment, + enrollment: &mut RemoteControlEnrollment, +) -> io::Result<()> { + enrollment.clear_server_token(); + if replace_current_enrollment(current_enrollment, enrollment) { + Ok(()) + } else { + Err(pairing_unavailable_error()) + } +} + +fn pairing_unavailable_error() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + "remote control pairing is unavailable until enrollment completes", + ) +} + fn remote_control_status_with_connection_status( status: &RemoteControlStatusChangedNotification, connection_status: RemoteControlConnectionStatus, @@ -176,6 +333,64 @@ fn remote_control_status_with_connection_status( } } +fn publish_current_enrollment( + current_enrollment: &CurrentRemoteControlEnrollment, + enrollment: &RemoteControlEnrollment, +) { + *current_enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(enrollment.clone()); +} + +fn clear_current_enrollment(current_enrollment: &CurrentRemoteControlEnrollment) { + *current_enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; +} + +fn replace_current_enrollment( + current_enrollment: &CurrentRemoteControlEnrollment, + enrollment: &RemoteControlEnrollment, +) -> bool { + let mut current_enrollment = current_enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !current_enrollment + .as_ref() + .is_some_and(|current| same_remote_control_enrollment(current, enrollment)) + { + return false; + } + *current_enrollment = Some(enrollment.clone()); + true +} + +fn clear_current_enrollment_if_matches( + current_enrollment: &CurrentRemoteControlEnrollment, + enrollment: &RemoteControlEnrollment, +) { + let mut current_enrollment = current_enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if current_enrollment + .as_ref() + .is_some_and(|current| same_remote_control_enrollment(current, enrollment)) + { + *current_enrollment = None; + } +} + +fn same_remote_control_enrollment( + left: &RemoteControlEnrollment, + right: &RemoteControlEnrollment, +) -> bool { + // A refresh rotates only the bearer. Pairing remains current while the same persisted server + // record is still selected for the current account. + left.account_id == right.account_id + && left.server_id == right.server_id + && left.environment_id == right.environment_id +} + pub async fn start_remote_control( config: RemoteControlStartConfig, state_db: Option>, @@ -198,6 +413,9 @@ pub async fn start_remote_control( }; let (enabled_tx, enabled_rx) = watch::channel(initial_enabled); + let current_enrollment = Arc::new(StdMutex::new(None)); + let websocket_current_enrollment = current_enrollment.clone(); + let handle_auth_manager = auth_manager.clone(); let server_name = gethostname().to_string_lossy().trim().to_string(); let remote_control_url = config.remote_control_url; let installation_id = config.installation_id; @@ -245,6 +463,7 @@ pub async fn start_remote_control( RemoteControlChannels { transport_event_tx, status_publisher, + current_enrollment: websocket_current_enrollment, }, shutdown_token, enabled_rx, @@ -289,6 +508,8 @@ pub async fn start_remote_control( enabled_tx: Arc::new(enabled_tx), status_tx: Arc::new(status_tx), state_db_available, + current_enrollment, + auth_manager: handle_auth_manager, }, )) } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs b/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs index b10af42e86..8eeb28e266 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs @@ -12,6 +12,7 @@ pub(super) struct RemoteControlTarget { pub(super) websocket_url: String, pub(super) enroll_url: String, pub(super) refresh_url: String, + pub(super) pair_url: String, } #[derive(Debug, Serialize)] @@ -37,6 +38,20 @@ pub(super) struct RefreshRemoteServerRequest { pub(super) installation_id: String, } +#[derive(Debug, Serialize)] +pub(super) struct StartRemoteControlPairingRequest { + pub(super) manual_code: bool, +} + +#[derive(Debug, Deserialize)] +pub(super) struct StartRemoteControlPairingResponse { + pub(super) pairing_code: String, + pub(super) manual_pairing_code: Option, + pub(super) server_id: String, + pub(super) environment_id: String, + pub(super) expires_at: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct ClientId(pub String); @@ -189,6 +204,9 @@ pub(super) fn normalize_remote_control_url( let refresh_url = remote_control_url .join("wham/remote/control/server/refresh") .map_err(map_url_parse_error)?; + let pair_url = remote_control_url + .join("wham/remote/control/server/pair") + .map_err(map_url_parse_error)?; let mut websocket_url = remote_control_url .join("wham/remote/control/server") .map_err(map_url_parse_error)?; @@ -207,6 +225,7 @@ pub(super) fn normalize_remote_control_url( websocket_url: websocket_url.to_string(), enroll_url: enroll_url.to_string(), refresh_url: refresh_url.to_string(), + pair_url: pair_url.to_string(), }) } @@ -227,6 +246,8 @@ mod tests { .to_string(), refresh_url: "https://chatgpt.com/backend-api/wham/remote/control/server/refresh" .to_string(), + pair_url: "https://chatgpt.com/backend-api/wham/remote/control/server/pair" + .to_string(), } ); assert_eq!( @@ -242,6 +263,9 @@ mod tests { refresh_url: "https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/refresh" .to_string(), + pair_url: + "https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/pair" + .to_string(), } ); } @@ -258,6 +282,8 @@ mod tests { .to_string(), refresh_url: "http://localhost:8080/backend-api/wham/remote/control/server/refresh" .to_string(), + pair_url: "http://localhost:8080/backend-api/wham/remote/control/server/pair" + .to_string(), } ); assert_eq!( @@ -271,6 +297,8 @@ mod tests { refresh_url: "https://localhost:8443/backend-api/wham/remote/control/server/refresh" .to_string(), + pair_url: "https://localhost:8443/backend-api/wham/remote/control/server/pair" + .to_string(), } ); } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 85bcf09222..5d96f89d89 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -20,6 +20,7 @@ use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::RemoteControlConnectionStatus; +use codex_app_server_protocol::RemoteControlPairingStartParams; use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_app_server_protocol::ServerNotification; use codex_config::types::AuthCredentialsStoreMode; @@ -39,7 +40,9 @@ use pretty_assertions::assert_eq; use serde_json::json; use std::collections::BTreeMap; use std::sync::Arc; +use std::sync::Mutex as StdMutex; use tempfile::TempDir; +use time::OffsetDateTime; use tokio::io::AsyncBufReadExt; use tokio::io::AsyncReadExt; use tokio::io::AsyncWriteExt; @@ -57,7 +60,10 @@ use tokio_tungstenite::accept_hdr_async; use tokio_tungstenite::tungstenite; use tokio_util::sync::CancellationToken; +mod pairing_tests; + const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; +const TEST_REMOTE_CONTROL_URL: &str = "http://127.0.0.1:1/backend-api/wham/remote/control"; const TEST_REMOTE_CONTROL_SERVER_TOKEN: &str = "Remote Control Token"; const TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN: &str = "Refreshed Remote Control Token"; const TEST_REMOTE_CONTROL_SERVER_TOKEN_EXPIRES_AT: &str = "2999-01-01T00:00:00Z"; @@ -128,6 +134,40 @@ fn test_server_name() -> String { gethostname().to_string_lossy().trim().to_string() } +fn remote_control_handle_with_current_enrollment( + remote_control_url: &str, + auth_manager: Arc, +) -> RemoteControlHandle { + let (enabled_tx, _enabled_rx) = watch::channel(/*init*/ true); + let (status_tx, _status_rx) = watch::channel(RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Connecting, + server_name: test_server_name(), + installation_id: TEST_INSTALLATION_ID.to_string(), + environment_id: Some("env_test".to_string()), + }); + let remote_control_target = normalize_remote_control_url(remote_control_url) + .expect("remote control target should normalize"); + let current_enrollment = Arc::new(StdMutex::new(Some(RemoteControlEnrollment { + remote_control_target, + account_id: "account_id".to_string(), + environment_id: "env_test".to_string(), + server_id: "srv_e_test".to_string(), + server_name: test_server_name(), + remote_control_token: Some(TEST_REMOTE_CONTROL_SERVER_TOKEN.to_string()), + expires_at: Some( + OffsetDateTime::from_unix_timestamp(33_336_362_096) + .expect("future timestamp should parse"), + ), + }))); + RemoteControlHandle { + enabled_tx: Arc::new(enabled_tx), + status_tx: Arc::new(status_tx), + state_db_available: true, + current_enrollment, + auth_manager, + } +} + fn remote_control_server_token_response( server_id: &str, environment_id: &str, @@ -1313,6 +1353,7 @@ async fn remote_control_http_mode_refreshes_persisted_enrollment_before_connecti let remote_control_target = normalize_remote_control_url(&remote_control_url).expect("target should parse"); let persisted_enrollment = RemoteControlEnrollment { + remote_control_target: remote_control_target.clone(), account_id: "account_id".to_string(), environment_id: "env_persisted".to_string(), server_id: "srv_e_persisted".to_string(), @@ -1418,6 +1459,7 @@ async fn remote_control_stdio_mode_waits_for_client_name_before_connecting() { normalize_remote_control_url(&remote_control_url).expect("target should parse"); let app_server_client_name = "stdio-client"; let persisted_enrollment = RemoteControlEnrollment { + remote_control_target: remote_control_target.clone(), account_id: "account_id".to_string(), environment_id: "env_persisted".to_string(), server_id: "srv_e_persisted".to_string(), @@ -1505,7 +1547,10 @@ async fn remote_control_waits_for_account_id_before_enrolling() { ) .await; let expected_server_name = gethostname().to_string_lossy().trim().to_string(); + let expected_remote_control_target = normalize_remote_control_url(&remote_control_url) + .expect("remote control target should normalize"); let expected_enrollment = RemoteControlEnrollment { + remote_control_target: expected_remote_control_target, account_id: "account_id".to_string(), environment_id: "env_ready".to_string(), server_id: "srv_e_ready".to_string(), @@ -1583,6 +1628,7 @@ async fn remote_control_http_mode_reenrolls_when_refresh_reports_stale_enrollmen normalize_remote_control_url(&remote_control_url).expect("target should parse"); let expected_server_name = gethostname().to_string_lossy().trim().to_string(); let stale_enrollment = RemoteControlEnrollment { + remote_control_target: remote_control_target.clone(), account_id: "account_id".to_string(), environment_id: "env_stale".to_string(), server_id: "srv_e_stale".to_string(), @@ -1591,6 +1637,7 @@ async fn remote_control_http_mode_reenrolls_when_refresh_reports_stale_enrollmen expires_at: None, }; let refreshed_enrollment = RemoteControlEnrollment { + remote_control_target: remote_control_target.clone(), account_id: "account_id".to_string(), environment_id: "env_refreshed".to_string(), server_id: "srv_e_refreshed".to_string(), @@ -1700,6 +1747,7 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() normalize_remote_control_url(&remote_control_url).expect("target should parse"); let expected_server_name = gethostname().to_string_lossy().trim().to_string(); let stale_enrollment = RemoteControlEnrollment { + remote_control_target: remote_control_target.clone(), account_id: "account_id".to_string(), environment_id: "env_stale".to_string(), server_id: "srv_e_stale".to_string(), @@ -1708,6 +1756,7 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() expires_at: None, }; let refreshed_enrollment = RemoteControlEnrollment { + remote_control_target: remote_control_target.clone(), account_id: "account_id".to_string(), environment_id: "env_refreshed".to_string(), server_id: "srv_e_refreshed".to_string(), diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs new file mode 100644 index 0000000000..176aeb4ed5 --- /dev/null +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs @@ -0,0 +1,494 @@ +use super::super::protocol::StartRemoteControlPairingRequest; +use super::*; +use pretty_assertions::assert_eq; + +fn remote_control_enrollment( + remote_control_url: &str, + remote_control_token: &str, +) -> RemoteControlEnrollment { + RemoteControlEnrollment { + remote_control_target: normalize_remote_control_url(remote_control_url) + .expect("target should normalize"), + account_id: "account-id".to_string(), + environment_id: "environment-id".to_string(), + server_id: "server-id".to_string(), + server_name: "server-name".to_string(), + remote_control_token: Some(remote_control_token.to_string()), + expires_at: Some( + OffsetDateTime::from_unix_timestamp(33_336_362_096) + .expect("future timestamp should parse"), + ), + } +} + +async fn pairing_error(status: &'static str, body: &'static str) -> (String, String) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let expected_pair_url = normalize_remote_control_url(&remote_control_url) + .expect("target should normalize") + .pair_url; + let server_task = tokio::spawn(async move { + let pairing_request = accept_http_request(&listener).await; + respond_with_status_and_headers( + pairing_request.stream, + status, + &[("x-request-id", "request-123"), ("cf-ray", "ray-123")], + body, + ) + .await; + }); + + let err = remote_control_enrollment(&remote_control_url, "remote-control-token") + .start_pairing(StartRemoteControlPairingRequest { manual_code: false }) + .await + .expect_err("pairing should fail"); + server_task.await.expect("server task should finish"); + (err.to_string(), expected_pair_url) +} + +async fn pairing_response_error(body: serde_json::Value) -> String { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let pairing_request = accept_http_request(&listener).await; + respond_with_json(pairing_request.stream, body).await; + }); + + let err = remote_control_enrollment(&remote_control_url, "remote-control-token") + .start_pairing(StartRemoteControlPairingRequest { manual_code: false }) + .await + .expect_err("pairing should fail"); + server_task.await.expect("server task should finish"); + err.to_string() +} + +#[tokio::test] +async fn remote_control_handle_starts_pairing_before_websocket_connects() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let refresh_request = accept_http_request(&listener).await; + assert_eq!( + refresh_request.request_line, + "POST /backend-api/wham/remote/control/server/refresh HTTP/1.1" + ); + assert_eq!( + serde_json::from_str::(&refresh_request.body) + .expect("refresh request body should deserialize"), + json!({ + "server_id": "srv_e_test", + "installation_id": TEST_INSTALLATION_ID, + }) + ); + respond_with_json( + refresh_request.stream, + remote_control_server_token_response( + "srv_e_test", + "env_test", + TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN, + ), + ) + .await; + + let pairing_request = accept_http_request(&listener).await; + assert_eq!( + pairing_request.request_line, + "POST /backend-api/wham/remote/control/server/pair HTTP/1.1" + ); + assert_eq!( + pairing_request.headers.get("authorization"), + Some(&format!( + "Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}" + )) + ); + assert_eq!( + serde_json::from_str::(&pairing_request.body) + .expect("pairing request body should deserialize"), + json!({ "manual_code": true }) + ); + respond_with_json( + pairing_request.stream, + json!({ + "pairing_code": "pairing-code", + "manual_pairing_code": "ABCD-EFGH", + "server_id": "srv_e_test", + "environment_id": "env_test", + "expires_at": "3026-05-22T12:34:56Z", + }), + ) + .await; + }); + let remote_handle = remote_control_handle_with_current_enrollment( + &remote_control_url, + remote_control_auth_manager(), + ); + remote_handle + .current_enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_mut() + .expect("current enrollment should exist") + .expires_at = Some(OffsetDateTime::now_utc() + time::Duration::seconds(29)); + + let response = remote_handle + .start_pairing(RemoteControlPairingStartParams { manual_code: true }) + .await + .expect("pairing should use the current server before websocket connect"); + server_task.await.expect("server task should finish"); + + assert_eq!( + response, + RemoteControlPairingStartResponse { + pairing_code: "pairing-code".to_string(), + manual_pairing_code: Some("ABCD-EFGH".to_string()), + environment_id: "env_test".to_string(), + expires_at: 33_336_362_096, + } + ); +} + +#[tokio::test] +async fn remote_control_handle_refreshes_after_pairing_auth_failure() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let stale_pairing_request = accept_http_request(&listener).await; + assert_eq!( + stale_pairing_request.request_line, + "POST /backend-api/wham/remote/control/server/pair HTTP/1.1" + ); + assert_eq!( + stale_pairing_request.headers.get("authorization"), + Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}")) + ); + respond_with_status(stale_pairing_request.stream, "401 Unauthorized", "").await; + + let refresh_request = accept_http_request(&listener).await; + assert_eq!( + refresh_request.request_line, + "POST /backend-api/wham/remote/control/server/refresh HTTP/1.1" + ); + assert_eq!( + refresh_request.headers.get("authorization"), + Some(&"Bearer Access Token".to_string()) + ); + respond_with_json( + refresh_request.stream, + remote_control_server_token_response( + "srv_e_test", + "env_test", + TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN, + ), + ) + .await; + + let refreshed_pairing_request = accept_http_request(&listener).await; + assert_eq!( + refreshed_pairing_request.request_line, + "POST /backend-api/wham/remote/control/server/pair HTTP/1.1" + ); + assert_eq!( + refreshed_pairing_request.headers.get("authorization"), + Some(&format!( + "Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}" + )) + ); + respond_with_json( + refreshed_pairing_request.stream, + json!({ + "pairing_code": "pairing-code", + "manual_pairing_code": "ABCD-EFGH", + "server_id": "srv_e_test", + "environment_id": "env_test", + "expires_at": "3026-05-22T12:34:56Z", + }), + ) + .await; + }); + let remote_handle = remote_control_handle_with_current_enrollment( + &remote_control_url, + remote_control_auth_manager(), + ); + + let response = remote_handle + .start_pairing(RemoteControlPairingStartParams::default()) + .await + .expect("pairing should refresh after server token auth failure"); + server_task.await.expect("server task should finish"); + + assert_eq!( + response, + RemoteControlPairingStartResponse { + pairing_code: "pairing-code".to_string(), + manual_pairing_code: Some("ABCD-EFGH".to_string()), + environment_id: "env_test".to_string(), + expires_at: 33_336_362_096, + } + ); +} + +#[tokio::test] +async fn remote_control_handle_recovers_auth_before_refreshing_pairing() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let stale_refresh_request = accept_http_request(&listener).await; + assert_eq!( + stale_refresh_request.request_line, + "POST /backend-api/wham/remote/control/server/refresh HTTP/1.1" + ); + assert_eq!( + stale_refresh_request.headers.get("authorization"), + Some(&"Bearer stale-token".to_string()) + ); + respond_with_status(stale_refresh_request.stream, "401 Unauthorized", "").await; + + let recovered_refresh_request = accept_http_request(&listener).await; + assert_eq!( + recovered_refresh_request.request_line, + "POST /backend-api/wham/remote/control/server/refresh HTTP/1.1" + ); + assert_eq!( + recovered_refresh_request.headers.get("authorization"), + Some(&"Bearer fresh-token".to_string()) + ); + respond_with_json( + recovered_refresh_request.stream, + remote_control_server_token_response( + "srv_e_test", + "env_test", + TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN, + ), + ) + .await; + + let pairing_request = accept_http_request(&listener).await; + assert_eq!( + pairing_request.request_line, + "POST /backend-api/wham/remote/control/server/pair HTTP/1.1" + ); + assert_eq!( + pairing_request.headers.get("authorization"), + Some(&format!( + "Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}" + )) + ); + respond_with_json( + pairing_request.stream, + json!({ + "pairing_code": "pairing-code", + "manual_pairing_code": "ABCD-EFGH", + "server_id": "srv_e_test", + "environment_id": "env_test", + "expires_at": "3026-05-22T12:34:56Z", + }), + ) + .await; + }); + let codex_home = TempDir::new().expect("temp dir should create"); + let mut stale_auth = remote_control_auth_dot_json(Some("account_id")); + stale_auth + .tokens + .as_mut() + .expect("stale auth should include tokens") + .access_token = "stale-token".to_string(); + save_auth( + codex_home.path(), + &stale_auth, + AuthCredentialsStoreMode::File, + ) + .expect("stale auth should save"); + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await; + let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); + fresh_auth + .tokens + .as_mut() + .expect("fresh auth should include tokens") + .access_token = "fresh-token".to_string(); + save_auth( + codex_home.path(), + &fresh_auth, + AuthCredentialsStoreMode::File, + ) + .expect("fresh auth should save"); + let remote_handle = + remote_control_handle_with_current_enrollment(&remote_control_url, auth_manager); + remote_handle + .current_enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_mut() + .expect("current enrollment should exist") + .expires_at = Some(OffsetDateTime::now_utc() + time::Duration::seconds(29)); + + let response = remote_handle + .start_pairing(RemoteControlPairingStartParams::default()) + .await + .expect("pairing should refresh after auth recovery"); + server_task.await.expect("server task should finish"); + + assert_eq!( + response, + RemoteControlPairingStartResponse { + pairing_code: "pairing-code".to_string(), + manual_pairing_code: Some("ABCD-EFGH".to_string()), + environment_id: "env_test".to_string(), + expires_at: 33_336_362_096, + } + ); +} + +#[tokio::test] +async fn start_remote_control_pairing_preserves_backend_error_context() { + let (err, expected_pair_url) = + pairing_error("503 Service Unavailable", "pairing unavailable").await; + + assert_eq!( + err, + format!( + "remote control pairing failed at `{expected_pair_url}`: HTTP 503 Service Unavailable, request-id: request-123, cf-ray: ray-123, body: pairing unavailable" + ) + ); +} + +#[tokio::test] +async fn start_remote_control_pairing_preserves_decode_error_context() { + let (err, expected_pair_url) = pairing_error("200 OK", "{").await; + assert!(err.contains(&format!( + "failed to parse remote control pairing response from `{expected_pair_url}`: HTTP 200 OK" + ))); + assert!(err.contains("request-id: request-123")); + assert!(err.contains("cf-ray: ray-123")); + assert!(err.contains("body: {")); + assert!(err.contains("decode error:")); +} + +#[tokio::test] +async fn start_remote_control_pairing_rejects_mismatched_backend_enrollment() { + assert_eq!( + pairing_response_error(json!({ + "pairing_code": "pairing-code", + "manual_pairing_code": "ABCD-EFGH", + "server_id": "other-server-id", + "environment_id": "other-environment-id", + "expires_at": "3026-05-22T12:34:56Z", + })) + .await, + "remote control pairing returned mismatched enrollment: expected server_id=server-id, environment_id=environment-id; got server_id=other-server-id, environment_id=other-environment-id" + ); +} + +#[tokio::test] +async fn start_remote_control_pairing_preserves_expiry_parse_error_context() { + let err = pairing_response_error(json!({ + "pairing_code": "pairing-code", + "manual_pairing_code": "ABCD-EFGH", + "server_id": "server-id", + "environment_id": "environment-id", + "expires_at": "not-a-timestamp", + })) + .await; + + assert!(err.contains("failed to parse remote control pairing response")); + assert!(err.contains("HTTP 200 OK")); + assert!(err.contains("request-id: ")); + assert!(err.contains("cf-ray: ")); + assert!(err.contains("\"expires_at\":\"not-a-timestamp\"")); + assert!(err.contains("expires_at parse error:")); +} + +#[tokio::test] +async fn remote_control_handle_disable_clears_current_enrollment() { + let remote_handle = remote_control_handle_with_current_enrollment( + TEST_REMOTE_CONTROL_URL, + remote_control_auth_manager(), + ); + + remote_handle.disable(); + remote_handle.enable().expect("enable should succeed"); + assert_eq!( + remote_handle + .start_pairing(RemoteControlPairingStartParams::default()) + .await + .expect_err("re-enabled remote control should wait for a current server") + .to_string(), + "remote control pairing is unavailable until enrollment completes" + ); +} + +#[tokio::test] +async fn remote_control_handle_discards_pairing_response_after_auth_change() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + save_auth( + codex_home.path(), + &remote_control_auth_dot_json(Some("account_id")), + AuthCredentialsStoreMode::File, + ) + .expect("initial auth should save"); + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await; + let remote_handle = + remote_control_handle_with_current_enrollment(&remote_control_url, auth_manager.clone()); + let pairing_task = tokio::spawn({ + let remote_handle = remote_handle.clone(); + async move { + remote_handle + .start_pairing(RemoteControlPairingStartParams::default()) + .await + } + }); + + let pairing_request = accept_http_request(&listener).await; + save_auth( + codex_home.path(), + &remote_control_auth_dot_json(Some("next_account_id")), + AuthCredentialsStoreMode::File, + ) + .expect("next auth should save"); + auth_manager.reload().await; + respond_with_json( + pairing_request.stream, + json!({ + "pairing_code": "stale-pairing-code", + "manual_pairing_code": "ABCD-EFGH", + "server_id": "srv_e_test", + "environment_id": "env_test", + "expires_at": "3026-05-22T12:34:56Z", + }), + ) + .await; + + assert_eq!( + pairing_task + .await + .expect("pairing task should join") + .expect_err("stale pairing response should be discarded") + .to_string(), + "remote control pairing is unavailable until enrollment completes" + ); +} diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 21710b5e26..5ec4f3d04e 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -1,3 +1,17 @@ +use super::CurrentRemoteControlEnrollment; +use super::clear_current_enrollment; +use super::protocol::ClientEnvelope; +use super::protocol::ClientEvent; +use super::protocol::ClientId; +use super::protocol::RemoteControlTarget; +use super::protocol::ServerEnvelope; +use super::protocol::StreamId; +use super::publish_current_enrollment; +use super::remote_control_status_with_connection_status; +use super::segment::ClientSegmentObservation; +use super::segment::ClientSegmentReassembler; +use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES; +use super::segment::split_server_envelope_for_transport; use crate::transport::TransportEvent; use crate::transport::remote_control::client_tracker::ClientTracker; use crate::transport::remote_control::client_tracker::REMOTE_CONTROL_IDLE_SWEEP_INTERVAL; @@ -9,18 +23,6 @@ use crate::transport::remote_control::enroll::load_persisted_remote_control_enro use crate::transport::remote_control::enroll::preview_remote_control_response_body; use crate::transport::remote_control::enroll::refresh_remote_control_server; use crate::transport::remote_control::enroll::update_persisted_remote_control_enrollment; - -use super::protocol::ClientEnvelope; -use super::protocol::ClientEvent; -use super::protocol::ClientId; -use super::protocol::RemoteControlTarget; -use super::protocol::ServerEnvelope; -use super::protocol::StreamId; -use super::remote_control_status_with_connection_status; -use super::segment::ClientSegmentObservation; -use super::segment::ClientSegmentReassembler; -use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES; -use super::segment::split_server_envelope_for_transport; use axum::http::HeaderValue; use base64::Engine; use codex_app_server_protocol::RemoteControlConnectionStatus; @@ -251,6 +253,7 @@ pub(crate) struct RemoteControlWebsocket { enrollment: Option, auth_recovery: UnauthorizedRecovery, auth_change_rx: watch::Receiver, + current_enrollment: CurrentRemoteControlEnrollment, client_tracker: Arc>, state: Arc>, server_event_rx: Arc>>, @@ -288,6 +291,7 @@ enum ConnectionEndReason { pub(super) struct RemoteControlChannels { pub(super) transport_event_tx: mpsc::Sender, pub(super) status_publisher: RemoteControlStatusPublisher, + pub(super) current_enrollment: CurrentRemoteControlEnrollment, } #[derive(Clone)] @@ -404,6 +408,7 @@ impl RemoteControlWebsocket { enrollment: None, auth_recovery, auth_change_rx, + current_enrollment: channels.current_enrollment, client_tracker: Arc::new(Mutex::new(client_tracker)), state: Arc::new(Mutex::new(WebsocketState { outbound_buffer, @@ -611,6 +616,7 @@ impl RemoteControlWebsocket { &mut self.enrollment, connect_options, &self.status_publisher, + &self.current_enrollment, ) => connect_result, }; @@ -1229,6 +1235,7 @@ pub(super) async fn connect_remote_control_websocket( enrollment: &mut Option, connect_options: RemoteControlConnectOptions<'_>, status_publisher: &RemoteControlStatusPublisher, + current_enrollment: &CurrentRemoteControlEnrollment, ) -> io::Result<( WebSocketStream>, tungstenite::http::Response<()>, @@ -1237,6 +1244,7 @@ pub(super) async fn connect_remote_control_websocket( let Some(state_db) = state_db else { *enrollment = None; + clear_current_enrollment(current_enrollment); return Err(io::Error::new( ErrorKind::NotFound, "remote control requires sqlite state db", @@ -1249,6 +1257,7 @@ pub(super) async fn connect_remote_control_websocket( if err.kind() == ErrorKind::PermissionDenied { *enrollment = None; status_publisher.publish_environment_id(/*environment_id*/ None); + clear_current_enrollment(current_enrollment); } return Err(err); } @@ -1265,6 +1274,10 @@ pub(super) async fn connect_remote_control_websocket( ); *enrollment = None; status_publisher.publish_environment_id(/*environment_id*/ None); + clear_current_enrollment(current_enrollment); + } + if let Some(enrollment) = enrollment.as_mut() { + enrollment.remote_control_target = remote_control_target.clone(); } if let Some(enrollment) = enrollment.as_ref() { @@ -1321,13 +1334,8 @@ pub(super) async fn connect_remote_control_websocket( let enrollment_ref = enrollment.as_mut().ok_or_else(|| { io::Error::other("missing remote control enrollment before server refresh") })?; - match refresh_remote_control_server( - remote_control_target, - &auth, - connect_options.installation_id, - enrollment_ref, - ) - .await + match refresh_remote_control_server(&auth, connect_options.installation_id, enrollment_ref) + .await { Ok(()) => {} Err(err) if err.kind() == ErrorKind::NotFound => { @@ -1342,6 +1350,7 @@ pub(super) async fn connect_remote_control_websocket( connect_options.app_server_client_name, enrollment, status_publisher, + current_enrollment, ) .await; enroll_remote_control_server_if_missing( @@ -1374,6 +1383,7 @@ pub(super) async fn connect_remote_control_websocket( let enrollment_ref = enrollment.as_ref().ok_or_else(|| { io::Error::other("missing remote control enrollment after enrollment step") })?; + publish_current_enrollment(current_enrollment, enrollment_ref); let request = build_remote_control_websocket_request( &remote_control_target.websocket_url, enrollment_ref, @@ -1415,6 +1425,7 @@ pub(super) async fn connect_remote_control_websocket( connect_options.app_server_client_name, enrollment, status_publisher, + current_enrollment, ) .await; } @@ -1429,6 +1440,7 @@ pub(super) async fn connect_remote_control_websocket( ) })? .clear_server_token(); + clear_current_enrollment(current_enrollment); return Err(io::Error::other(format!( "remote control websocket auth failed with HTTP {}; refreshing server token before reconnect", response.status() @@ -1453,6 +1465,7 @@ async fn clear_remote_control_enrollment( app_server_client_name: Option<&str>, enrollment: &mut Option, status_publisher: &RemoteControlStatusPublisher, + current_enrollment: &CurrentRemoteControlEnrollment, ) { if let Err(clear_err) = update_persisted_remote_control_enrollment( Some(state_db), @@ -1467,6 +1480,7 @@ async fn clear_remote_control_enrollment( } *enrollment = None; status_publisher.publish_environment_id(/*environment_id*/ None); + clear_current_enrollment(current_enrollment); } async fn enroll_remote_control_server_if_missing( @@ -1534,7 +1548,7 @@ async fn enroll_remote_control_server_if_missing( Ok(()) } -async fn recover_remote_control_auth( +pub(super) async fn recover_remote_control_auth( auth_recovery: &mut UnauthorizedRecovery, auth_change_rx: &mut watch::Receiver, ) -> bool { @@ -1647,6 +1661,8 @@ mod tests { fn remote_control_enrollment(remote_control_token: Option<&str>) -> RemoteControlEnrollment { RemoteControlEnrollment { + remote_control_target: normalize_remote_control_url("http://localhost/backend-api/") + .expect("target should normalize"), account_id: "account_id".to_string(), environment_id: "env_test".to_string(), server_id: "srv_e_test".to_string(), @@ -1657,6 +1673,10 @@ mod tests { } } + fn test_current_enrollment() -> CurrentRemoteControlEnrollment { + Arc::new(std::sync::Mutex::new(None)) + } + #[test] fn next_reconnect_delay_resets_after_cap() { let mut reconnect_attempt = 9; @@ -1810,6 +1830,7 @@ mod tests { let mut enrollment = Some(remote_control_enrollment(Some( TEST_REMOTE_CONTROL_SERVER_TOKEN, ))); + let current_enrollment = test_current_enrollment(); let (status_publisher, status_rx) = remote_control_status_channel(); let err = match connect_remote_control_websocket( @@ -1828,6 +1849,7 @@ mod tests { app_server_client_name: None, }, &status_publisher, + ¤t_enrollment, ) .await { @@ -1837,6 +1859,12 @@ mod tests { server_task.await.expect("server task should succeed"); assert_eq!(err.to_string(), expected_error); + assert!( + current_enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_some() + ); assert_eq!( status_rx.borrow().clone(), RemoteControlStatusChangedNotification { @@ -1864,6 +1892,7 @@ mod tests { let mut enrollment = Some(remote_control_enrollment(Some( TEST_REMOTE_CONTROL_SERVER_TOKEN, ))); + let current_enrollment = test_current_enrollment(); let (status_publisher, status_rx) = remote_control_status_channel(); let server_task = tokio::spawn(async move { @@ -1891,6 +1920,7 @@ mod tests { app_server_client_name: None, }, &status_publisher, + ¤t_enrollment, ) .await .expect_err("unauthorized response should fail the websocket connect"); @@ -1909,11 +1939,14 @@ mod tests { err.to_string(), "remote control websocket auth failed with HTTP 401 Unauthorized; refreshing server token before reconnect" ); - assert_eq!( - enrollment, - Some(remote_control_enrollment( - /*remote_control_token*/ None - )) + let mut expected_enrollment = remote_control_enrollment(/*remote_control_token*/ None); + expected_enrollment.remote_control_target = remote_control_target; + assert_eq!(enrollment, Some(expected_enrollment)); + assert!( + current_enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_none() ); } @@ -1976,6 +2009,7 @@ mod tests { app_server_client_name: None, }, &status_publisher, + &test_current_enrollment(), ) .await .expect_err("unauthorized enrollment should fail the websocket connect"); @@ -2070,6 +2104,7 @@ mod tests { app_server_client_name: None, }, &status_publisher, + &test_current_enrollment(), ) .await .expect_err("unauthorized refresh should fail the websocket connect"); @@ -2135,6 +2170,7 @@ mod tests { app_server_client_name: None, }, &status_publisher, + &test_current_enrollment(), ) .await .expect_err("missing sqlite state db should fail remote control"); @@ -2185,6 +2221,7 @@ mod tests { app_server_client_name: None, }, &status_publisher, + &test_current_enrollment(), ) .await .expect_err("missing auth should fail remote control"); @@ -2236,6 +2273,7 @@ mod tests { RemoteControlChannels { transport_event_tx, status_publisher, + current_enrollment: test_current_enrollment(), }, shutdown_token, enabled_rx, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 29acc51983..eb6e4afd86 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -210,6 +210,7 @@ Example with notification opt-out: - `remoteControl/enable` — experimental; enable remote control for the current app-server process and return the current remote-control status snapshot. The caller is responsible for persisting the desired setting outside app-server. - `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. This does not revoke already enrolled controller devices. - `remoteControl/status/read` — experimental; read the current remote-control status snapshot. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. +- `remoteControl/pairing/start` — experimental; start a short-lived remote-control pairing artifact for the current app-server process. Pass `manualCode: true` to also request a manual pairing code. Returns `pairingCode`, `manualPairingCode`, `environmentId`, and Unix-seconds `expiresAt`; app-server intentionally does not expose the backend `serverId`. - `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot. - `skills/config/write` — write user-level skill config by name or absolute path. - `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 2dbd17a3d7..7aa93ec86e 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -917,6 +917,11 @@ impl MessageProcessor { .remote_control_processor .status_read() .map(|response| Some(response.into())), + ClientRequest::RemoteControlPairingStart { params, .. } => self + .remote_control_processor + .pairing_start(params) + .await + .map(|response| Some(response.into())), ClientRequest::ConfigRequirementsRead { params: _, .. } => self .config_processor .config_requirements_read() diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor.rs b/codex-rs/app-server/src/request_processors/remote_control_processor.rs index 2fceeb5030..7c279585f2 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor.rs @@ -5,7 +5,10 @@ use crate::transport::RemoteControlUnavailable; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RemoteControlDisableResponse; use codex_app_server_protocol::RemoteControlEnableResponse; +use codex_app_server_protocol::RemoteControlPairingStartParams; +use codex_app_server_protocol::RemoteControlPairingStartResponse; use codex_app_server_protocol::RemoteControlStatusReadResponse; +use std::io; #[derive(Clone)] pub(crate) struct RemoteControlRequestProcessor { @@ -42,6 +45,16 @@ impl RemoteControlRequestProcessor { }) } + pub(crate) async fn pairing_start( + &self, + params: RemoteControlPairingStartParams, + ) -> Result { + self.handle()? + .start_pairing(params) + .await + .map_err(map_pairing_start_error) + } + fn handle(&self) -> Result<&RemoteControlHandle, JSONRPCErrorError> { self.remote_control_handle .as_ref() @@ -52,3 +65,14 @@ impl RemoteControlRequestProcessor { fn map_unavailable(err: RemoteControlUnavailable) -> JSONRPCErrorError { invalid_request(err.to_string()) } + +fn map_pairing_start_error(err: io::Error) -> JSONRPCErrorError { + if err.kind() == io::ErrorKind::InvalidInput { + invalid_request(err.to_string()) + } else { + internal_error(err.to_string()) + } +} + +#[cfg(test)] +mod remote_control_processor_tests; diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs b/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs new file mode 100644 index 0000000000..8a1db50a3a --- /dev/null +++ b/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs @@ -0,0 +1,48 @@ +use super::*; +use crate::error_code::INTERNAL_ERROR_CODE; +use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn pairing_start_returns_internal_error_when_remote_control_is_unavailable() { + let err = RemoteControlRequestProcessor::new(/*remote_control_handle*/ None) + .pairing_start(RemoteControlPairingStartParams::default()) + .await + .expect_err("missing remote control should fail pairing"); + + assert_eq!( + err, + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + data: None, + message: "remote control is unavailable for this app-server".to_string(), + } + ); +} + +#[test] +fn pairing_start_maps_invalid_input_to_invalid_request() { + assert_eq!( + map_pairing_start_error(io::Error::new( + io::ErrorKind::InvalidInput, + "remote control pairing is unavailable", + )), + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + data: None, + message: "remote control pairing is unavailable".to_string(), + } + ); +} + +#[test] +fn pairing_start_maps_backend_failures_to_internal_error() { + assert_eq!( + map_pairing_start_error(io::Error::other("remote control pairing failed")), + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + data: None, + message: "remote control pairing failed".to_string(), + } + ); +} diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index 3a3787d234..88378322a8 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -67,6 +67,7 @@ use codex_app_server_protocol::ProcessKillParams; use codex_app_server_protocol::ProcessResizePtyParams; use codex_app_server_protocol::ProcessSpawnParams; use codex_app_server_protocol::ProcessWriteStdinParams; +use codex_app_server_protocol::RemoteControlPairingStartParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; @@ -643,6 +644,16 @@ impl TestAppServer { .await } + /// Send a `remoteControl/pairing/start` JSON-RPC request. + pub async fn send_remote_control_pairing_start_request( + &mut self, + params: RemoteControlPairingStartParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("remoteControl/pairing/start", params) + .await + } + /// Send an `app/list` JSON-RPC request. pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result { let params = Some(serde_json::to_value(params)?); diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs index d15f2d4187..86187cfc40 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_control.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -11,12 +11,15 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlDisableResponse; use codex_app_server_protocol::RemoteControlEnableResponse; +use codex_app_server_protocol::RemoteControlPairingStartParams; +use codex_app_server_protocol::RemoteControlPairingStartResponse; use codex_app_server_protocol::RemoteControlStatusReadResponse; use codex_app_server_protocol::RequestId; use codex_config::types::AuthCredentialsStoreMode; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncWriteExt; use tokio::io::BufReader; use tokio::net::TcpListener; use tokio::net::TcpStream; @@ -125,6 +128,65 @@ async fn remote_control_status_read_returns_connecting_status_after_enable() -> Ok(()) } +#[tokio::test] +async fn remote_control_pairing_start_returns_pairing_artifacts() -> Result<()> { + let codex_home = TempDir::new()?; + let mut backend = PairingRemoteControlBackend::start(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_remote_control_enable_request().await?; + let _: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!( + timeout(DEFAULT_TIMEOUT, backend.wait_for_enroll_request()).await??, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_matching_notification( + "remoteControl/status/changed enrolled", + |notification| { + notification.method == "remoteControl/status/changed" + && notification + .params + .as_ref() + .and_then(|params| params.get("environmentId")) + .and_then(serde_json::Value::as_str) + == Some("environment-id") + }, + ), + ) + .await??; + + let request_id = mcp + .send_remote_control_pairing_start_request(RemoteControlPairingStartParams { + manual_code: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(response.result.get("serverId"), None); + let received: RemoteControlPairingStartResponse = to_response(response)?; + + assert_eq!( + received, + RemoteControlPairingStartResponse { + pairing_code: "pairing-code".to_string(), + manual_pairing_code: Some("ABCD-EFGH".to_string()), + environment_id: "environment-id".to_string(), + expires_at: 33_336_362_096, + } + ); + Ok(()) +} + struct BlockingRemoteControlBackend { enroll_request_rx: Option>>, server_task: JoinHandle<()>, @@ -132,20 +194,7 @@ struct BlockingRemoteControlBackend { impl BlockingRemoteControlBackend { async fn start(codex_home: &std::path::Path) -> Result { - let listener = TcpListener::bind("127.0.0.1:0").await?; - let remote_control_url = format!("http://{}/backend-api/", listener.local_addr()?); - write_mock_responses_config_toml_with_chatgpt_base_url( - codex_home, - &remote_control_url, - &remote_control_url, - )?; - write_chatgpt_auth( - codex_home, - ChatGptAuthFixture::new("chatgpt-token") - .account_id("account_id") - .chatgpt_account_id("account_id"), - AuthCredentialsStoreMode::File, - )?; + let listener = configured_remote_control_listener(codex_home).await?; let (enroll_request_tx, enroll_request_rx) = oneshot::channel(); let server_task = tokio::spawn(async move { @@ -175,19 +224,119 @@ impl BlockingRemoteControlBackend { } } +struct PairingRemoteControlBackend { + enroll_request_rx: Option>>, + server_task: JoinHandle<()>, +} + +impl PairingRemoteControlBackend { + async fn start(codex_home: &std::path::Path) -> Result { + let listener = configured_remote_control_listener(codex_home).await?; + let (enroll_request_tx, enroll_request_rx) = oneshot::channel(); + let server_task = tokio::spawn(async move { + let mut enroll_request_tx = Some(enroll_request_tx); + let result = async { + let enroll_request = read_http_request(&listener).await?; + if let Some(enroll_request_tx) = enroll_request_tx.take() { + let _ = enroll_request_tx.send(Ok(enroll_request.request_line.clone())); + } + respond_with_json( + enroll_request.reader.into_inner(), + serde_json::json!({ + "server_id": "server-id", + "environment_id": "environment-id", + "remote_control_token": "remote-control-token", + "expires_at": "3026-05-22T12:34:56Z", + }), + ) + .await?; + + let _websocket_request = read_http_request(&listener).await?; + let pair_http_request = read_http_request(&listener).await?; + respond_with_json( + pair_http_request.reader.into_inner(), + serde_json::json!({ + "pairing_code": "pairing-code", + "manual_pairing_code": "ABCD-EFGH", + "server_id": "server-id", + "environment_id": "environment-id", + "expires_at": "3026-05-22T12:34:56Z", + }), + ) + .await?; + std::future::pending::<()>().await; + Ok::<(), anyhow::Error>(()) + } + .await; + + if let Err(err) = result { + let err = err.to_string(); + if let Some(enroll_request_tx) = enroll_request_tx { + let _ = enroll_request_tx.send(Err(anyhow::anyhow!(err))); + } + } + }); + + Ok(Self { + enroll_request_rx: Some(enroll_request_rx), + server_task, + }) + } + + async fn wait_for_enroll_request(&mut self) -> Result { + self.enroll_request_rx + .take() + .context("enroll request should only be awaited once")? + .await? + } +} + +impl Drop for PairingRemoteControlBackend { + fn drop(&mut self) { + self.server_task.abort(); + } +} + impl Drop for BlockingRemoteControlBackend { fn drop(&mut self) { self.server_task.abort(); } } +struct HttpRequest { + request_line: String, + reader: BufReader, +} + +async fn configured_remote_control_listener(codex_home: &std::path::Path) -> Result { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let remote_control_url = format!("http://{}/backend-api/", listener.local_addr()?); + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home, + &remote_control_url, + &remote_control_url, + )?; + write_chatgpt_auth( + codex_home, + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account_id") + .chatgpt_account_id("account_id"), + AuthCredentialsStoreMode::File, + )?; + Ok(listener) +} + async fn read_enroll_request(listener: TcpListener) -> Result<(String, BufReader)> { + let request = read_http_request(&listener).await?; + Ok((request.request_line, request.reader)) +} + +async fn read_http_request(listener: &TcpListener) -> Result { let (stream, _) = listener.accept().await?; let mut reader = BufReader::new(stream); let mut request_line = String::new(); reader.read_line(&mut request_line).await?; - loop { let mut line = String::new(); reader.read_line(&mut line).await?; @@ -196,5 +345,23 @@ async fn read_enroll_request(listener: TcpListener) -> Result<(String, BufReader } } - Ok((request_line.trim_end().to_string(), reader)) + Ok(HttpRequest { + request_line: request_line.trim_end().to_string(), + reader, + }) +} + +async fn respond_with_json(stream: TcpStream, body: serde_json::Value) -> Result<()> { + let body = body.to_string(); + let mut stream = stream; + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await?; + Ok(()) } From 747f1003ddf734b97b550d44b13c63ed71dc7986 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Mon, 1 Jun 2026 18:20:25 -0700 Subject: [PATCH 02/59] [codex] Add comprehensive root formatting check (#25683) ## Why The root formatting entrypoints could drift: `just fmt` did not format the Justfile itself, and the CI-facing check recipe only checked Python scripts instead of matching everything formatted by `just fmt`. ## What changed - Add a shared cross-platform Python formatter driver used by both `just fmt` and `just fmt-check`. - Run Justfile, Rust, Python SDK, and internal-script formatter groups concurrently while buffering each formatter group's output until it finishes. - Log formatter starts immediately, then print each formatter group's labeled output when it completes. - Keep the SDK lint-fix and Ruff formatting passes ordered, with source comments explaining their distinct roles and the check-mode equivalents. - Run Ruff through shared `uv run --no-sync --with ruff` overlays so formatting works on clean glibc Linux checkouts without installing the platform-specific SDK runtime wheel. - Show `fmt-check` help text in `just -l` and simplify CI to call the shared driver through `just fmt-check`. - Pin the general CI workflow to `just@1.51.0` so its formatter agrees with the checked-in Justfile. - Add regression coverage for the thin Just recipes and the driver's formatter graph. ## Validation - `just fmt` - `just fmt-check` - `python3 -m pytest sdk/python/tests/test_artifact_workflow_and_binaries.py -k 'root_fmt or root_format' -q` - `pnpm run format` - `git diff --check` - `just -l | rg -n '^ fmt|fmt-check'` - `uvx --from uv==0.7.22 uv run --frozen --project sdk/python --no-sync --with ruff ruff check --diff sdk/python` --- .github/workflows/ci.yml | 6 +- justfile | 27 ++- scripts/format.py | 177 ++++++++++++++++++ .../test_artifact_workflow_and_binaries.py | 137 ++++++++++++-- 4 files changed, 312 insertions(+), 35 deletions(-) create mode 100644 scripts/format.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9adc90e040..63c6ffe520 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,13 +76,13 @@ jobs: - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 with: - tool: just + tool: just@1.51.0 - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: version: "0.11.3" - - name: Ruff format Python scripts (run `just fmt` to fix) - run: just fmt-scripts-check + - name: Check formatting (run `just fmt` to fix) + run: just fmt-check - name: Prettier (run `pnpm run format:fix` to fix) run: pnpm run format diff --git a/justfile b/justfile index 2c57e1cdf6..34b5115ee0 100644 --- a/justfile +++ b/justfile @@ -21,9 +21,9 @@ exec *args: cargo run --bin codex -- exec {args} # Start `codex exec-server` and run codex-tui. -[unix] [no-cd] [positional-arguments] +[unix] tui-with-exec-server *args: {{ justfile_directory() }}/scripts/run_tui_with_exec_server.sh "$@" @@ -36,16 +36,13 @@ app-server-test-client *args: cargo build -p codex-cli cargo run -p codex-app-server-test-client -- --codex-bin ./target/debug/codex {args} -# Format Rust, Python SDK code, and Python scripts. +# Format the justfile, Rust, Python SDK code, and Python scripts. fmt: - cargo fmt -- --config imports_granularity=Item {stderr-null} - uv run --frozen --project ../sdk/python --extra dev ruff check --fix --fix-only ../sdk/python - uv run --frozen --project ../sdk/python --extra dev ruff format ../sdk/python - # Root scripts have their own locked Ruff environment. - uv run --frozen --project ../scripts ruff format ../scripts + {{ python }} ../scripts/format.py -fmt-scripts-check: - uv run --frozen --project ../scripts ruff format --check ../scripts +# Check formatting without modifying files. +fmt-check: + {{ python }} ../scripts/format.py --check fix *args: cargo clippy --fix --tests --allow-dirty {args} @@ -97,21 +94,21 @@ bench-smoke: # Build and run Codex from source using Bazel. # On Unix, use `[no-cd]` and `--run_under="cd $PWD &&"` to ensure Bazel runs # the command in the current working directory. -[unix] [no-cd] +[unix] bazel-codex *args: bazel run //codex-rs/cli:codex --run_under="cd $PWD &&" -- "$@" [windows] bazel-codex *args: - bazel run //codex-rs/cli:codex --run_under='cd /d "{{invocation_directory_native()}}" &&' -- @($args | Select-Object -Skip 1) + bazel run //codex-rs/cli:codex --run_under='cd /d "{{ invocation_directory_native() }}" &&' -- @($args | Select-Object -Skip 1) [no-cd] bazel-lock-update: bazel mod deps --lockfile_mode=update -[unix] [no-cd] +[unix] bazel-lock-check: {{ justfile_directory() }}/scripts/check-module-bazel-lock.sh @@ -122,13 +119,13 @@ bazel-lock-check: bazel-test: bazel test --test_tag_filters=-argument-comment-lint //... --keep_going -[unix] [no-cd] +[unix] bazel-clippy: bazel_targets="$({{ justfile_directory() }}/scripts/list-bazel-clippy-targets.sh)" && bazel build --config=clippy -- ${bazel_targets} -[unix] [no-cd] +[unix] bazel-argument-comment-lint: bazel build --config=argument-comment-lint -- $({{ justfile_directory() }}/tools/argument-comment-lint/list-bazel-targets.sh) @@ -155,8 +152,8 @@ write-hooks-schema: cargo run --manifest-path {{ justfile_directory() }}/codex-rs/Cargo.toml -p codex-hooks --bin write_hooks_schema_fixtures # Run the argument-comment Dylint checks across codex-rs. -[unix] [no-cd] +[unix] argument-comment-lint *args: if [ "$#" -eq 0 ]; then \ bazel build --config=argument-comment-lint -- $({{ justfile_directory() }}/tools/argument-comment-lint/list-bazel-targets.sh); \ diff --git a/scripts/format.py b/scripts/format.py new file mode 100644 index 0000000000..0c64d2aa88 --- /dev/null +++ b/scripts/format.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Format repository sources or check that they are already formatted.""" + +import argparse +import shlex +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +CODEX_RS_ROOT = REPO_ROOT / "codex-rs" + + +@dataclass(frozen=True) +class Command: + args: tuple[str, ...] + cwd: Path = REPO_ROOT + discard_stderr: bool = False + + +@dataclass(frozen=True) +class FormatterGroup: + name: str + commands: tuple[Command, ...] + + +@dataclass(frozen=True) +class FormatterResult: + name: str + output: str + returncode: int + + +def formatter_groups(*, check: bool) -> tuple[FormatterGroup, ...]: + just_args = ["just", "--unstable", "--fmt"] + cargo_args = ["cargo", "fmt", "--", "--config", "imports_granularity=Item"] + # Use an unpinned overlay so Ruff is available without syncing project + # dependencies. Each `--project` still retains its local configuration context. + sdk_uv_run_args = [ + "uv", + "run", + "--frozen", + "--project", + "sdk/python", + "--no-sync", + "--with", + "ruff", + ] + scripts_uv_run_args = [ + "uv", + "run", + "--frozen", + "--project", + "scripts", + "--no-sync", + "--with", + "ruff", + ] + sdk_format_args = [ + *sdk_uv_run_args, + "ruff", + "format", + ] + scripts_format_args = [ + *scripts_uv_run_args, + "ruff", + "format", + ] + + if check: + just_args.append("--check") + cargo_args.append("--check") + sdk_format_args.append("--check") + scripts_format_args.append("--check") + # `ruff check --diff` reports lint-driven rewrites without changing files. + # It is the check-mode counterpart of `--fix --fix-only`, not a full lint gate. + sdk_lint_args = ["ruff", "check", "--diff"] + else: + # Ruff's lint fixer and formatter are separate passes: the first applies + # fixable lint rewrites, while the second formats source layout. + sdk_lint_args = ["ruff", "check", "--fix", "--fix-only"] + + return ( + FormatterGroup("Just", (Command(tuple(just_args)),)), + FormatterGroup( + "Rust", + # Stable rustfmt repeats a nightly-only `imports_granularity` warning + # for each crate, so suppress that expected stderr noise. + (Command(tuple(cargo_args), CODEX_RS_ROOT, discard_stderr=True),), + ), + FormatterGroup( + "Python SDK", + ( + Command( + ( + *sdk_uv_run_args, + *sdk_lint_args, + "sdk/python", + ) + ), + Command((*sdk_format_args, "sdk/python")), + ), + ), + FormatterGroup( + "Python scripts", + ( + # The SDK and internal scripts intentionally use separate project + # roots so uv and Ruff retain each project's configuration context. + Command((*scripts_format_args, "scripts")), + ), + ), + ) + + +def run_formatter_group(group: FormatterGroup) -> FormatterResult: + """Run one formatter group sequentially and return its buffered output.""" + output: list[str] = [] + for command in group.commands: + output.append(f"$ {shlex.join(command.args)}\n") + try: + process = subprocess.run( + command.args, + cwd=command.cwd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL + if command.discard_stderr + else subprocess.STDOUT, + text=True, + check=False, + ) + except OSError as error: + output.append(f"{error}\n") + return FormatterResult(group.name, "".join(output), 1) + + output.append(process.stdout) + if process.stdout and not process.stdout.endswith("\n"): + output.append("\n") + if process.returncode != 0: + return FormatterResult(group.name, "".join(output), process.returncode) + + return FormatterResult(group.name, "".join(output), 0) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--check", + action="store_true", + help="check formatting without modifying files", + ) + args = parser.parse_args() + groups = formatter_groups(check=args.check) + + failures: list[str] = [] + with ThreadPoolExecutor(max_workers=len(groups)) as executor: + futures = {} + for group in groups: + print(f"Starting {group.name} formatter...", flush=True) + futures[executor.submit(run_formatter_group, group)] = group.name + for future in as_completed(futures): + result = future.result() + print(f"==> {result.name} formatter finished") + print(result.output, end="") + if result.returncode != 0: + failures.append(result.name) + + if failures: + print(f"Formatting failed: {', '.join(failures)}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index 78fbdd6235..a8dda48854 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -14,6 +14,18 @@ import tomllib ROOT = Path(__file__).resolve().parents[1] +def _load_root_format_script_module(): + """Load the root formatter driver so tests exercise its real command graph.""" + script_path = ROOT.parents[1] / "scripts" / "format.py" + spec = importlib.util.spec_from_file_location("format_repo", script_path) + if spec is None or spec.loader is None: + raise AssertionError(f"Failed to load script module: {script_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + def _load_update_script_module(): """Load the maintenance script as a module so tests exercise real helpers.""" script_path = ROOT / "scripts" / "update_sdk_artifacts.py" @@ -68,44 +80,135 @@ def test_generation_has_single_maintenance_entrypoint_script() -> None: assert scripts == ["update_sdk_artifacts.py"] -def test_root_fmt_recipe_formats_rust_python_sdk_and_scripts() -> None: - """The repo fmt command should format Rust, the Python SDK, and scripts.""" +def test_root_fmt_recipes_use_shared_formatter_driver() -> None: + """The root formatting recipes should use the shared cross-platform driver.""" justfile = ROOT.parents[1] / "justfile" lines = justfile.read_text().splitlines() fmt_index = lines.index("fmt:") + fmt_check_index = lines.index("fmt-check:") next_recipe_index = next( index - for index in range(fmt_index + 1, len(lines)) + for index in range(fmt_check_index + 1, len(lines)) if lines[index] and not lines[index].startswith((" ", "\t", "#")) ) - fmt_recipe = lines[fmt_index:next_recipe_index] actual = { "working_directory": lines[0], - "previous_comment": next( - line for line in reversed(lines[:fmt_index]) if line.startswith("#") + "fmt_comment": next(line for line in reversed(lines[:fmt_index]) if line.startswith("#")), + "fmt_commands": [ + line.strip() + for line in lines[fmt_index + 1 : fmt_check_index] + if line.strip() and not line.startswith("#") + ], + "fmt_check_comment": next( + line for line in reversed(lines[:fmt_check_index]) if line.startswith("#") ), - "commands": [line.strip() for line in fmt_recipe[1:] if line.strip()], + "fmt_check_commands": [ + line.strip() for line in lines[fmt_check_index + 1 : next_recipe_index] if line.strip() + ], } expected = { "working_directory": 'set working-directory := "codex-rs"', - "previous_comment": "# Format Rust, Python SDK code, and Python scripts.", - "commands": [ - "cargo fmt -- --config imports_granularity=Item {stderr-null}", - "uv run --frozen --project ../sdk/python --extra dev ruff check --fix --fix-only ../sdk/python", - "uv run --frozen --project ../sdk/python --extra dev ruff format ../sdk/python", - "# Root scripts have their own locked Ruff environment.", - "uv run --frozen --project ../scripts ruff format ../scripts", - ], + "fmt_comment": "# Format the justfile, Rust, Python SDK code, and Python scripts.", + "fmt_commands": ["{{ python }} ../scripts/format.py"], + "fmt_check_comment": "# Check formatting without modifying files.", + "fmt_check_commands": ["{{ python }} ../scripts/format.py --check"], } assert actual == expected, ( - "The root `just fmt` recipe must run Rust fmt and Ruff for Python SDK code and scripts. " - "Fix the `fmt` recipe in `justfile`, then run `just fmt`.\n" + "The root formatting recipes must use the shared formatter driver. " + "Fix the recipes in `justfile`, then run `just fmt`.\n" f"Expected: {json.dumps(expected, indent=2)}\n" f"Actual: {json.dumps(actual, indent=2)}" ) +def test_root_format_driver_covers_all_formatter_groups() -> None: + """The shared driver should retain every formatter in both modes.""" + script = _load_root_format_script_module() + formatters = script.formatter_groups(check=False) + checks = script.formatter_groups(check=True) + + assert [group.name for group in formatters] == [ + "Just", + "Rust", + "Python SDK", + "Python scripts", + ] + assert [group.name for group in checks] == [group.name for group in formatters] + assert [len(group.commands) for group in formatters] == [1, 1, 2, 1] + assert [len(group.commands) for group in checks] == [ + len(group.commands) for group in formatters + ] + sdk_uv_run_args = ( + "uv", + "run", + "--frozen", + "--project", + "sdk/python", + "--no-sync", + "--with", + "ruff", + ) + scripts_uv_run_args = ( + "uv", + "run", + "--frozen", + "--project", + "scripts", + "--no-sync", + "--with", + "ruff", + ) + assert all( + command.args[: len(sdk_uv_run_args)] == sdk_uv_run_args + for group in (formatters[2], checks[2]) + for command in group.commands + ) + assert all( + command.args[: len(scripts_uv_run_args)] == scripts_uv_run_args + for group in (formatters[3], checks[3]) + for command in group.commands + ) + assert formatters[2].commands[0].args[-5:] == ( + "ruff", + "check", + "--fix", + "--fix-only", + "sdk/python", + ) + assert checks[2].commands[0].args[-4:] == ( + "ruff", + "check", + "--diff", + "sdk/python", + ) + assert formatters[0].commands[-1].args == ("just", "--unstable", "--fmt") + assert checks[0].commands[-1].args == ("just", "--unstable", "--fmt", "--check") + assert formatters[1].commands[-1].args == ( + "cargo", + "fmt", + "--", + "--config", + "imports_granularity=Item", + ) + assert checks[1].commands[-1].args == ( + "cargo", + "fmt", + "--", + "--config", + "imports_granularity=Item", + "--check", + ) + assert [group.commands[-1].args[-3:] for group in formatters[2:]] == [ + ("ruff", "format", "sdk/python"), + ("ruff", "format", "scripts"), + ] + assert [group.commands[-1].args[-4:] for group in checks[2:]] == [ + ("ruff", "format", "--check", "sdk/python"), + ("ruff", "format", "--check", "scripts"), + ] + + def test_generate_types_wires_all_generation_steps() -> None: """The type generation command should refresh every schema-derived artifact.""" source = (ROOT / "scripts" / "update_sdk_artifacts.py").read_text() From c955f730781da63bf1d29480f5cb43b012ef430a Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 1 Jun 2026 18:41:04 -0700 Subject: [PATCH 03/59] Move code review rules into AGENTS (#25738) ## Why Codex Review now supports repository-specific review rules in AGENTS.md. Adding the review prompts there makes the guidance available as repository review rules next to the code it governs while keeping the existing local review skills intact. ## What changed - Added a `## Code Review Rules` section to `AGENTS.md` with the existing review prompts for model context, breaking changes, test authoring, and change size. - Preserved the existing `.codex/skills/code-review*` skill files. ## Verification - `git diff --check origin/main...HEAD` --- AGENTS.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 63a9ba67c4..88d3fc4eea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,49 @@ Particularly when introducing a new concept/feature/API, before adding to `codex Likewise, when reviewing code, do not hesitate to push back on PRs that would unnecessarily add code to `codex-core`. +## Code Review Rules + +### Model visible context + +Codex maintains a context (history of messages) that is sent to the model in inference requests. + +1. No history rewrite - the context must be built up incrementally. +2. Avoid frequent changes to context that cause cache misses. +3. No unbounded items - everything injected in the model context must have a bounded size and a hard cap. +4. No items larger than 10K tokens. +5. Highlight new individual items that can cross >1k tokens as P0. These need an additional manual review. +6. All injected fragments must be defined as structs in `core/context` and implement ContextualUserFragment trait + +### Breaking changes + +Search for breaking changes in external integration surfaces: + +- app-server APIs +- CLI parameters +- configuration loading +- resuming sessions from existing rollouts + +### Test authoring guidance + +For agent changes prefer integration tests over unit tests. Integration tests are under `core/suite` and use `test_codex` to set up a test instance of codex. + +Features that change the agent logic MUST add an integration test: + +- Provide a list of major logic changes and user-facing behaviors that need to be tested. + +If unit tests are needed, put them in a dedicated test file (\*\_tests.rs). +Avoid test-only functions in the main implementation. + +Check whether there are existing helpers to make tests more streamlined and readable. + +### Change size guidance (800 lines) + +Unless the change is mechanical the total number of changed lines should not exceed 800 lines. +For complex logic changes the size should be under 500 lines. + +If the change is larger, explore whether it can be split into reviewable stages and identify the smallest coherent stage to land first. +Base the staging suggestion on the actual diff, dependencies, and affected call sites. + ## TUI style conventions See `codex-rs/tui/styles.md`. From c8e5db16c9994affe8e1959fb76d58c3775d4075 Mon Sep 17 00:00:00 2001 From: efrazer-oai Date: Mon, 1 Jun 2026 21:25:42 -0700 Subject: [PATCH 04/59] feat: show enterprise monthly credit limits in status (#24812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Enterprise users can have an effective monthly credit limit, but Codex `/status` currently drops that metadata from the account-usage response. This change adds the optional `spend_control.individual_limit` projection to the existing rate-limit snapshot flow. The backend client reads the monthly limit, app-server exposes it as `individualLimit`, and the TUI renders a `Monthly credit limit` row through the existing progress-bar renderer. When the backend does not return an effective monthly limit, existing rate-limit behavior is unchanged. ## Existing backend state The account-usage backend already returns the effective monthly limit and current usage together: ```json { "spend_control": { "reached": false, "individual_limit": { "limit": "25000", "used": "8000", "remaining": "17000", "used_percent": 32, "remaining_percent": 68, "reset_after_seconds": 86400, "reset_at": 1778137680 } } } ``` Before this change, Codex projected rolling `primary` and `secondary` windows plus `credits`. It ignored `spend_control.individual_limit`, so app-server clients and `/status` could not render the monthly cap. The updated flow is: ```text account usage backend -> backend-client reads spend_control.individual_limit -> existing rate-limit snapshot carries optional individual_limit -> app-server exposes optional individualLimit -> TUI renders Monthly credit limit ``` ## App-server contract `account/rateLimits/read` and sparse `account/rateLimits/updated` notifications now include an additive nullable `rateLimits.individualLimit` field: ```json { "individualLimit": { "limit": "25000", "used": "8000", "remainingPercent": 68, "resetsAt": 1778137680 } } ``` In an `account/rateLimits/read` response, `null` means no monthly limit is available. `account/rateLimits/updated` remains a sparse rolling notification: clients merge available values into their most recent `account/rateLimits/read` snapshot or refetch. Nullable account metadata in a rolling notification does not clear a previously observed value. ## Design decisions - Extend the existing rate-limit snapshot instead of introducing a separate request or wire-level update protocol. - Keep the Codex projection narrow: `/status` needs the effective limit, current usage, remaining percentage, and reset timestamp. - Render the monthly row through the existing progress-bar renderer, with one optional detail line for `8,000 of 25,000 credits used`. - Keep the backend response optional so existing accounts and older usage states preserve their current behavior. - Preserve cached monthly metadata when sparse rolling notifications omit it. Live account-usage reads remain authoritative and can clear a removed limit. ## Visual evidence ```text Monthly credit limit: [██████████████░░░░░░] 68% left (resets 07:08 on 7 May) 8,000 of 25,000 credits used ``` Snapshot: `codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_enterprise_monthly_credit_limit.snap` ## Testing Tests: generated app-server schema verification, protocol tests, backend-client tests, app-server integration coverage, TUI snapshot coverage, formatting, and workspace lint cleanup. --- .../schema/json/ServerNotification.json | 36 +++++ .../codex_app_server_protocol.schemas.json | 36 +++++ .../codex_app_server_protocol.v2.schemas.json | 36 +++++ .../AccountRateLimitsUpdatedNotification.json | 36 +++++ .../json/v2/GetAccountRateLimitsResponse.json | 35 +++++ .../AccountRateLimitsUpdatedNotification.ts | 7 + .../schema/typescript/v2/RateLimitSnapshot.ts | 3 +- .../v2/SpendControlLimitSnapshot.ts | 5 + .../schema/typescript/v2/index.ts | 1 + .../src/protocol/v2/account.rs | 30 ++++ codex-rs/app-server/README.md | 5 +- .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/app-server/src/outgoing_message.rs | 2 + .../app-server/tests/suite/v2/rate_limits.rs | 27 ++++ codex-rs/backend-client/src/client.rs | 53 +++++++ codex-rs/backend-client/src/types.rs | 1 + codex-rs/codex-api/src/rate_limits.rs | 2 + .../src/models/mod.rs | 6 + .../src/models/rate_limit_status_payload.rs | 8 + .../src/models/spend_control_limit_details.rs | 60 ++++++++ .../models/spend_control_status_details.rs | 35 +++++ codex-rs/core/src/session/tests.rs | 6 + codex-rs/core/src/state/session.rs | 3 + codex-rs/core/src/state/session_tests.rs | 19 ++- codex-rs/core/tests/suite/client.rs | 2 + .../core/tests/suite/client_websockets.rs | 1 + codex-rs/memories/write/src/guard_tests.rs | 1 + codex-rs/protocol/src/error_tests.rs | 1 + codex-rs/protocol/src/protocol.rs | 9 ++ codex-rs/tui/src/app/app_server_events.rs | 2 +- codex-rs/tui/src/app_server_session.rs | 1 + codex-rs/tui/src/chatwidget/rate_limits.rs | 35 ++++- codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + .../src/chatwidget/tests/status_and_layout.rs | 49 ++++++ .../tests/status_surface_previews.rs | 2 + codex-rs/tui/src/status/card.rs | 13 ++ codex-rs/tui/src/status/rate_limits.rs | 71 +++++++++ ...ludes_enterprise_monthly_credit_limit.snap | 22 +++ ...hly_credit_details_in_narrow_terminal.snap | 27 ++++ codex-rs/tui/src/status/tests.rs | 143 +++++++++++++++++- 40 files changed, 822 insertions(+), 11 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/SpendControlLimitSnapshot.ts create mode 100644 codex-rs/codex-backend-openapi-models/src/models/spend_control_limit_details.rs create mode 100644 codex-rs/codex-backend-openapi-models/src/models/spend_control_status_details.rs create mode 100644 codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_enterprise_monthly_credit_limit.snap create mode 100644 codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_wraps_enterprise_monthly_credit_details_in_narrow_terminal.snap diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 4eb5cae387..faf5a96879 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -29,6 +29,7 @@ "type": "object" }, "AccountRateLimitsUpdatedNotification": { + "description": "Sparse rolling rate-limit update.\n\nClients should merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and does not clear a previously observed value.", "properties": { "rateLimits": { "$ref": "#/definitions/RateLimitSnapshot" @@ -2677,6 +2678,16 @@ } ] }, + "individualLimit": { + "anyOf": [ + { + "$ref": "#/definitions/SpendControlLimitSnapshot" + }, + { + "type": "null" + } + ] + }, "limitId": { "type": [ "string", @@ -3135,6 +3146,31 @@ "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" }, + "SpendControlLimitSnapshot": { + "properties": { + "limit": { + "type": "string" + }, + "remainingPercent": { + "format": "int32", + "type": "integer" + }, + "resetsAt": { + "format": "int64", + "type": "integer" + }, + "used": { + "type": "string" + } + }, + "required": [ + "limit", + "remainingPercent", + "resetsAt", + "used" + ], + "type": "object" + }, "SubAgentSource": { "oneOf": [ { 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 b97c1f8fb7..ee1a49069c 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 @@ -5720,6 +5720,7 @@ }, "AccountRateLimitsUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Sparse rolling rate-limit update.\n\nClients should merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and does not clear a previously observed value.", "properties": { "rateLimits": { "$ref": "#/definitions/v2/RateLimitSnapshot" @@ -13327,6 +13328,16 @@ } ] }, + "individualLimit": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SpendControlLimitSnapshot" + }, + { + "type": "null" + } + ] + }, "limitId": { "type": [ "string", @@ -15269,6 +15280,31 @@ ], "type": "string" }, + "SpendControlLimitSnapshot": { + "properties": { + "limit": { + "type": "string" + }, + "remainingPercent": { + "format": "int32", + "type": "integer" + }, + "resetsAt": { + "format": "int64", + "type": "integer" + }, + "used": { + "type": "string" + } + }, + "required": [ + "limit", + "remainingPercent", + "resetsAt", + "used" + ], + "type": "object" + }, "SubAgentSource": { "oneOf": [ { 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 f8261c86d6..4d219f383b 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 @@ -92,6 +92,7 @@ }, "AccountRateLimitsUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Sparse rolling rate-limit update.\n\nClients should merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and does not clear a previously observed value.", "properties": { "rateLimits": { "$ref": "#/definitions/RateLimitSnapshot" @@ -9856,6 +9857,16 @@ } ] }, + "individualLimit": { + "anyOf": [ + { + "$ref": "#/definitions/SpendControlLimitSnapshot" + }, + { + "type": "null" + } + ] + }, "limitId": { "type": [ "string", @@ -13093,6 +13104,31 @@ ], "type": "string" }, + "SpendControlLimitSnapshot": { + "properties": { + "limit": { + "type": "string" + }, + "remainingPercent": { + "format": "int32", + "type": "integer" + }, + "resetsAt": { + "format": "int64", + "type": "integer" + }, + "used": { + "type": "string" + } + }, + "required": [ + "limit", + "remainingPercent", + "resetsAt", + "used" + ], + "type": "object" + }, "SubAgentSource": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json index 96fefb228b..c518085df9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json @@ -61,6 +61,16 @@ } ] }, + "individualLimit": { + "anyOf": [ + { + "$ref": "#/definitions/SpendControlLimitSnapshot" + }, + { + "type": "null" + } + ] + }, "limitId": { "type": [ "string", @@ -141,8 +151,34 @@ "usedPercent" ], "type": "object" + }, + "SpendControlLimitSnapshot": { + "properties": { + "limit": { + "type": "string" + }, + "remainingPercent": { + "format": "int32", + "type": "integer" + }, + "resetsAt": { + "format": "int64", + "type": "integer" + }, + "used": { + "type": "string" + } + }, + "required": [ + "limit", + "remainingPercent", + "resetsAt", + "used" + ], + "type": "object" } }, + "description": "Sparse rolling rate-limit update.\n\nClients should merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and does not clear a previously observed value.", "properties": { "rateLimits": { "$ref": "#/definitions/RateLimitSnapshot" diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json index 8f7db24e7f..7916d619ec 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json @@ -61,6 +61,16 @@ } ] }, + "individualLimit": { + "anyOf": [ + { + "$ref": "#/definitions/SpendControlLimitSnapshot" + }, + { + "type": "null" + } + ] + }, "limitId": { "type": [ "string", @@ -141,6 +151,31 @@ "usedPercent" ], "type": "object" + }, + "SpendControlLimitSnapshot": { + "properties": { + "limit": { + "type": "string" + }, + "remainingPercent": { + "format": "int32", + "type": "integer" + }, + "resetsAt": { + "format": "int64", + "type": "integer" + }, + "used": { + "type": "string" + } + }, + "required": [ + "limit", + "remainingPercent", + "resetsAt", + "used" + ], + "type": "object" } }, "properties": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AccountRateLimitsUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AccountRateLimitsUpdatedNotification.ts index 96c735a2eb..a6d6a33f49 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AccountRateLimitsUpdatedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AccountRateLimitsUpdatedNotification.ts @@ -3,4 +3,11 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { RateLimitSnapshot } from "./RateLimitSnapshot"; +/** + * Sparse rolling rate-limit update. + * + * Clients should merge available values into the most recent `account/rateLimits/read` response + * or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and + * does not clear a previously observed value. + */ export type AccountRateLimitsUpdatedNotification = { rateLimits: RateLimitSnapshot, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts index dc8417a304..c1e3953dfc 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts @@ -5,5 +5,6 @@ import type { PlanType } from "../PlanType"; import type { CreditsSnapshot } from "./CreditsSnapshot"; import type { RateLimitReachedType } from "./RateLimitReachedType"; import type { RateLimitWindow } from "./RateLimitWindow"; +import type { SpendControlLimitSnapshot } from "./SpendControlLimitSnapshot"; -export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, planType: PlanType | null, rateLimitReachedType: RateLimitReachedType | null, }; +export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, individualLimit: SpendControlLimitSnapshot | null, planType: PlanType | null, rateLimitReachedType: RateLimitReachedType | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SpendControlLimitSnapshot.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SpendControlLimitSnapshot.ts new file mode 100644 index 0000000000..077e590af0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SpendControlLimitSnapshot.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 SpendControlLimitSnapshot = { limit: string, used: string, remainingPercent: number, resetsAt: number, }; 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 66b67fb1b4..48e1f9580b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -350,6 +350,7 @@ export type { SkillsListEntry } from "./SkillsListEntry"; export type { SkillsListParams } from "./SkillsListParams"; export type { SkillsListResponse } from "./SkillsListResponse"; export type { SortDirection } from "./SortDirection"; +export type { SpendControlLimitSnapshot } from "./SpendControlLimitSnapshot"; export type { SubagentMigration } from "./SubagentMigration"; export type { TerminalInteractionNotification } from "./TerminalInteractionNotification"; export type { TextElement } from "./TextElement"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index 796c93d555..c2a538982c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -6,6 +6,7 @@ use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; use codex_protocol::protocol::RateLimitReachedType as CoreRateLimitReachedType; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; +use codex_protocol::protocol::SpendControlLimitSnapshot as CoreSpendControlLimitSnapshot; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -247,6 +248,11 @@ pub struct AccountUpdatedNotification { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] +/// Sparse rolling rate-limit update. +/// +/// Clients should merge available values into the most recent `account/rateLimits/read` response +/// or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and +/// does not clear a previously observed value. pub struct AccountRateLimitsUpdatedNotification { pub rate_limits: RateLimitSnapshot, } @@ -260,6 +266,7 @@ pub struct RateLimitSnapshot { pub primary: Option, pub secondary: Option, pub credits: Option, + pub individual_limit: Option, pub plan_type: Option, pub rate_limit_reached_type: Option, } @@ -272,6 +279,7 @@ impl From for RateLimitSnapshot { primary: value.primary.map(RateLimitWindow::from), secondary: value.secondary.map(RateLimitWindow::from), credits: value.credits.map(CreditsSnapshot::from), + individual_limit: value.individual_limit.map(SpendControlLimitSnapshot::from), plan_type: value.plan_type, rate_limit_reached_type: value .rate_limit_reached_type @@ -371,6 +379,28 @@ impl From for CreditsSnapshot { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SpendControlLimitSnapshot { + pub limit: String, + pub used: String, + pub remaining_percent: i32, + #[ts(type = "number")] + pub resets_at: i64, +} + +impl From for SpendControlLimitSnapshot { + fn from(value: CoreSpendControlLimitSnapshot) -> Self { + Self { + limit: value.limit, + used: value.used, + remaining_percent: value.remaining_percent, + resets_at: value.resets_at, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, 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 eb6e4afd86..c853eb50f3 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1760,8 +1760,8 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`. - `account/logout` — sign out; triggers `account/updated`. - `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available. -- `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify). -- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. +- `account/rateLimits/read` — fetch ChatGPT rate limits and an optional effective monthly credit limit; updates arrive via `account/rateLimits/updated` (notify). +- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. - `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit. - `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`. - `mcpServer/startupStatus/updated` (notify) — emitted when a configured MCP server's startup status changes for a loaded thread; payload includes `{ name, status, error }` where `status` is `starting`, `ready`, `failed`, or `cancelled`. @@ -1865,6 +1865,7 @@ Field notes: - `windowDurationMins` is the quota window length. - `resetsAt` is a Unix timestamp (seconds) for the next reset. - `rateLimitReachedType` identifies the backend-classified limit state when one has been reached. +- `individualLimit` describes the effective monthly credit limit when available. In an `account/rateLimits/read` response, `null` means no monthly limit is available. In a sparse `account/rateLimits/updated` notification, nullable account metadata may be unavailable and does not clear a previously observed value. ### 8) Notify a workspace owner about a limit diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 56754868fc..43e3d305bb 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3546,6 +3546,7 @@ mod tests { unlimited: false, balance: Some("5".to_string()), }), + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index e8fffd5361..ac7e87741a 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -798,6 +798,7 @@ mod tests { }), secondary: None, credits: None, + individual_limit: None, plan_type: Some(PlanType::Plus), rate_limit_reached_type: None, }, @@ -818,6 +819,7 @@ mod tests { }, "secondary": null, "credits": null, + "individualLimit": null, "planType": "plus", "rateLimitReachedType": null } diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index 0eca044991..1bb93db819 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -15,6 +15,7 @@ use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; use codex_app_server_protocol::SendAddCreditsNudgeEmailResponse; +use codex_app_server_protocol::SpendControlLimitSnapshot; use codex_config::types::AuthCredentialsStoreMode; use codex_protocol::account::PlanType as AccountPlanType; use pretty_assertions::assert_eq; @@ -128,6 +129,19 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { "rate_limit_reached_type": { "type": "workspace_member_usage_limit_reached", }, + "spend_control": { + "reached": false, + "individual_limit": { + "source": "workspace_spend_controls", + "limit": "25000", + "used": "8000", + "remaining": "17000", + "used_percent": 32, + "remaining_percent": 68, + "reset_after_seconds": 43200, + "reset_at": secondary_reset_timestamp, + } + }, "additional_rate_limits": [ { "limit_name": "codex_other", @@ -183,6 +197,12 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { resets_at: Some(secondary_reset_timestamp), }), credits: None, + individual_limit: Some(SpendControlLimitSnapshot { + limit: "25000".to_string(), + used: "8000".to_string(), + remaining_percent: 68, + resets_at: secondary_reset_timestamp, + }), plan_type: Some(AccountPlanType::Pro), rate_limit_reached_type: Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached), }, @@ -204,6 +224,12 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { resets_at: Some(secondary_reset_timestamp), }), credits: None, + individual_limit: Some(SpendControlLimitSnapshot { + limit: "25000".to_string(), + used: "8000".to_string(), + remaining_percent: 68, + resets_at: secondary_reset_timestamp, + }), plan_type: Some(AccountPlanType::Pro), rate_limit_reached_type: Some( RateLimitReachedType::WorkspaceMemberUsageLimitReached, @@ -222,6 +248,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { }), secondary: None, credits: None, + individual_limit: None, plan_type: Some(AccountPlanType::Pro), rate_limit_reached_type: None, }, diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 46c83fdde4..159dba8b84 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -16,6 +16,7 @@ use codex_protocol::protocol::CreditsSnapshot; use codex_protocol::protocol::RateLimitReachedType; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; +use codex_protocol::protocol::SpendControlLimitSnapshot; use reqwest::StatusCode; use reqwest::header::CONTENT_TYPE; use reqwest::header::HeaderMap; @@ -470,11 +471,17 @@ impl Client { .rate_limit_reached_type .flatten() .and_then(|details| Self::map_rate_limit_reached_type(details.kind)); + let individual_limit = payload + .spend_control + .flatten() + .and_then(|details| details.individual_limit.flatten()) + .map(|details| Self::map_individual_limit(*details)); let mut snapshots = vec![Self::make_rate_limit_snapshot( Some("codex".to_string()), /*limit_name*/ None, payload.rate_limit.flatten().map(|details| *details), payload.credits.flatten().map(|details| *details), + individual_limit, plan_type, rate_limit_reached_type, )]; @@ -485,6 +492,7 @@ impl Client { Some(details.limit_name), details.rate_limit.flatten().map(|rate_limit| *rate_limit), /*credits*/ None, + /*individual_limit*/ None, plan_type, /*rate_limit_reached_type*/ None, ) @@ -498,6 +506,7 @@ impl Client { limit_name: Option, rate_limit: Option, credits: Option, + individual_limit: Option, plan_type: Option, rate_limit_reached_type: Option, ) -> RateLimitSnapshot { @@ -514,6 +523,7 @@ impl Client { primary, secondary, credits: Self::map_credits(credits), + individual_limit, plan_type, rate_limit_reached_type, } @@ -582,6 +592,17 @@ impl Client { }) } + fn map_individual_limit( + details: crate::types::SpendControlLimitDetails, + ) -> SpendControlLimitSnapshot { + SpendControlLimitSnapshot { + limit: details.limit, + used: details.used, + remaining_percent: details.remaining_percent, + resets_at: i64::from(details.reset_at), + } + } + fn map_plan_type(plan_type: crate::types::PlanType) -> AccountPlanType { match plan_type { crate::types::PlanType::Free => AccountPlanType::Free, @@ -676,6 +697,23 @@ mod tests { balance: Some(Some("9.99".to_string())), ..Default::default() }))), + spend_control: Some(Some(Box::new( + codex_backend_openapi_models::models::SpendControlStatusDetails { + reached: false, + individual_limit: Some(Some(Box::new( + crate::types::SpendControlLimitDetails { + source: None, + limit: "25000".to_string(), + used: "8000".to_string(), + remaining: "17000".to_string(), + used_percent: 32, + remaining_percent: 68, + reset_after_seconds: 3600, + reset_at: 789, + }, + ))), + }, + ))), rate_limit_reached_type: Some(Some(BackendRateLimitReachedType { kind: RateLimitReachedKind::WorkspaceMemberCreditsDepleted, })), @@ -707,6 +745,15 @@ mod tests { snapshots[0].rate_limit_reached_type, Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) ); + assert_eq!( + snapshots[0].individual_limit, + Some(SpendControlLimitSnapshot { + limit: "25000".to_string(), + used: "8000".to_string(), + remaining_percent: 68, + resets_at: 789, + }) + ); assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other")); assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other")); @@ -715,6 +762,7 @@ mod tests { Some(70.0) ); assert_eq!(snapshots[1].credits, None); + assert_eq!(snapshots[1].individual_limit, None); assert_eq!(snapshots[1].plan_type, Some(AccountPlanType::Pro)); assert_eq!(snapshots[1].rate_limit_reached_type, None); } @@ -730,6 +778,7 @@ mod tests { rate_limit: None, }])), credits: None, + spend_control: None, rate_limit_reached_type: None, }; @@ -755,6 +804,7 @@ mod tests { }), secondary: None, credits: None, + individual_limit: None, plan_type: Some(AccountPlanType::Pro), rate_limit_reached_type: None, }, @@ -768,6 +818,7 @@ mod tests { }), secondary: None, credits: None, + individual_limit: None, plan_type: Some(AccountPlanType::Pro), rate_limit_reached_type: None, }, @@ -812,6 +863,7 @@ mod tests { plan_type: crate::types::PlanType::Plus, rate_limit: None, credits: None, + spend_control: None, additional_rate_limits: None, rate_limit_reached_type: Some(Some(BackendRateLimitReachedType { kind })), }; @@ -827,6 +879,7 @@ mod tests { plan_type: crate::types::PlanType::Plus, rate_limit: None, credits: None, + spend_control: None, additional_rate_limits: None, rate_limit_reached_type: None, }; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 67304f100f..38d9c6838f 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -10,6 +10,7 @@ pub use codex_backend_openapi_models::models::RateLimitReachedKind; pub use codex_backend_openapi_models::models::RateLimitStatusDetails; pub use codex_backend_openapi_models::models::RateLimitStatusPayload; pub use codex_backend_openapi_models::models::RateLimitWindowSnapshot; +pub use codex_backend_openapi_models::models::SpendControlLimitDetails; pub use codex_backend_openapi_models::models::TaskListItem; use serde::Deserialize; diff --git a/codex-rs/codex-api/src/rate_limits.rs b/codex-rs/codex-api/src/rate_limits.rs index a2ad876671..91f96fc3b8 100644 --- a/codex-rs/codex-api/src/rate_limits.rs +++ b/codex-rs/codex-api/src/rate_limits.rs @@ -93,6 +93,7 @@ pub fn parse_rate_limit_for_limit( primary, secondary, credits, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }) @@ -157,6 +158,7 @@ pub fn parse_rate_limit_event(payload: &str) -> Option { primary, secondary, credits, + individual_limit: None, plan_type: event.plan_type, rate_limit_reached_type: None, }) diff --git a/codex-rs/codex-backend-openapi-models/src/models/mod.rs b/codex-rs/codex-backend-openapi-models/src/models/mod.rs index f0504a9c82..c3f4e6beec 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/mod.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/mod.rs @@ -56,3 +56,9 @@ pub use self::rate_limit_window_snapshot::RateLimitWindowSnapshot; pub(crate) mod credit_status_details; pub use self::credit_status_details::CreditStatusDetails; + +pub(crate) mod spend_control_limit_details; +pub use self::spend_control_limit_details::SpendControlLimitDetails; + +pub(crate) mod spend_control_status_details; +pub use self::spend_control_status_details::SpendControlStatusDetails; diff --git a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs index 56d4a8ee10..066a81b524 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs @@ -30,6 +30,13 @@ pub struct RateLimitStatusPayload { skip_serializing_if = "Option::is_none" )] pub credits: Option>>, + #[serde( + rename = "spend_control", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub spend_control: Option>>, #[serde( rename = "additional_rate_limits", default, @@ -52,6 +59,7 @@ impl RateLimitStatusPayload { plan_type, rate_limit: None, credits: None, + spend_control: None, additional_rate_limits: None, rate_limit_reached_type: None, } diff --git a/codex-rs/codex-backend-openapi-models/src/models/spend_control_limit_details.rs b/codex-rs/codex-backend-openapi-models/src/models/spend_control_limit_details.rs new file mode 100644 index 0000000000..6fc1fc51a9 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/spend_control_limit_details.rs @@ -0,0 +1,60 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct SpendControlLimitDetails { + #[serde( + rename = "source", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub source: Option>, + #[serde(rename = "limit")] + pub limit: String, + #[serde(rename = "used")] + pub used: String, + #[serde(rename = "remaining")] + pub remaining: String, + #[serde(rename = "used_percent")] + pub used_percent: i32, + #[serde(rename = "remaining_percent")] + pub remaining_percent: i32, + #[serde(rename = "reset_after_seconds")] + pub reset_after_seconds: i32, + #[serde(rename = "reset_at")] + pub reset_at: i32, +} + +impl SpendControlLimitDetails { + pub fn new( + limit: String, + used: String, + remaining: String, + used_percent: i32, + remaining_percent: i32, + reset_after_seconds: i32, + reset_at: i32, + ) -> SpendControlLimitDetails { + SpendControlLimitDetails { + source: None, + limit, + used, + remaining, + used_percent, + remaining_percent, + reset_after_seconds, + reset_at, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/spend_control_status_details.rs b/codex-rs/codex-backend-openapi-models/src/models/spend_control_status_details.rs new file mode 100644 index 0000000000..1283acabf8 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/spend_control_status_details.rs @@ -0,0 +1,35 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct SpendControlStatusDetails { + #[serde(rename = "reached")] + pub reached: bool, + #[serde( + rename = "individual_limit", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub individual_limit: Option>>, +} + +impl SpendControlStatusDetails { + pub fn new(reached: bool) -> SpendControlStatusDetails { + SpendControlStatusDetails { + reached, + individual_limit: None, + } + } +} diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 14baed9372..ec67f765d1 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3118,6 +3118,7 @@ async fn set_rate_limits_retains_previous_credits() { unlimited: false, balance: Some("10.00".to_string()), }), + individual_limit: None, plan_type: Some(codex_protocol::account::PlanType::Plus), rate_limit_reached_type: None, }; @@ -3137,6 +3138,7 @@ async fn set_rate_limits_retains_previous_credits() { resets_at: Some(1_900), }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -3150,6 +3152,7 @@ async fn set_rate_limits_retains_previous_credits() { primary: update.primary.clone(), secondary: update.secondary, credits: initial.credits, + individual_limit: initial.individual_limit, plan_type: initial.plan_type, rate_limit_reached_type: None, }) @@ -3227,6 +3230,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { unlimited: false, balance: Some("15.00".to_string()), }), + individual_limit: None, plan_type: Some(codex_protocol::account::PlanType::Plus), rate_limit_reached_type: None, }; @@ -3242,6 +3246,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { }), secondary: None, credits: None, + individual_limit: None, plan_type: Some(codex_protocol::account::PlanType::Pro), rate_limit_reached_type: None, }; @@ -3255,6 +3260,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { primary: update.primary, secondary: update.secondary, credits: initial.credits, + individual_limit: initial.individual_limit, plan_type: update.plan_type, rate_limit_reached_type: None, }) diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index ac59cb32d5..2663943f82 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -258,6 +258,9 @@ fn merge_rate_limit_fields( if snapshot.credits.is_none() { snapshot.credits = previous.and_then(|prior| prior.credits.clone()); } + if snapshot.individual_limit.is_none() { + snapshot.individual_limit = previous.and_then(|prior| prior.individual_limit.clone()); + } if snapshot.plan_type.is_none() { snapshot.plan_type = previous.and_then(|prior| prior.plan_type); } diff --git a/codex-rs/core/src/state/session_tests.rs b/codex-rs/core/src/state/session_tests.rs index b6b5a057a0..5f0ae085e7 100644 --- a/codex-rs/core/src/state/session_tests.rs +++ b/codex-rs/core/src/state/session_tests.rs @@ -3,6 +3,7 @@ use crate::session::tests::make_session_configuration_for_tests; use crate::state::AutoCompactWindowSnapshot; use codex_protocol::protocol::CreditsSnapshot; use codex_protocol::protocol::RateLimitWindow; +use codex_protocol::protocol::SpendControlLimitSnapshot; use pretty_assertions::assert_eq; #[tokio::test] @@ -49,6 +50,7 @@ async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() { }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }); @@ -95,6 +97,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }); @@ -108,6 +111,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }); @@ -122,7 +126,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc } #[tokio::test] -async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other() { +async fn set_rate_limits_carries_account_metadata_from_codex_to_codex_other() { let session_configuration = make_session_configuration_for_tests().await; let mut state = SessionState::new(session_configuration); @@ -140,6 +144,12 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other unlimited: false, balance: Some("50".to_string()), }), + individual_limit: Some(SpendControlLimitSnapshot { + limit: "25000".to_string(), + used: "8000".to_string(), + remaining_percent: 68, + resets_at: 300, + }), plan_type: Some(codex_protocol::account::PlanType::Plus), rate_limit_reached_type: None, }); @@ -154,6 +164,7 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }); @@ -174,6 +185,12 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other unlimited: false, balance: Some("50".to_string()), }), + individual_limit: Some(SpendControlLimitSnapshot { + limit: "25000".to_string(), + used: "8000".to_string(), + remaining_percent: 68, + resets_at: 300, + }), plan_type: Some(codex_protocol::account::PlanType::Plus), rate_limit_reached_type: None, }) diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 48ba697aa0..c954a954fb 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -2580,6 +2580,7 @@ async fn token_count_includes_rate_limits_snapshot() { "resets_at": 1704074400 }, "credits": null, + "individual_limit": null, "plan_type": null, "rate_limit_reached_type": null } @@ -2655,6 +2656,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { "resets_at": null }, "credits": null, + "individual_limit": null, "plan_type": null, "rate_limit_reached_type": null }); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index d9e94204f2..41db88f1ad 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1350,6 +1350,7 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { "resets_at": null }, "credits": null, + "individual_limit": null, "plan_type": null, "rate_limit_reached_type": null } diff --git a/codex-rs/memories/write/src/guard_tests.rs b/codex-rs/memories/write/src/guard_tests.rs index 139a8773ca..0659aabd19 100644 --- a/codex-rs/memories/write/src/guard_tests.rs +++ b/codex-rs/memories/write/src/guard_tests.rs @@ -11,6 +11,7 @@ fn snapshot( primary: primary_used_percent.map(window), secondary: secondary_used_percent.map(window), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, } diff --git a/codex-rs/protocol/src/error_tests.rs b/codex-rs/protocol/src/error_tests.rs index 11bd26133b..bd7a5f0058 100644 --- a/codex-rs/protocol/src/error_tests.rs +++ b/codex-rs/protocol/src/error_tests.rs @@ -35,6 +35,7 @@ fn rate_limit_snapshot() -> RateLimitSnapshot { resets_at: Some(secondary_reset_at), }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7c91223f72..6f95ac8b88 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2009,6 +2009,7 @@ pub struct RateLimitSnapshot { pub primary: Option, pub secondary: Option, pub credits: Option, + pub individual_limit: Option, pub plan_type: Option, pub rate_limit_reached_type: Option, } @@ -2058,6 +2059,14 @@ pub struct CreditsSnapshot { pub balance: Option, } +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] +pub struct SpendControlLimitSnapshot { + pub limit: String, + pub used: String, + pub remaining_percent: i32, + pub resets_at: i64, +} + // Includes prompts, tools and space to call compact. const BASELINE_TOKENS: i64 = 12000; diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 18e6116e58..4aeba59aab 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -77,7 +77,7 @@ impl App { } ServerNotification::AccountRateLimitsUpdated(notification) => { self.chat_widget - .on_rate_limit_snapshot(Some(notification.rate_limits.clone())); + .on_rolling_rate_limit_snapshot(notification.rate_limits.clone()); return; } ServerNotification::AccountUpdated(notification) => { diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index f4e0ec101d..2290640ab4 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1804,6 +1804,7 @@ mod tests { }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, } diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index df45a11c7d..f831b5a007 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -150,8 +150,27 @@ pub(super) fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) - matches!(info, AppServerCodexErrorInfo::CyberPolicy) } +#[derive(Clone, Copy)] +enum RateLimitSnapshotSource { + AccountUsage, + RollingUpdate, +} + impl ChatWidget { pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option) { + self.on_rate_limit_snapshot_from(snapshot, RateLimitSnapshotSource::AccountUsage); + } + + pub(crate) fn on_rolling_rate_limit_snapshot(&mut self, snapshot: RateLimitSnapshot) { + // Rolling app-server notifications are sparse. Preserve metadata learned from the full read. + self.on_rate_limit_snapshot_from(Some(snapshot), RateLimitSnapshotSource::RollingUpdate); + } + + fn on_rate_limit_snapshot_from( + &mut self, + snapshot: Option, + source: RateLimitSnapshotSource, + ) { if let Some(mut snapshot) = snapshot { let limit_id = snapshot .limit_id @@ -172,7 +191,16 @@ impl ChatWidget { balance: credits.balance.clone(), }); } - + let preserved_individual_limit = + if matches!(source, RateLimitSnapshotSource::RollingUpdate) + && snapshot.individual_limit.is_none() + { + self.rate_limit_snapshots_by_limit_id + .get(&limit_id) + .and_then(|display| display.individual_limit.clone()) + } else { + None + }; self.plan_type = snapshot.plan_type.or(self.plan_type); let is_codex_limit = limit_id.eq_ignore_ascii_case("codex"); @@ -234,8 +262,11 @@ impl ChatWidget { self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending; } - let display = + let mut display = rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now()); + if display.individual_limit.is_none() { + display.individual_limit = preserved_individual_limit; + } self.rate_limit_snapshots_by_limit_id .insert(limit_id, display); diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 04eb543baa..cfc9278ded 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -112,6 +112,7 @@ pub(super) fn snapshot(percent: f64) -> RateLimitSnapshot { }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: 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 4b13e53801..3d08daa427 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -2,6 +2,7 @@ use super::*; use crate::bottom_pane::goal_status_indicator_line; use crate::chatwidget::rate_limits::NUDGE_MODEL_SLUG; use crate::chatwidget::rate_limits::get_limits_duration; +use codex_app_server_protocol::SpendControlLimitSnapshot; use pretty_assertions::assert_eq; use ratatui::backend::TestBackend; use serial_test::serial; @@ -580,6 +581,7 @@ async fn status_line_uses_secondary_fallback_for_unsupported_window() { resets_at: None, }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); @@ -608,6 +610,7 @@ async fn status_line_legacy_limit_items_prefer_matching_windows() { resets_at: None, }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); @@ -640,6 +643,7 @@ async fn status_line_shows_secondary_non_weekly_when_primary_is_weekly() { resets_at: None, }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); @@ -668,6 +672,7 @@ async fn status_line_five_hour_item_omits_weekly_only_limit() { }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); @@ -696,6 +701,7 @@ async fn status_line_single_monthly_primary_omits_weekly_limit_item() { }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); @@ -724,6 +730,7 @@ async fn status_line_secondary_only_non_weekly_limit_omits_primary_limit_item() resets_at: None, }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); @@ -752,6 +759,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { unlimited: false, balance: Some("17.5".to_string()), }), + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); @@ -772,6 +780,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); @@ -793,6 +802,40 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { ); } +#[tokio::test] +async fn rolling_rate_limit_snapshot_preserves_prior_individual_limit() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let mut usage_limits = snapshot(/*percent*/ 10.0); + usage_limits.individual_limit = Some(SpendControlLimitSnapshot { + limit: "25000".to_string(), + used: "8000".to_string(), + remaining_percent: 68, + resets_at: 1_800_000_000, + }); + chat.on_rate_limit_snapshot(Some(usage_limits)); + + chat.on_rolling_rate_limit_snapshot(snapshot(/*percent*/ 20.0)); + + let display = chat + .rate_limit_snapshots_by_limit_id + .get("codex") + .expect("rate limits should be cached"); + let individual_limit = display + .individual_limit + .as_ref() + .expect("rolling updates should preserve monthly limits"); + assert_eq!(individual_limit.used, "8,000"); + assert_eq!(individual_limit.limit, "25,000"); + assert_eq!(individual_limit.percent_remaining, 68.0); + + chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 30.0))); + let display = chat + .rate_limit_snapshots_by_limit_id + .get("codex") + .expect("rate limits should be cached"); + assert!(display.individual_limit.is_none()); +} + #[tokio::test] async fn rate_limit_snapshot_updates_and_retains_plan_type() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -811,6 +854,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { resets_at: None, }), credits: None, + individual_limit: None, plan_type: Some(PlanType::Plus), rate_limit_reached_type: None, })); @@ -830,6 +874,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { resets_at: Some(234), }), credits: None, + individual_limit: None, plan_type: Some(PlanType::Pro), rate_limit_reached_type: None, })); @@ -849,6 +894,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { resets_at: Some(567), }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); @@ -873,6 +919,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() { unlimited: false, balance: Some("5.00".to_string()), }), + individual_limit: None, plan_type: Some(PlanType::Pro), rate_limit_reached_type: None, })); @@ -887,6 +934,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() { }), secondary: None, credits: None, + individual_limit: None, plan_type: Some(PlanType::Pro), rate_limit_reached_type: None, })); @@ -940,6 +988,7 @@ async fn rate_limit_switch_prompt_skips_non_codex_limit() { }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index a90b8dcb27..8654b37d24 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -76,6 +76,7 @@ fn cache_rate_limit_snapshot(chat: &mut ChatWidget) { resets_at: None, }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); @@ -233,6 +234,7 @@ async fn status_surface_preview_omits_unavailable_rate_limit_items() { }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 0117508ae5..b8c9c16530 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -471,6 +471,7 @@ impl StatusHistoryCell { StatusRateLimitValue::Window { percent_used, resets_at, + details, } => { let percent_remaining = (100.0 - percent_used).clamp(0.0, 100.0); let summary = format_status_limit_summary(percent_remaining); @@ -522,6 +523,18 @@ impl StatusHistoryCell { } else { lines.push(base_line); } + if let Some(details) = details { + let detail_width = formatter.value_width(available_inner_width).max(1); + let wrap_options = textwrap::Options::new(detail_width).break_words(false); + lines.extend( + textwrap::wrap(details.as_str(), wrap_options) + .into_iter() + .map(|wrapped| { + formatter + .continuation(vec![Span::from(wrapped.into_owned()).dim()]) + }), + ); + } } StatusRateLimitValue::Text(text) => { let label = row.label.clone(); diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index 1146bb8a31..2054f799d6 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -17,6 +17,8 @@ use chrono::Utc; use codex_app_server_protocol::CreditsSnapshot as CoreCreditsSnapshot; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; +use codex_app_server_protocol::SpendControlLimitSnapshot as CoreSpendControlLimitSnapshot; +use codex_protocol::num_format::format_with_separators; const STATUS_LIMIT_BAR_SEGMENTS: usize = 20; const STATUS_LIMIT_BAR_FILLED: &str = "█"; @@ -39,6 +41,8 @@ pub(crate) enum StatusRateLimitValue { percent_used: f64, /// Localized reset string, or `None` when unknown. resets_at: Option, + /// Optional detail line rendered beneath the progress bar. + details: Option, }, /// Plain text value used for non-window rows. Text(String), @@ -99,6 +103,8 @@ pub(crate) struct RateLimitSnapshotDisplay { pub secondary: Option, /// Optional credits metadata when available. pub credits: Option, + /// Optional effective monthly credit limit from workspace spend controls. + pub individual_limit: Option, } /// Display-ready credits state extracted from protocol snapshots. @@ -112,6 +118,17 @@ pub(crate) struct CreditsSnapshotDisplay { pub balance: Option, } +/// Display-ready effective monthly limit extracted from spend controls. +#[derive(Debug, Clone)] +pub(crate) struct SpendControlLimitSnapshotDisplay { + /// Local timestamp representing when this monthly usage value was captured. + pub captured_at: DateTime, + pub percent_remaining: f64, + pub used: String, + pub limit: String, + pub resets_at: Option, +} + /// Converts a protocol snapshot into UI-friendly display data. /// /// Pass the timestamp from the same observation point as `snapshot`; supplying a significantly @@ -141,6 +158,10 @@ pub(crate) fn rate_limit_snapshot_display_for_limit( .as_ref() .map(|window| RateLimitWindowDisplay::from_window(window, captured_at)), credits: snapshot.credits.as_ref().map(CreditsSnapshotDisplay::from), + individual_limit: snapshot + .individual_limit + .as_ref() + .and_then(|limit| SpendControlLimitSnapshotDisplay::from_limit(limit, captured_at)), } } @@ -154,6 +175,22 @@ impl From<&CoreCreditsSnapshot> for CreditsSnapshotDisplay { } } +impl SpendControlLimitSnapshotDisplay { + fn from_limit( + value: &CoreSpendControlLimitSnapshot, + captured_at: DateTime, + ) -> Option { + Some(Self { + captured_at, + percent_remaining: f64::from(value.remaining_percent.clamp(0, 100)), + used: format_credit_amount(&value.used)?, + limit: format_credit_amount(&value.limit)?, + resets_at: DateTime::::from_timestamp(value.resets_at, 0) + .map(|dt| format_reset_timestamp(dt.with_timezone(&Local), captured_at)), + }) + } +} + /// Builds display rows from a snapshot and marks stale data by capture age. /// /// Callers should pass `Local::now()` for `now` at render time; using a cached timestamp can make @@ -182,6 +219,14 @@ pub(crate) fn compose_rate_limit_data_many( for snapshot in snapshots { stale |= now.signed_duration_since(snapshot.captured_at) > ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES); + stale |= snapshot + .individual_limit + .as_ref() + .map(|limit| { + now.signed_duration_since(limit.captured_at) + > ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES) + }) + .unwrap_or(false); let limit_bucket_label = snapshot.limit_name.clone(); let show_limit_prefix = !limit_bucket_label.eq_ignore_ascii_case("codex"); @@ -230,6 +275,7 @@ pub(crate) fn compose_rate_limit_data_many( value: StatusRateLimitValue::Window { percent_used: primary.used_percent, resets_at: primary.resets_at.clone(), + details: None, }, }); } @@ -256,6 +302,7 @@ pub(crate) fn compose_rate_limit_data_many( value: StatusRateLimitValue::Window { percent_used: secondary.used_percent, resets_at: secondary.resets_at.clone(), + details: None, }, }); } @@ -265,6 +312,19 @@ pub(crate) fn compose_rate_limit_data_many( { rows.push(row); } + if let Some(individual_limit) = snapshot.individual_limit.as_ref() { + rows.push(StatusRateLimitRow { + label: "Monthly credit limit".to_string(), + value: StatusRateLimitValue::Window { + percent_used: 100.0 - individual_limit.percent_remaining, + resets_at: individual_limit.resets_at.clone(), + details: Some(format!( + "{} of {} credits used", + individual_limit.used, individual_limit.limit + )), + }, + }); + } } if rows.is_empty() { @@ -341,6 +401,14 @@ fn format_credit_balance(raw: &str) -> Option { None } +fn format_credit_amount(raw: &str) -> Option { + let value = raw.trim().parse::().ok()?; + if !value.is_finite() || value < 0.0 { + return None; + } + Some(format_with_separators(value.round() as i64)) +} + #[cfg(test)] mod tests { use super::CreditsSnapshotDisplay; @@ -372,6 +440,7 @@ mod tests { unlimited: false, balance: Some("25".to_string()), }), + individual_limit: None, }; let other = RateLimitSnapshotDisplay { limit_name: "codex-other".to_string(), @@ -383,6 +452,7 @@ mod tests { unlimited: false, balance: Some("99".to_string()), }), + individual_limit: None, }; let rows = match compose_rate_limit_data_many(&[codex, other], now) { @@ -420,6 +490,7 @@ mod tests { window_minutes: Some(2 * 60), }), credits: None, + individual_limit: None, }; let rows = match compose_rate_limit_data_many(&[other], now) { diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_enterprise_monthly_credit_limit.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_enterprise_monthly_credit_limit.snap new file mode 100644 index 0000000000..765ad8d3a7 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_enterprise_monthly_credit_limit.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭───────────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 1.2K total (800 input + 400 output) │ +│ Context window: 100% left (1.2K used / 272K) │ +│ Monthly credit limit: [██████████████░░░░░░] 68% left (resets 07:08 on 7 May) │ +│ 8,000 of 25,000 credits used │ +╰───────────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_wraps_enterprise_monthly_credit_details_in_narrow_terminal.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_wraps_enterprise_monthly_credit_details_in_narrow_terminal.snap new file mode 100644 index 0000000000..cf6fa2c2ec --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_wraps_enterprise_monthly_credit_details_in_narrow_terminal.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit │ +│ https://chatgpt.com/codex/settings/usage │ +│ for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace │ +│ Agents.md: │ +│ │ +│ Token usage: 1.2K total (800 │ +│ Context window: 100% left (1.2K u │ +│ Monthly credit limit: 68% left │ +│ (resets 07:08 on │ +│ 7 May) │ +│ 8,000 of 25,000 │ +│ credits used │ +╰────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 8b4e41ce76..1fc4c4feba 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -2,6 +2,11 @@ use super::new_status_output; use super::new_status_output_with_rate_limits; use super::new_status_output_with_rate_limits_handle; use super::rate_limit_snapshot_display; +use super::rate_limits::RateLimitSnapshotDisplay; +use super::rate_limits::RateLimitWindowDisplay; +use super::rate_limits::SpendControlLimitSnapshotDisplay; +use super::rate_limits::StatusRateLimitData; +use super::rate_limits::compose_rate_limit_data_many; use crate::history_cell::HistoryCell; use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; @@ -13,12 +18,14 @@ use crate::test_support::test_path_buf; use crate::token_usage::TokenUsage; use crate::token_usage::TokenUsageInfo; use chrono::Duration as ChronoDuration; +use chrono::Local; use chrono::TimeZone; use chrono::Utc; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::CreditsSnapshot; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; +use codex_app_server_protocol::SpendControlLimitSnapshot; use codex_config::LoaderOverrides; use codex_model_provider_info::ModelProviderAwsAuthInfo; use codex_model_provider_info::ModelProviderInfo; @@ -40,6 +47,35 @@ use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::prelude::*; use tempfile::TempDir; +use unicode_width::UnicodeWidthStr; + +#[test] +fn stale_monthly_limit_marks_fresh_rolling_snapshot_stale() { + let now = Local::now(); + let snapshot = RateLimitSnapshotDisplay { + limit_name: "codex".to_string(), + captured_at: now, + primary: Some(RateLimitWindowDisplay { + used_percent: 20.0, + resets_at: Some("soon".to_string()), + window_minutes: Some(300), + }), + secondary: None, + credits: None, + individual_limit: Some(SpendControlLimitSnapshotDisplay { + captured_at: now - ChronoDuration::minutes(20), + percent_remaining: 68.0, + used: "8,000".to_string(), + limit: "25,000".to_string(), + resets_at: Some("later".to_string()), + }), + }; + + assert!(matches!( + compose_rate_limit_data_many(&[snapshot], now), + StatusRateLimitData::Stale(_) + )); +} fn app_server_workspace_write_profile(network_enabled: bool) -> PermissionProfile { PermissionProfile::Managed { @@ -133,18 +169,27 @@ fn render_lines(lines: &[Line<'static>]) -> Vec { } fn sanitize_directory(lines: Vec) -> Vec { + let frame_width = lines + .iter() + .find(|line| line.starts_with('╭')) + .map(|line| UnicodeWidthStr::width(line.as_str())); lines .into_iter() .map(|line| { - if let (Some(dir_pos), Some(pipe_idx)) = (line.find("Directory: "), line.rfind('│')) { + if let (Some(frame_width), Some(dir_pos), Some(pipe_idx)) = + (frame_width, line.find("Directory: "), line.rfind('│')) + { let prefix = &line[..dir_pos + "Directory: ".len()]; let suffix = &line[pipe_idx..]; - let content_width = pipe_idx.saturating_sub(dir_pos + "Directory: ".len()); let replacement = "[[workspace]]"; + let content_width = frame_width.saturating_sub( + UnicodeWidthStr::width(prefix) + UnicodeWidthStr::width(suffix), + ); let mut rebuilt = prefix.to_string(); rebuilt.push_str(replacement); - if content_width > replacement.len() { - rebuilt.push_str(&" ".repeat(content_width - replacement.len())); + let replacement_width = UnicodeWidthStr::width(replacement); + if content_width > replacement_width { + rebuilt.push_str(&" ".repeat(content_width - replacement_width)); } rebuilt.push_str(suffix); rebuilt @@ -236,6 +281,7 @@ async fn status_snapshot_includes_reasoning_details() { resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_200)), }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -871,6 +917,7 @@ async fn status_snapshot_includes_monthly_limit() { }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -903,6 +950,82 @@ async fn status_snapshot_includes_monthly_limit() { assert_snapshot!(sanitized); } +#[tokio::test] +async fn status_snapshot_includes_enterprise_monthly_credit_limit() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + let account_display = test_status_account_display(); + let usage = TokenUsage { + input_tokens: 800, + cached_input_tokens: 0, + output_tokens: 400, + reasoning_output_tokens: 0, + total_tokens: 1_200, + }; + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 5, 6, 7, 8, 9) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: None, + secondary: None, + credits: None, + individual_limit: Some(SpendControlLimitSnapshot { + limit: "25000".to_string(), + used: "8000".to_string(), + remaining_percent: 68, + resets_at: reset_at_from(&captured_at, /*seconds*/ 86_400), + }), + plan_type: None, + rate_limit_reached_type: None, + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + + let model_slug = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + Some(&rate_display), + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 92)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); + + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 46)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!( + "status_snapshot_wraps_enterprise_monthly_credit_details_in_narrow_terminal", + sanitized + ); +} + #[tokio::test] async fn status_snapshot_uses_generic_limit_labels_for_unsupported_windows() { let temp_home = TempDir::new().expect("temp home"); @@ -938,6 +1061,7 @@ async fn status_snapshot_uses_generic_limit_labels_for_unsupported_windows() { resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 172_800)), }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -990,6 +1114,7 @@ async fn status_snapshot_shows_unlimited_credits() { unlimited: true, balance: None, }), + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1040,6 +1165,7 @@ async fn status_snapshot_shows_positive_credits() { unlimited: false, balance: Some("12.5".to_string()), }), + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1090,6 +1216,7 @@ async fn status_snapshot_hides_zero_credits() { unlimited: false, balance: Some("0".to_string()), }), + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1138,6 +1265,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { unlimited: true, balance: None, }), + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1244,6 +1372,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1414,6 +1543,7 @@ async fn status_snapshot_shows_refreshing_limits_notice() { resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 2_700)), }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1485,6 +1615,7 @@ async fn status_snapshot_includes_credits_and_limits() { unlimited: false, balance: Some("37.5".to_string()), }), + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1539,6 +1670,7 @@ async fn status_snapshot_shows_unavailable_limits_message() { primary: None, secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1596,6 +1728,7 @@ async fn status_snapshot_treats_refreshing_empty_limits_as_unavailable() { primary: None, secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1667,6 +1800,7 @@ async fn status_snapshot_shows_stale_limits_message() { resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_800)), }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1738,6 +1872,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { unlimited: false, balance: Some("80".to_string()), }), + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; From cb63ee7f5d077a625fe93d292bbcf5a4b9dfc6a3 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Mon, 1 Jun 2026 21:27:06 -0700 Subject: [PATCH 05/59] [codex] Add plugin list JSON output (#25330) ## Summary - add `--json` output to `codex plugin list` with `installed` and `available` arrays - add `--available` for JSON output only; using it without `--json` is rejected - keep the existing non-JSON table output unchanged - add CLI coverage for JSON installed/available output and the `--available`/`--json` requirement ## Validation - `just test -p codex-cli plugin_list` - `just fix -p codex-cli` - `git diff --check` Note: `just fmt` ran Rust formatting first, then failed in the Python ruff step because `openai-codex-cli-bin==0.132.0` has no wheel for this Linux platform. --- codex-rs/cli/src/plugin_cmd.rs | 152 ++++++++++++++++++++++++++++++- codex-rs/cli/tests/plugin_cli.rs | 99 ++++++++++++++++++++ 2 files changed, 250 insertions(+), 1 deletion(-) diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index f8c559e506..9ed2c1be6e 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -12,10 +12,14 @@ use codex_core_plugins::PluginsManager; use codex_core_plugins::installed_marketplaces::marketplace_install_root; use codex_core_plugins::installed_marketplaces::resolve_configured_marketplace_root; use codex_core_plugins::marketplace::MarketplaceListError; +use codex_core_plugins::marketplace::MarketplacePluginAuthPolicy; +use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy; +use codex_core_plugins::marketplace::MarketplacePluginSource; use codex_core_plugins::marketplace::find_marketplace_manifest_path; use codex_plugin::PluginId; use codex_plugin::validate_plugin_segment; use codex_utils_cli::CliConfigOverrides; +use serde::Serialize; use std::path::Path; use std::path::PathBuf; @@ -73,12 +77,20 @@ pub struct AddPluginArgs { #[derive(Debug, Parser)] #[command( bin_name = "codex plugin list", - after_help = "Examples:\n codex plugin list\n codex plugin list --marketplace debug" + after_help = "Examples:\n codex plugin list\n codex plugin list --marketplace debug\n codex plugin list --json\n codex plugin list --available --json" )] pub struct ListPluginsArgs { /// Only list plugins from this configured marketplace name. #[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")] marketplace_name: Option, + + /// Output plugin list as JSON. + #[arg(long = "json")] + json: bool, + + /// Include uninstalled marketplace plugins in the JSON output. + #[arg(long = "available", requires = "json")] + available: bool, } #[derive(Debug, Parser)] @@ -166,6 +178,12 @@ pub async fn run_plugin_list( }) .collect::>(); + if args.json { + let output = JsonPluginListOutput::from_marketplaces(marketplaces, args.available); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + if marketplaces.is_empty() { if let Some(marketplace_name) = args.marketplace_name { println!("No plugins found in marketplace `{marketplace_name}`."); @@ -240,6 +258,138 @@ pub async fn run_plugin_list( Ok(()) } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonPluginListOutput { + installed: Vec, + available: Vec, +} + +impl JsonPluginListOutput { + fn from_marketplaces( + marketplaces: Vec, + include_available: bool, + ) -> Self { + let mut installed = Vec::new(); + let mut available = Vec::new(); + + for marketplace in marketplaces { + for plugin in marketplace.plugins { + let entry = JsonPluginListEntry::from_configured_plugin(&marketplace.name, plugin); + if entry.installed { + installed.push(entry); + } else if include_available { + available.push(entry); + } + } + } + + Self { + installed, + available, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonPluginListEntry { + plugin_id: String, + name: String, + marketplace_name: String, + version: Option, + installed: bool, + enabled: bool, + source: JsonPluginSource, + install_policy: &'static str, + auth_policy: &'static str, +} + +impl JsonPluginListEntry { + fn from_configured_plugin( + marketplace_name: &str, + plugin: codex_core_plugins::ConfiguredMarketplacePlugin, + ) -> Self { + let version = plugin.installed_version.or(plugin.local_version); + Self { + plugin_id: plugin.id, + name: plugin.name, + marketplace_name: marketplace_name.to_string(), + version, + installed: plugin.installed, + enabled: plugin.enabled, + source: JsonPluginSource::from_marketplace_source(plugin.source), + install_policy: install_policy_label(plugin.policy.installation), + auth_policy: auth_policy_label(plugin.policy.authentication), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(tag = "source", rename_all = "kebab-case")] +enum JsonPluginSource { + Local { + path: String, + }, + Git { + url: String, + #[serde(rename = "ref", skip_serializing_if = "Option::is_none")] + ref_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + sha: Option, + }, + GitSubdir { + url: String, + path: String, + #[serde(rename = "ref", skip_serializing_if = "Option::is_none")] + ref_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + sha: Option, + }, +} + +impl JsonPluginSource { + fn from_marketplace_source(source: MarketplacePluginSource) -> Self { + match source { + MarketplacePluginSource::Local { path } => Self::Local { + path: path.as_path().display().to_string(), + }, + MarketplacePluginSource::Git { + url, + path: Some(path), + ref_name, + sha, + } => Self::GitSubdir { + url, + path, + ref_name, + sha, + }, + MarketplacePluginSource::Git { + url, + path: None, + ref_name, + sha, + } => Self::Git { url, ref_name, sha }, + } + } +} + +fn install_policy_label(policy: MarketplacePluginInstallPolicy) -> &'static str { + match policy { + MarketplacePluginInstallPolicy::NotAvailable => "NOT_AVAILABLE", + MarketplacePluginInstallPolicy::Available => "AVAILABLE", + MarketplacePluginInstallPolicy::InstalledByDefault => "INSTALLED_BY_DEFAULT", + } +} + +fn auth_policy_label(policy: MarketplacePluginAuthPolicy) -> &'static str { + match policy { + MarketplacePluginAuthPolicy::OnInstall => "ON_INSTALL", + MarketplacePluginAuthPolicy::OnUse => "ON_USE", + } +} + pub async fn run_plugin_remove( overrides: Vec<(String, toml::Value)>, args: RemovePluginArgs, diff --git a/codex-rs/cli/tests/plugin_cli.rs b/codex-rs/cli/tests/plugin_cli.rs index 5719b8f103..4ce3617211 100644 --- a/codex-rs/cli/tests/plugin_cli.rs +++ b/codex-rs/cli/tests/plugin_cli.rs @@ -4,6 +4,8 @@ use codex_config::MarketplaceConfigUpdate; use codex_config::record_user_marketplace; use predicates::prelude::PredicateBooleanExt; use predicates::str::contains; +use pretty_assertions::assert_eq; +use serde_json::json; use std::path::Path; use tempfile::TempDir; @@ -489,6 +491,103 @@ async fn plugin_list_prints_plugins_in_a_table() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<()> { + let (codex_home, source) = setup_local_marketplace()?; + let plugin_path = source.path().join("plugins").join("sample"); + + let assert = codex_command(codex_home.path())? + .args(["plugin", "list", "--available", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "installed": [], + "available": [ + { + "pluginId": "sample@debug", + "name": "sample", + "marketplaceName": "debug", + "version": "1.2.3", + "installed": false, + "enabled": false, + "source": { + "source": "local", + "path": plugin_path.display().to_string(), + }, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_INSTALL", + }, + ], + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn plugin_list_json_prints_installed_plugins() -> Result<()> { + let (codex_home, source) = setup_local_marketplace()?; + let plugin_path = source.path().join("plugins").join("sample"); + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .success(); + + let assert = codex_command(codex_home.path())? + .args(["plugin", "list", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "installed": [ + { + "pluginId": "sample@debug", + "name": "sample", + "marketplaceName": "debug", + "version": "1.2.3", + "installed": true, + "enabled": true, + "source": { + "source": "local", + "path": plugin_path.display().to_string(), + }, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_INSTALL", + }, + ], + "available": [], + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn plugin_list_available_requires_json() -> Result<()> { + let (codex_home, _source) = setup_local_marketplace()?; + + codex_command(codex_home.path())? + .args(["plugin", "list", "--available"]) + .assert() + .failure() + .stderr(contains( + "the following required arguments were not provided", + )) + .stderr(contains("--json")); + + Ok(()) +} + #[tokio::test] async fn plugin_list_shows_installed_version_when_plugin_is_installed() -> Result<()> { let (codex_home, _source) = setup_local_marketplace()?; From f2b725102b26185f179c7ccec5fba00f6b01f0a3 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Mon, 1 Jun 2026 22:10:52 -0700 Subject: [PATCH 06/59] [codex] Cache remote plugin catalog for suggestions (#25457) ## Summary - cache the global remote plugin catalog when remote plugin listing runs and warm it during startup - use the cached remote catalog in plugin install recommendations with canonical `plugin@openai-curated-remote` ids - reuse the session `PluginsManager` for plugin recommendations so remote cache state is visible on the recommend path - skip core installed-state verification for remote plugin install suggestions while leaving local plugin and connector verification unchanged ## Testing - `just fmt` - `git diff --check` - `cargo test -p codex-core list_tool_suggest_discoverable_plugins_includes_cached_remote_global_plugins` - `cargo test -p codex-core remote_plugin_install_suggestions_skip_core_installed_verification` - `cargo test -p codex-app-server plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled` Earlier focused checks during the same branch: codex-tools TUI filter test, request_plugin_install tests, and codex-app-server build. --- .../src/request_processors/plugins.rs | 1 + .../app-server/tests/suite/v2/plugin_list.rs | 17 + codex-rs/core-plugins/src/lib.rs | 14 + codex-rs/core-plugins/src/manager.rs | 48 +- codex-rs/core-plugins/src/remote.rs | 100 +++- .../core-plugins/src/remote/catalog_cache.rs | 111 +++++ codex-rs/core/src/connectors.rs | 15 +- codex-rs/core/src/connectors_tests.rs | 16 +- codex-rs/core/src/plugins/discoverable.rs | 62 ++- .../core/src/plugins/discoverable_tests.rs | 426 ++++++++++++++++-- codex-rs/core/src/session/turn.rs | 1 + .../tools/handlers/request_plugin_install.rs | 11 + .../handlers/request_plugin_install_tests.rs | 11 + 13 files changed, 771 insertions(+), 62 deletions(-) create mode 100644 codex-rs/core-plugins/src/remote/catalog_cache.rs diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 4171887cdd..2335694856 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -662,6 +662,7 @@ impl PluginRequestProcessor { &remote_plugin_service_config, auth.as_ref(), &remote_sources, + /*global_catalog_cache_path*/ Some(config.codex_home.as_path()), ) .await { 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 ea9e5f4f35..0df7c82eef 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -1900,6 +1900,23 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - "project management".to_string() ] ); + let cache_files = std::fs::read_dir(codex_home.path().join("cache/remote_plugin_catalog"))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::, _>>()?; + assert_eq!(cache_files.len(), 1); + let cached_catalog: serde_json::Value = + serde_json::from_slice(&std::fs::read(&cache_files[0])?)?; + assert_eq!(cached_catalog["schema_version"], serde_json::json!(1)); + let cached_plugin_ids = cached_catalog["plugins"] + .as_array() + .expect("cached plugins should be an array") + .iter() + .map(|plugin| plugin["id"].as_str().expect("cached plugin id").to_string()) + .collect::>(); + assert_eq!( + cached_plugin_ids, + vec!["plugins~Plugin_00000000000000000000000000000000".to_string()] + ); assert_eq!(response.featured_plugin_ids, Vec::::new()); assert!( !server diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 4e59162e34..60cb50a885 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -35,6 +35,20 @@ pub const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[ "outlook-calendar@openai-curated", "linear@openai-curated", "figma@openai-curated", + "github@openai-curated-remote", + "notion@openai-curated-remote", + "slack@openai-curated-remote", + "gmail@openai-curated-remote", + "google-calendar@openai-curated-remote", + "google-drive@openai-curated-remote", + "openai-developers@openai-curated-remote", + "canva@openai-curated-remote", + "teams@openai-curated-remote", + "sharepoint@openai-curated-remote", + "outlook-email@openai-curated-remote", + "outlook-calendar@openai-curated-remote", + "linear@openai-curated-remote", + "figma@openai-curated-remote", "chrome@openai-bundled", "computer-use@openai-bundled", ]; diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index ff596273f9..8022c6c86d 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -592,6 +592,31 @@ impl PluginsManager { Some(crate::remote::group_remote_installed_plugins_by_marketplaces(plugins, visible_scopes)) } + pub fn cached_global_remote_discoverable_plugins_for_config( + &self, + config: &PluginsConfigInput, + auth: Option<&CodexAuth>, + ) -> Vec { + if !config.plugins_enabled || !config.remote_plugin_enabled { + return Vec::new(); + } + let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) else { + return Vec::new(); + }; + let Some(account_id) = auth.get_account_id() else { + return Vec::new(); + }; + if account_id.is_empty() { + return Vec::new(); + } + + crate::remote::cached_global_remote_discoverable_plugins( + self.codex_home.as_path(), + &remote_plugin_service_config(config), + auth, + ) + } + pub async fn build_and_cache_remote_installed_plugin_marketplaces( &self, config: &PluginsConfigInput, @@ -1548,9 +1573,30 @@ impl PluginsManager { ); manager.maybe_start_remote_installed_plugin_bundle_sync( &config_for_remote_sync, - auth, + auth.clone(), on_effective_plugins_changed, ); + if config_for_remote_sync.remote_plugin_enabled { + match crate::remote::fetch_and_cache_global_remote_plugin_catalog( + manager.codex_home.as_path(), + &remote_plugin_service_config(&config_for_remote_sync), + auth.as_ref(), + ) + .await + { + Ok(()) => {} + Err( + RemotePluginCatalogError::AuthRequired + | RemotePluginCatalogError::UnsupportedAuthMode, + ) => {} + Err(err) => { + warn!( + error = %err, + "failed to warm remote plugin catalog cache" + ); + } + } + } }); let config = config.clone(); diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 16ae512931..3c86f6b14b 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -12,15 +12,18 @@ use codex_plugin::PluginId; use codex_utils_absolute_path::AbsolutePathBuf; use reqwest::RequestBuilder; use serde::Deserialize; +use serde::Serialize; use serde_json::Value as JsonValue; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::collections::HashSet; use std::fs; +use std::path::Path; use std::path::PathBuf; use std::time::Duration; use url::Url; +mod catalog_cache; mod remote_installed_plugin_sync; mod share; @@ -179,6 +182,18 @@ pub struct RemotePluginSkillDetail { pub contents: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteDiscoverablePlugin { + pub config_id: String, + pub remote_plugin_id: String, + pub name: String, + pub description: Option, + pub has_skills: bool, + pub app_ids: Vec, + pub install_policy: PluginInstallPolicy, + pub availability: PluginAvailability, +} + pub fn is_valid_remote_plugin_id(plugin_id: &str) -> bool { !plugin_id.is_empty() && plugin_id @@ -293,7 +308,7 @@ pub enum RemotePluginCatalogError { CacheRemove(String), } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)] pub enum RemotePluginScope { #[serde(rename = "GLOBAL")] Global, @@ -340,7 +355,7 @@ struct RemotePluginPagination { next_page_token: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginSkillInterfaceResponse { display_name: Option, short_description: Option, @@ -350,7 +365,7 @@ struct RemotePluginSkillInterfaceResponse { icon_large_url: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginSkillResponse { name: String, description: String, @@ -364,7 +379,7 @@ struct RemotePluginSkillDetailResponse { skill_md_contents: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginReleaseInterfaceResponse { short_description: Option, long_description: Option, @@ -383,7 +398,7 @@ struct RemotePluginReleaseInterfaceResponse { screenshot_urls: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginReleaseResponse { #[serde(default)] version: Option, @@ -402,7 +417,7 @@ struct RemotePluginReleaseResponse { skills: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginDirectoryItem { id: String, name: String, @@ -450,7 +465,7 @@ fn workspace_plugin_discoverability( }) } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginDirectorySharePrincipal { principal_type: RemotePluginSharePrincipalType, principal_id: String, @@ -489,6 +504,7 @@ pub async fn fetch_remote_marketplaces( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, sources: &[RemoteMarketplaceSource], + global_catalog_cache_path: Option<&Path>, ) -> Result, RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; let mut marketplaces = Vec::new(); @@ -512,6 +528,8 @@ pub async fn fetch_remote_marketplaces( fetch_directory_plugins_for_scope(config, auth, scope), fetch_installed_plugins_for_scope(config, auth, scope), )?; + let directory_plugins_for_cache = + global_catalog_cache_path.map(|_| directory_plugins.clone()); if let Some(marketplace) = build_remote_marketplace( scope.marketplace_name(), scope.marketplace_display_name(), @@ -521,6 +539,16 @@ pub async fn fetch_remote_marketplaces( )? { marketplaces.push(marketplace); } + if let (Some(codex_home), Some(directory_plugins)) = + (global_catalog_cache_path, directory_plugins_for_cache) + { + catalog_cache::write_cached_global_directory_plugins( + codex_home, + config, + auth, + &directory_plugins, + ); + } } RemoteMarketplaceSource::WorkspaceDirectory => { let scope = RemotePluginScope::Workspace; @@ -600,6 +628,36 @@ pub async fn fetch_remote_marketplaces( Ok(marketplaces) } +pub async fn fetch_and_cache_global_remote_plugin_catalog( + codex_home: &Path, + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, +) -> Result<(), RemotePluginCatalogError> { + let auth = ensure_chatgpt_auth(auth)?; + let plugins = + fetch_directory_plugins_for_scope(config, auth, RemotePluginScope::Global).await?; + catalog_cache::write_cached_global_directory_plugins(codex_home, config, auth, &plugins); + Ok(()) +} + +pub fn cached_global_remote_discoverable_plugins( + codex_home: &Path, + config: &RemotePluginServiceConfig, + auth: &CodexAuth, +) -> Vec { + catalog_cache::load_cached_global_directory_plugins(codex_home, config, auth) + .unwrap_or_default() + .into_iter() + .filter_map(|plugin| match remote_discoverable_plugin_from_directory_item(&plugin) { + Ok(plugin) => Some(plugin), + Err(err) => { + tracing::warn!(error = %err, "ignoring cached remote plugin recommendation entry"); + None + } + }) + .collect() +} + pub async fn fetch_openai_curated_remote_collection_marketplace( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, @@ -1053,6 +1111,34 @@ fn build_remote_plugin_summary( }) } +fn remote_discoverable_plugin_from_directory_item( + plugin: &RemotePluginDirectoryItem, +) -> Result { + let marketplace_name = remote_plugin_canonical_marketplace_name(plugin)?; + let plugin_id = + PluginId::new(plugin.name.clone(), marketplace_name.to_string()).map_err(|err| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "invalid remote plugin config id for `{}` in `{marketplace_name}`: {err}", + plugin.name + )) + })?; + let display_name = + non_empty_string(Some(&plugin.release.display_name)).unwrap_or_else(|| plugin.name.clone()); + let description = non_empty_string(plugin.release.interface.short_description.as_deref()) + .or_else(|| non_empty_string(Some(&plugin.release.description))); + + Ok(RemoteDiscoverablePlugin { + config_id: plugin_id.as_key(), + remote_plugin_id: plugin.id.clone(), + name: display_name, + description, + has_skills: !plugin.release.skills.is_empty(), + app_ids: plugin.release.app_ids.clone(), + install_policy: plugin.installation_policy, + availability: plugin.availability, + }) +} + fn remote_plugin_share_context( plugin: &RemotePluginDirectoryItem, ) -> Result, RemotePluginCatalogError> { diff --git a/codex-rs/core-plugins/src/remote/catalog_cache.rs b/codex-rs/core-plugins/src/remote/catalog_cache.rs new file mode 100644 index 0000000000..d49885dfbb --- /dev/null +++ b/codex-rs/core-plugins/src/remote/catalog_cache.rs @@ -0,0 +1,111 @@ +use super::RemotePluginDirectoryItem; +use super::RemotePluginServiceConfig; +use codex_login::CodexAuth; +use serde::Deserialize; +use serde::Serialize; +use std::path::Path; +use std::path::PathBuf; +use tracing::warn; + +const REMOTE_PLUGIN_CATALOG_DISK_CACHE_SCHEMA_VERSION: u8 = 1; +const REMOTE_PLUGIN_CATALOG_DISK_CACHE_DIR: &str = "cache/remote_plugin_catalog"; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct RemotePluginCatalogCacheKey { + chatgpt_base_url: String, + account_id: Option, + chatgpt_user_id: Option, + is_workspace_account: bool, +} + +impl RemotePluginCatalogCacheKey { + fn global(config: &RemotePluginServiceConfig, auth: &CodexAuth) -> Self { + Self { + chatgpt_base_url: config.chatgpt_base_url.clone(), + account_id: auth.get_account_id(), + chatgpt_user_id: auth.get_chatgpt_user_id(), + is_workspace_account: auth.is_workspace_account(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct RemotePluginCatalogDiskCache { + schema_version: u8, + plugins: Vec, +} + +pub(crate) fn load_cached_global_directory_plugins( + codex_home: &Path, + config: &RemotePluginServiceConfig, + auth: &CodexAuth, +) -> Option> { + let cache_path = cache_path( + codex_home, + &RemotePluginCatalogCacheKey::global(config, auth), + ); + let bytes = match std::fs::read(&cache_path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None, + Err(err) => { + warn!( + cache_path = %cache_path.display(), + "failed to read remote plugin catalog disk cache: {err}" + ); + return None; + } + }; + let cache: RemotePluginCatalogDiskCache = match serde_json::from_slice(&bytes) { + Ok(cache) => cache, + Err(err) => { + warn!( + cache_path = %cache_path.display(), + "failed to parse remote plugin catalog disk cache: {err}" + ); + let _ = std::fs::remove_file(cache_path); + return None; + } + }; + if cache.schema_version != REMOTE_PLUGIN_CATALOG_DISK_CACHE_SCHEMA_VERSION { + let _ = std::fs::remove_file(cache_path); + return None; + } + + Some(cache.plugins) +} + +pub(crate) fn write_cached_global_directory_plugins( + codex_home: &Path, + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + plugins: &[RemotePluginDirectoryItem], +) { + let cache_path = cache_path( + codex_home, + &RemotePluginCatalogCacheKey::global(config, auth), + ); + if let Some(parent) = cache_path.parent() + && std::fs::create_dir_all(parent).is_err() + { + return; + } + let Ok(bytes) = serde_json::to_vec_pretty(&RemotePluginCatalogDiskCache { + schema_version: REMOTE_PLUGIN_CATALOG_DISK_CACHE_SCHEMA_VERSION, + plugins: plugins.to_vec(), + }) else { + return; + }; + let _ = std::fs::write(cache_path, bytes); +} + +fn cache_path(codex_home: &Path, cache_key: &RemotePluginCatalogCacheKey) -> PathBuf { + let cache_key_json = serde_json::to_vec(cache_key).unwrap_or_default(); + let mut cache_key_hash = 0xcbf29ce484222325_u64; + for byte in cache_key_json { + cache_key_hash ^= u64::from(byte); + cache_key_hash = cache_key_hash.wrapping_mul(0x100000001b3); + } + codex_home + .join(REMOTE_PLUGIN_CATALOG_DISK_CACHE_DIR) + .join(format!("{cache_key_hash:016x}.json")) +} diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 55a941e8c4..c0b49cb633 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -111,6 +111,7 @@ pub(crate) async fn list_accessible_and_enabled_connectors_from_manager( pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( config: &Config, + plugins_manager: &PluginsManager, auth: Option<&CodexAuth>, accessible_connectors: &[AppInfo], loaded_plugin_app_connector_ids: &[String], @@ -129,11 +130,15 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( ) .into_iter() .map(DiscoverableTool::from); - let discoverable_plugins = - list_tool_suggest_discoverable_plugins(config, loaded_plugin_app_connector_ids) - .await? - .into_iter() - .map(DiscoverableTool::from); + let discoverable_plugins = list_tool_suggest_discoverable_plugins( + config, + plugins_manager, + auth, + loaded_plugin_app_connector_ids, + ) + .await? + .into_iter() + .map(DiscoverableTool::from); Ok(discoverable_connectors .chain(discoverable_plugins) .collect()) diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index 285df9a29a..5db026c3b1 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -1236,11 +1236,17 @@ discoverables = [ .await .expect("config should load"); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf()); - let discoverable_tools = - list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[], &[]) - .await - .expect("discoverable tools should load"); + let discoverable_tools = list_tool_suggest_discoverable_tools_with_auth( + &config, + &plugins_manager, + Some(&auth), + &[], + &[], + ) + .await + .expect("discoverable tools should load"); assert_eq!( discoverable_tools, @@ -1268,9 +1274,11 @@ apps = true .expect("config should load"); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let loaded_plugin_app_connector_ids = vec!["asdk_app_databricks_workspace".to_string()]; + let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf()); let discoverable_tools = list_tool_suggest_discoverable_tools_with_auth( &config, + &plugins_manager, Some(&auth), &[], &loaded_plugin_app_connector_ids, diff --git a/codex-rs/core/src/plugins/discoverable.rs b/codex-rs/core/src/plugins/discoverable.rs index 4f0d88b512..933b30dc1d 100644 --- a/codex-rs/core/src/plugins/discoverable.rs +++ b/codex-rs/core/src/plugins/discoverable.rs @@ -4,29 +4,36 @@ use tracing::warn; use super::PluginCapabilitySummary; use crate::config::Config; +use codex_app_server_protocol::PluginAvailability; +use codex_app_server_protocol::PluginInstallPolicy; use codex_config::types::ToolSuggestDiscoverableType; use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME; use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core_plugins::PluginsManager; -use codex_core_plugins::TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST as TOOL_SUGGEST_DISCOVERABLE_PLUGIN_FALLBACK_ALLOWLIST; +use codex_core_plugins::TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST; use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy; +use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; +use codex_core_plugins::remote::RemotePluginScope; use codex_features::Feature; +use codex_login::CodexAuth; use codex_tools::DiscoverablePluginInfo; const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[ OPENAI_BUNDLED_MARKETPLACE_NAME, OPENAI_CURATED_MARKETPLACE_NAME, + REMOTE_GLOBAL_MARKETPLACE_NAME, ]; pub(crate) async fn list_tool_suggest_discoverable_plugins( config: &Config, + plugins_manager: &PluginsManager, + auth: Option<&CodexAuth>, loaded_plugin_app_connector_ids: &[String], ) -> anyhow::Result> { if !config.features.enabled(Feature::Plugins) { return Ok(Vec::new()); } - let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf()); let plugins_input = config.plugins_config_input(); let configured_plugin_ids = config .tool_suggest @@ -55,17 +62,28 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins( .map(|connector_id| connector_id.0.clone()) .collect::>(); installed_app_connector_ids.extend(loaded_plugin_app_connector_ids.iter().cloned()); + let remote_installed_marketplaces = if plugins_input.remote_plugin_enabled { + plugins_manager + .build_remote_installed_plugin_marketplaces_from_cache(&[RemotePluginScope::Global]) + } else { + None + }; let mut discoverable_plugins = Vec::::new(); for marketplace in marketplaces { let marketplace_name = marketplace.name; + if plugins_input.remote_plugin_enabled + && marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME + { + continue; + } let is_allowlisted_marketplace = TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST.contains(&marketplace_name.as_str()); for plugin in marketplace.plugins { let is_configured_plugin = configured_plugin_ids.contains(plugin.id.as_str()); let is_fallback_plugin = - TOOL_SUGGEST_DISCOVERABLE_PLUGIN_FALLBACK_ALLOWLIST.contains(&plugin.id.as_str()); + TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str()); if plugin.installed || plugin.policy.installation == MarketplacePluginInstallPolicy::NotAvailable || disabled_plugin_ids.contains(plugin.id.as_str()) @@ -113,6 +131,44 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins( } } } + if let Some(remote_installed_marketplaces) = remote_installed_marketplaces.as_ref() { + let installed_remote_plugin_ids = remote_installed_marketplaces + .iter() + .flat_map(|marketplace| marketplace.plugins.iter()) + .map(|plugin| plugin.remote_plugin_id.clone()) + .collect::>(); + for plugin in plugins_manager + .cached_global_remote_discoverable_plugins_for_config(&plugins_input, auth) + { + let is_configured_plugin = configured_plugin_ids.contains(plugin.config_id.as_str()) + || configured_plugin_ids.contains(plugin.remote_plugin_id.as_str()); + let is_fallback_plugin = + TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.config_id.as_str()); + let matches_installed_app = plugin + .app_ids + .iter() + .any(|app_id| installed_app_connector_ids.contains(app_id.as_str())); + let is_disabled = disabled_plugin_ids.contains(plugin.config_id.as_str()) + || disabled_plugin_ids.contains(plugin.remote_plugin_id.as_str()); + if installed_remote_plugin_ids.contains(&plugin.remote_plugin_id) + || plugin.install_policy == PluginInstallPolicy::NotAvailable + || plugin.availability == PluginAvailability::DisabledByAdmin + || is_disabled + || (!is_configured_plugin && !is_fallback_plugin && !matches_installed_app) + { + continue; + } + + discoverable_plugins.push(DiscoverablePluginInfo { + id: plugin.config_id, + name: plugin.name, + description: plugin.description, + has_skills: plugin.has_skills, + mcp_server_names: Vec::new(), + app_connector_ids: plugin.app_ids, + }); + } + } discoverable_plugins.sort_by(|left, right| { left.name .cmp(&right.name) diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs index 21203aea23..992bf93f45 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -1,4 +1,3 @@ -use super::*; use crate::plugins::test_support::load_plugins_config; use crate::plugins::test_support::write_curated_plugin; use crate::plugins::test_support::write_curated_plugin_sha; @@ -7,6 +6,10 @@ use crate::plugins::test_support::write_openai_curated_marketplace; use crate::plugins::test_support::write_plugins_feature_config; use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME; use codex_core_plugins::PluginInstallRequest; +use codex_core_plugins::PluginsManager; +use codex_core_plugins::remote::RemotePluginScope; +use codex_core_plugins::remote::RemotePluginServiceConfig; +use codex_core_plugins::remote::fetch_and_cache_global_remote_plugin_catalog; use codex_core_plugins::startup_sync::curated_plugins_repo_path; use codex_tools::DiscoverablePluginInfo; use codex_utils_absolute_path::AbsolutePathBuf; @@ -17,6 +20,44 @@ use tracing::Level; use tracing_subscriber::fmt::format::FmtSpan; use tracing_test::internal::MockWriter; +async fn list_discoverable_plugins( + config: &crate::config::Config, + loaded_plugin_app_connector_ids: &[String], +) -> anyhow::Result> { + list_discoverable_plugins_with_auth(config, /*auth*/ None, loaded_plugin_app_connector_ids) + .await +} + +async fn list_discoverable_plugins_with_auth( + config: &crate::config::Config, + auth: Option<&codex_login::CodexAuth>, + loaded_plugin_app_connector_ids: &[String], +) -> anyhow::Result> { + let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf()); + list_discoverable_plugins_with_manager_and_auth( + config, + &plugins_manager, + auth, + loaded_plugin_app_connector_ids, + ) + .await +} + +async fn list_discoverable_plugins_with_manager_and_auth( + config: &crate::config::Config, + plugins_manager: &PluginsManager, + auth: Option<&codex_login::CodexAuth>, + loaded_plugin_app_connector_ids: &[String], +) -> anyhow::Result> { + super::list_tool_suggest_discoverable_plugins( + config, + plugins_manager, + auth, + loaded_plugin_app_connector_ids, + ) + .await +} + #[tokio::test] async fn list_tool_suggest_discoverable_plugins_returns_fallback_plugins_without_installed_apps() { let codex_home = tempdir().expect("tempdir should succeed"); @@ -25,9 +66,7 @@ async fn list_tool_suggest_discoverable_plugins_returns_fallback_plugins_without write_plugins_feature_config(codex_home.path()); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!( discoverable_plugins @@ -51,9 +90,7 @@ async fn list_tool_suggest_discoverable_plugins_filters_non_fallback_by_installe install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await; let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!( discoverable_plugins @@ -76,10 +113,9 @@ async fn list_tool_suggest_discoverable_plugins_filters_by_loaded_plugin_apps() write_plugins_feature_config(codex_home.path()); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = - list_tool_suggest_discoverable_plugins(&config, &[hubspot_app_id.to_string()]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[hubspot_app_id.to_string()]) + .await + .unwrap(); assert_eq!( discoverable_plugins @@ -102,9 +138,7 @@ async fn list_tool_suggest_discoverable_plugins_filters_microsoft_by_installed_a install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "teams").await; let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!( discoverable_plugins @@ -119,6 +153,283 @@ async fn list_tool_suggest_discoverable_plugins_filters_microsoft_by_installed_a ); } +#[tokio::test] +async fn list_tool_suggest_discoverable_plugins_includes_cached_remote_global_plugins() { + use codex_login::CodexAuth; + use serde_json::json; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + use wiremock::matchers::query_param; + + let codex_home = tempdir().expect("tempdir should succeed"); + write_file( + &codex_home.path().join(crate::config::CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = true +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/list")) + .and(query_param("scope", "GLOBAL")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [ + { + "id": "plugins~Plugin_remote_github", + "name": "github", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "AVAILABLE", + "release": { + "display_name": "Remote GitHub", + "description": "Remote GitHub long", + "app_ids": ["github"], + "interface": { + "short_description": "Remote GitHub short", + "long_description": null, + "developer_name": null, + "category": null, + "capabilities": [], + "website_url": null, + "privacy_policy_url": null, + "terms_of_service_url": null, + "brand_color": null, + "default_prompt": null, + "composer_icon_url": null, + "logo_url": null, + "screenshot_urls": [] + }, + "skills": [ + { + "name": "github", + "description": "Use GitHub", + "interface": null + } + ] + } + }, + { + "id": "plugins~Plugin_remote_unlisted", + "name": "remote-unlisted", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "AVAILABLE", + "release": { + "display_name": "Remote Unlisted", + "description": "Remote Unlisted long", + "app_ids": [], + "interface": { + "short_description": "Remote Unlisted short", + "long_description": null, + "developer_name": null, + "category": null, + "capabilities": [], + "website_url": null, + "privacy_policy_url": null, + "terms_of_service_url": null, + "brand_color": null, + "default_prompt": null, + "composer_icon_url": null, + "logo_url": null, + "screenshot_urls": [] + }, + "skills": [ + { + "name": "remote-unlisted", + "description": "Use unlisted remote plugin", + "interface": null + } + ] + } + }, + { + "id": "plugins~Plugin_remote_slack_not_available", + "name": "slack", + "scope": "GLOBAL", + "installation_policy": "NOT_AVAILABLE", + "authentication_policy": "ON_USE", + "status": "AVAILABLE", + "release": { + "display_name": "Remote Slack", + "description": "Remote Slack long", + "app_ids": [], + "interface": { + "short_description": "Remote Slack short", + "long_description": null, + "developer_name": null, + "category": null, + "capabilities": [], + "website_url": null, + "privacy_policy_url": null, + "terms_of_service_url": null, + "brand_color": null, + "default_prompt": null, + "composer_icon_url": null, + "logo_url": null, + "screenshot_urls": [] + }, + "skills": [] + } + }, + { + "id": "plugins~Plugin_remote_figma_admin_disabled", + "name": "figma", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "DISABLED_BY_ADMIN", + "release": { + "display_name": "Remote Figma", + "description": "Remote Figma long", + "app_ids": [], + "interface": { + "short_description": "Remote Figma short", + "long_description": null, + "developer_name": null, + "category": null, + "capabilities": [], + "website_url": null, + "privacy_policy_url": null, + "terms_of_service_url": null, + "brand_color": null, + "default_prompt": null, + "composer_icon_url": null, + "logo_url": null, + "screenshot_urls": [] + }, + "skills": [] + } + } + ], + "pagination": { + "next_page_token": null + } + }))) + .expect(1) + .mount(&server) + .await; + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let mut config = load_plugins_config(codex_home.path()).await; + config.chatgpt_base_url = format!("{}/backend-api", server.uri()); + let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf()); + fetch_and_cache_global_remote_plugin_catalog( + codex_home.path(), + &RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }, + Some(&auth), + ) + .await + .expect("remote plugin catalog cache should write"); + + let discoverable_plugins = list_discoverable_plugins_with_manager_and_auth( + &config, + &plugins_manager, + Some(&auth), + &[], + ) + .await + .unwrap(); + assert!( + discoverable_plugins + .iter() + .all(|plugin| plugin.id != "github@openai-curated-remote") + ); + + for scope in ["GLOBAL", "WORKSPACE"] { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", scope)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [], + "pagination": { + "next_page_token": null + } + }))) + .expect(1) + .mount(&server) + .await; + } + plugins_manager + .build_and_cache_remote_installed_plugin_marketplaces( + &config.plugins_config_input(), + Some(&auth), + &[RemotePluginScope::Global], + /*on_effective_plugins_changed*/ None, + ) + .await + .expect("remote installed plugin cache should write"); + + let discoverable_plugins = list_discoverable_plugins_with_manager_and_auth( + &config, + &plugins_manager, + Some(&auth), + &[], + ) + .await + .unwrap(); + assert_eq!( + discoverable_plugins + .iter() + .filter(|plugin| plugin.id.ends_with("@openai-curated-remote")) + .map(|plugin| plugin.id.as_str()) + .collect::>(), + vec!["github@openai-curated-remote"] + ); + let remote_plugins = discoverable_plugins + .into_iter() + .filter(|plugin| plugin.id == "github@openai-curated-remote") + .collect::>(); + + assert_eq!( + remote_plugins, + vec![DiscoverablePluginInfo { + id: "github@openai-curated-remote".to_string(), + name: "Remote GitHub".to_string(), + description: Some("Remote GitHub short".to_string()), + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: vec!["github".to_string()], + }] + ); + + write_file( + &codex_home.path().join(crate::config::CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = true + +[tool_suggest] +disabled_tools = [ + { type = "plugin", id = "github@openai-curated-remote" } +] +"#, + ); + let mut config_with_disabled_remote_plugin = load_plugins_config(codex_home.path()).await; + config_with_disabled_remote_plugin.chatgpt_base_url = config.chatgpt_base_url.clone(); + let discoverable_plugins = list_discoverable_plugins_with_manager_and_auth( + &config_with_disabled_remote_plugin, + &plugins_manager, + Some(&auth), + &[], + ) + .await + .unwrap(); + assert!( + discoverable_plugins + .iter() + .all(|plugin| plugin.id != "github@openai-curated-remote") + ); +} + #[tokio::test] async fn list_tool_suggest_discoverable_plugins_filters_sales_apps_by_marketplace() { let hubspot_app_id = "asdk_app_697acb8e53d88191bf7a79e62012ae14"; @@ -179,9 +490,7 @@ source = "/tmp/{sales_marketplace_name}" install_marketplace_plugin(codex_home.path(), sales_marketplace_root.as_path(), "sales").await; let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!( discoverable_plugins @@ -195,6 +504,55 @@ source = "/tmp/{sales_marketplace_name}" ); } +#[tokio::test] +async fn list_tool_suggest_discoverable_plugins_omits_openai_curated_when_remote_enabled() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + + let bundled_marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME; + let bundled_marketplace_root = codex_home + .path() + .join(format!(".tmp/marketplaces/{bundled_marketplace_name}")); + write_file( + &bundled_marketplace_root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "{bundled_marketplace_name}", + "plugins": [ + {{"name": "chrome", "source": {{"source": "local", "path": "./plugins/chrome"}}}} + ] +}} +"# + ), + ); + write_curated_plugin(&bundled_marketplace_root, "chrome"); + write_file( + &codex_home.path().join(crate::config::CONFIG_TOML_FILE), + &format!( + r#"[features] +plugins = true +remote_plugin = true + +[marketplaces.{bundled_marketplace_name}] +source_type = "git" +source = "/tmp/{bundled_marketplace_name}" +"# + ), + ); + + let config = load_plugins_config(codex_home.path()).await; + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); + + assert_eq!( + discoverable_plugins + .into_iter() + .map(|plugin| plugin.id) + .collect::>(), + vec!["chrome@openai-bundled".to_string()] + ); +} + #[tokio::test] async fn list_tool_suggest_discoverable_plugins_deduplicates_configured_marketplace_plugin() { let codex_home = tempdir().expect("tempdir should succeed"); @@ -234,9 +592,7 @@ discoverables = [{{ type = "plugin", id = "{plugin_id}" }}] ); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!(discoverable_plugins.len(), 1); assert_eq!(discoverable_plugins[0].id, plugin_id.as_str()); @@ -278,9 +634,7 @@ source = "/tmp/{marketplace_name}" install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await; let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!(discoverable_plugins.len(), 1); assert_eq!(discoverable_plugins[0].id, "slack@openai-curated"); @@ -299,9 +653,7 @@ plugins = false ); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!(discoverable_plugins, Vec::::new()); } @@ -322,9 +674,7 @@ async fn list_tool_suggest_discoverable_plugins_normalizes_description() { install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await; let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!( discoverable_plugins, @@ -359,7 +709,7 @@ async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins( .expect("plugin should install"); let refreshed_config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config, &[]) + let discoverable_plugins = list_discoverable_plugins(&refreshed_config, &[]) .await .unwrap(); @@ -384,9 +734,7 @@ disabled_tools = [ ); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!(discoverable_plugins, Vec::::new()); } @@ -435,9 +783,7 @@ async fn list_tool_suggest_discoverable_plugins_omits_not_available_curated_plug install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await; let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!( discoverable_plugins @@ -464,9 +810,7 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }] ); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!( discoverable_plugins, @@ -519,9 +863,7 @@ async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_ .finish(); let _guard = tracing::subscriber::set_default(subscriber); - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!( discoverable_plugins diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index f808134f99..d1e9e59f70 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1080,6 +1080,7 @@ pub(crate) async fn built_tools( if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() { match connectors::list_tool_suggest_discoverable_tools_with_auth( &turn_context.config, + sess.services.plugins_manager.as_ref(), auth.as_ref(), accessible_connectors.as_slice(), &loaded_plugin_app_connector_ids, 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 c134eae079..51032b27c5 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -2,6 +2,7 @@ use std::collections::HashSet; use codex_app_server_protocol::AppInfo; use codex_config::types::ToolSuggestDisabledTool; +use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; @@ -273,6 +274,10 @@ async fn verify_request_plugin_install_completed( verified_connector_install_completed(connector.id.as_str(), &accessible_connectors) }), DiscoverableTool::Plugin(plugin) => { + if is_remote_plugin_install_suggestion(&plugin.id) { + return true; + } + session.reload_user_config_layer().await; let config = session.get_config().await; let completed = verified_plugin_install_completed( @@ -293,6 +298,12 @@ async fn verify_request_plugin_install_completed( } } +fn is_remote_plugin_install_suggestion(plugin_id: &str) -> bool { + plugin_id + .rsplit_once('@') + .is_some_and(|(_, marketplace_name)| marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME) +} + #[expect( clippy::await_holding_invalid_type, reason = "connector cache refresh reads through the session-owned manager guard" diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs b/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs index 1a8caf0dce..830e9d408e 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs @@ -57,6 +57,17 @@ async fn verified_plugin_install_completed_requires_installed_plugin() { )); } +#[test] +fn remote_plugin_install_suggestions_skip_core_installed_verification() { + assert!(is_remote_plugin_install_suggestion( + "snowflake@openai-curated-remote" + )); + assert!(!is_remote_plugin_install_suggestion( + "snowflake@openai-curated" + )); + assert!(!is_remote_plugin_install_suggestion("Plugin_123")); +} + #[test] fn request_plugin_install_response_persists_only_decline_always_mode() { assert!(request_plugin_install_response_requests_persistent_disable( From 07f04cc3c7d1ddb4289469fdfa4740be0d0a82d2 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Mon, 1 Jun 2026 23:25:37 -0700 Subject: [PATCH 07/59] [codex] Move plugin discoverable logic into core-plugins (#25783) ## Summary - Move plugin discoverable recommendation filtering from `codex-core` into `codex-core-plugins` behind `ToolSuggestPluginDiscoveryInput`. - Keep `codex-core` as a thin adapter from `Config` to the core-plugins API and back to `DiscoverablePluginInfo`. - Keep the existing discoverable allowlist private to the core-plugins implementation. ## Validation - `just fmt` - `just test -p codex-core list_tool_suggest_discoverable_plugins` - `git diff --check` - Read-only subagent review: no findings --- codex-rs/core-plugins/src/discoverable.rs | 218 ++++++++++++++++++++++ codex-rs/core-plugins/src/lib.rs | 36 +--- codex-rs/core/src/plugins/discoverable.rs | 202 ++++---------------- 3 files changed, 259 insertions(+), 197 deletions(-) create mode 100644 codex-rs/core-plugins/src/discoverable.rs diff --git a/codex-rs/core-plugins/src/discoverable.rs b/codex-rs/core-plugins/src/discoverable.rs new file mode 100644 index 0000000000..ccf093d816 --- /dev/null +++ b/codex-rs/core-plugins/src/discoverable.rs @@ -0,0 +1,218 @@ +use anyhow::Context; +use codex_app_server_protocol::PluginAvailability; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_login::CodexAuth; +use codex_plugin::PluginCapabilitySummary; +use std::collections::HashSet; +use tracing::warn; + +use crate::OPENAI_BUNDLED_MARKETPLACE_NAME; +use crate::OPENAI_CURATED_MARKETPLACE_NAME; +use crate::PluginsConfigInput; +use crate::PluginsManager; +use crate::marketplace::MarketplacePluginInstallPolicy; +use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; +use crate::remote::RemotePluginScope; + +const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[ + "github@openai-curated", + "notion@openai-curated", + "slack@openai-curated", + "gmail@openai-curated", + "google-calendar@openai-curated", + "google-drive@openai-curated", + "openai-developers@openai-curated", + "canva@openai-curated", + "teams@openai-curated", + "sharepoint@openai-curated", + "outlook-email@openai-curated", + "outlook-calendar@openai-curated", + "linear@openai-curated", + "figma@openai-curated", + "github@openai-curated-remote", + "notion@openai-curated-remote", + "slack@openai-curated-remote", + "gmail@openai-curated-remote", + "google-calendar@openai-curated-remote", + "google-drive@openai-curated-remote", + "openai-developers@openai-curated-remote", + "canva@openai-curated-remote", + "teams@openai-curated-remote", + "sharepoint@openai-curated-remote", + "outlook-email@openai-curated-remote", + "outlook-calendar@openai-curated-remote", + "linear@openai-curated-remote", + "figma@openai-curated-remote", + "chrome@openai-bundled", + "computer-use@openai-bundled", +]; + +const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[ + OPENAI_BUNDLED_MARKETPLACE_NAME, + OPENAI_CURATED_MARKETPLACE_NAME, + REMOTE_GLOBAL_MARKETPLACE_NAME, +]; + +#[derive(Debug, Clone)] +pub struct ToolSuggestPluginDiscoveryInput { + pub plugins: PluginsConfigInput, + pub configured_plugin_ids: HashSet, + pub disabled_plugin_ids: HashSet, + pub loaded_plugin_app_connector_ids: HashSet, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ToolSuggestDiscoverablePlugin { + pub id: String, + pub name: String, + pub description: Option, + pub has_skills: bool, + pub mcp_server_names: Vec, + pub app_connector_ids: Vec, +} + +impl PluginsManager { + pub async fn list_tool_suggest_discoverable_plugins( + &self, + input: &ToolSuggestPluginDiscoveryInput, + auth: Option<&CodexAuth>, + ) -> anyhow::Result> { + if !input.plugins.plugins_enabled { + return Ok(Vec::new()); + } + + let marketplaces = self + .list_marketplaces_for_config(&input.plugins, &[]) + .context("failed to list plugin marketplaces for tool suggestions")? + .marketplaces; + let mut installed_app_connector_ids = self + .plugins_for_config(&input.plugins) + .await + .capability_summaries() + .iter() + .flat_map(|plugin| plugin.app_connector_ids.iter()) + .map(|connector_id| connector_id.0.clone()) + .collect::>(); + installed_app_connector_ids.extend(input.loaded_plugin_app_connector_ids.iter().cloned()); + let remote_installed_marketplaces = if input.plugins.remote_plugin_enabled { + self.build_remote_installed_plugin_marketplaces_from_cache(&[RemotePluginScope::Global]) + } else { + None + }; + + let mut discoverable_plugins = Vec::::new(); + for marketplace in marketplaces { + let marketplace_name = marketplace.name; + if input.plugins.remote_plugin_enabled + && marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME + { + continue; + } + let is_allowlisted_marketplace = TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST + .contains(&marketplace_name.as_str()); + + for plugin in marketplace.plugins { + let is_configured_plugin = input.configured_plugin_ids.contains(plugin.id.as_str()); + let is_fallback_plugin = + TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str()); + if plugin.installed + || plugin.policy.installation == MarketplacePluginInstallPolicy::NotAvailable + || input.disabled_plugin_ids.contains(plugin.id.as_str()) + || (!is_allowlisted_marketplace && !is_configured_plugin) + { + continue; + } + + let plugin_id = plugin.id.clone(); + + match self + .read_plugin_detail_for_marketplace_plugin( + &input.plugins, + &marketplace_name, + plugin, + ) + .await + { + Ok(plugin) => { + let plugin: PluginCapabilitySummary = plugin.into(); + let matches_installed_app = + plugin.app_connector_ids.iter().any(|connector_id| { + installed_app_connector_ids.contains(connector_id.0.as_str()) + }); + if !is_configured_plugin && !is_fallback_plugin && !matches_installed_app { + continue; + } + + discoverable_plugins.push(ToolSuggestDiscoverablePlugin { + id: plugin.config_name, + name: plugin.display_name, + description: plugin.description, + has_skills: plugin.has_skills, + mcp_server_names: plugin.mcp_server_names, + app_connector_ids: plugin + .app_connector_ids + .into_iter() + .map(|connector_id| connector_id.0) + .collect(), + }); + } + Err(err) => { + warn!("failed to load discoverable plugin suggestion {plugin_id}: {err:#}") + } + } + } + } + if let Some(remote_installed_marketplaces) = remote_installed_marketplaces.as_ref() { + let installed_remote_plugin_ids = remote_installed_marketplaces + .iter() + .flat_map(|marketplace| marketplace.plugins.iter()) + .map(|plugin| plugin.remote_plugin_id.clone()) + .collect::>(); + for plugin in + self.cached_global_remote_discoverable_plugins_for_config(&input.plugins, auth) + { + let is_configured_plugin = input + .configured_plugin_ids + .contains(plugin.config_id.as_str()) + || input + .configured_plugin_ids + .contains(plugin.remote_plugin_id.as_str()); + let is_fallback_plugin = + TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.config_id.as_str()); + let matches_installed_app = plugin + .app_ids + .iter() + .any(|app_id| installed_app_connector_ids.contains(app_id.as_str())); + let is_disabled = input + .disabled_plugin_ids + .contains(plugin.config_id.as_str()) + || input + .disabled_plugin_ids + .contains(plugin.remote_plugin_id.as_str()); + if installed_remote_plugin_ids.contains(&plugin.remote_plugin_id) + || plugin.install_policy == PluginInstallPolicy::NotAvailable + || plugin.availability == PluginAvailability::DisabledByAdmin + || is_disabled + || (!is_configured_plugin && !is_fallback_plugin && !matches_installed_app) + { + continue; + } + + discoverable_plugins.push(ToolSuggestDiscoverablePlugin { + id: plugin.config_id, + name: plugin.name, + description: plugin.description, + has_skills: plugin.has_skills, + mcp_server_names: Vec::new(), + app_connector_ids: plugin.app_ids, + }); + } + } + discoverable_plugins.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(discoverable_plugins) + } +} diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 60cb50a885..4728a8c6f8 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -1,3 +1,4 @@ +mod discoverable; pub mod installed_marketplaces; pub mod loader; mod manager; @@ -20,42 +21,11 @@ pub mod toggles; pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated"; pub const OPENAI_BUNDLED_MARKETPLACE_NAME: &str = "openai-bundled"; -pub const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[ - "github@openai-curated", - "notion@openai-curated", - "slack@openai-curated", - "gmail@openai-curated", - "google-calendar@openai-curated", - "google-drive@openai-curated", - "openai-developers@openai-curated", - "canva@openai-curated", - "teams@openai-curated", - "sharepoint@openai-curated", - "outlook-email@openai-curated", - "outlook-calendar@openai-curated", - "linear@openai-curated", - "figma@openai-curated", - "github@openai-curated-remote", - "notion@openai-curated-remote", - "slack@openai-curated-remote", - "gmail@openai-curated-remote", - "google-calendar@openai-curated-remote", - "google-drive@openai-curated-remote", - "openai-developers@openai-curated-remote", - "canva@openai-curated-remote", - "teams@openai-curated-remote", - "sharepoint@openai-curated-remote", - "outlook-email@openai-curated-remote", - "outlook-calendar@openai-curated-remote", - "linear@openai-curated-remote", - "figma@openai-curated-remote", - "chrome@openai-bundled", - "computer-use@openai-bundled", -]; - pub type LoadedPlugin = codex_plugin::LoadedPlugin; pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome; +pub use discoverable::ToolSuggestDiscoverablePlugin; +pub use discoverable::ToolSuggestPluginDiscoveryInput; pub use manager::ConfiguredMarketplace; pub use manager::ConfiguredMarketplaceListOutcome; pub use manager::ConfiguredMarketplacePlugin; diff --git a/codex-rs/core/src/plugins/discoverable.rs b/codex-rs/core/src/plugins/discoverable.rs index 933b30dc1d..8ddec7056e 100644 --- a/codex-rs/core/src/plugins/discoverable.rs +++ b/codex-rs/core/src/plugins/discoverable.rs @@ -1,28 +1,10 @@ -use anyhow::Context; -use std::collections::HashSet; -use tracing::warn; - -use super::PluginCapabilitySummary; use crate::config::Config; -use codex_app_server_protocol::PluginAvailability; -use codex_app_server_protocol::PluginInstallPolicy; use codex_config::types::ToolSuggestDiscoverableType; -use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME; -use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core_plugins::PluginsManager; -use codex_core_plugins::TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST; -use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy; -use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; -use codex_core_plugins::remote::RemotePluginScope; -use codex_features::Feature; +use codex_core_plugins::ToolSuggestPluginDiscoveryInput; use codex_login::CodexAuth; use codex_tools::DiscoverablePluginInfo; - -const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[ - OPENAI_BUNDLED_MARKETPLACE_NAME, - OPENAI_CURATED_MARKETPLACE_NAME, - REMOTE_GLOBAL_MARKETPLACE_NAME, -]; +use std::collections::HashSet; pub(crate) async fn list_tool_suggest_discoverable_plugins( config: &Config, @@ -30,151 +12,43 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins( auth: Option<&CodexAuth>, loaded_plugin_app_connector_ids: &[String], ) -> anyhow::Result> { - if !config.features.enabled(Feature::Plugins) { - return Ok(Vec::new()); - } - - let plugins_input = config.plugins_config_input(); - let configured_plugin_ids = config - .tool_suggest - .discoverables - .iter() - .filter(|discoverable| discoverable.kind == ToolSuggestDiscoverableType::Plugin) - .map(|discoverable| discoverable.id.as_str()) - .collect::>(); - let disabled_plugin_ids = config - .tool_suggest - .disabled_tools - .iter() - .filter(|disabled_tool| disabled_tool.kind == ToolSuggestDiscoverableType::Plugin) - .map(|disabled_tool| disabled_tool.id.as_str()) - .collect::>(); - let marketplaces = plugins_manager - .list_marketplaces_for_config(&plugins_input, &[]) - .context("failed to list plugin marketplaces for tool suggestions")? - .marketplaces; - let mut installed_app_connector_ids = plugins_manager - .plugins_for_config(&plugins_input) - .await - .capability_summaries() - .iter() - .flat_map(|plugin| plugin.app_connector_ids.iter()) - .map(|connector_id| connector_id.0.clone()) - .collect::>(); - installed_app_connector_ids.extend(loaded_plugin_app_connector_ids.iter().cloned()); - let remote_installed_marketplaces = if plugins_input.remote_plugin_enabled { - plugins_manager - .build_remote_installed_plugin_marketplaces_from_cache(&[RemotePluginScope::Global]) - } else { - None - }; - - let mut discoverable_plugins = Vec::::new(); - for marketplace in marketplaces { - let marketplace_name = marketplace.name; - if plugins_input.remote_plugin_enabled - && marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME - { - continue; - } - let is_allowlisted_marketplace = - TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST.contains(&marketplace_name.as_str()); - - for plugin in marketplace.plugins { - let is_configured_plugin = configured_plugin_ids.contains(plugin.id.as_str()); - let is_fallback_plugin = - TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str()); - if plugin.installed - || plugin.policy.installation == MarketplacePluginInstallPolicy::NotAvailable - || disabled_plugin_ids.contains(plugin.id.as_str()) - || (!is_allowlisted_marketplace && !is_configured_plugin) - { - continue; - } - - let plugin_id = plugin.id.clone(); - - match plugins_manager - .read_plugin_detail_for_marketplace_plugin( - &plugins_input, - &marketplace_name, - plugin, - ) - .await - { - Ok(plugin) => { - let plugin: PluginCapabilitySummary = plugin.into(); - let matches_installed_app = - plugin.app_connector_ids.iter().any(|connector_id| { - installed_app_connector_ids.contains(connector_id.0.as_str()) - }); - if !is_configured_plugin && !is_fallback_plugin && !matches_installed_app { - continue; - } - - discoverable_plugins.push(DiscoverablePluginInfo { - id: plugin.config_name, - name: plugin.display_name, - description: plugin.description, - has_skills: plugin.has_skills, - mcp_server_names: plugin.mcp_server_names, - app_connector_ids: plugin - .app_connector_ids - .into_iter() - .map(|connector_id| connector_id.0) - .collect(), - }); - } - Err(err) => { - warn!("failed to load discoverable plugin suggestion {plugin_id}: {err:#}") - } - } - } - } - if let Some(remote_installed_marketplaces) = remote_installed_marketplaces.as_ref() { - let installed_remote_plugin_ids = remote_installed_marketplaces + let input = ToolSuggestPluginDiscoveryInput { + plugins: config.plugins_config_input(), + configured_plugin_ids: config + .tool_suggest + .discoverables .iter() - .flat_map(|marketplace| marketplace.plugins.iter()) - .map(|plugin| plugin.remote_plugin_id.clone()) - .collect::>(); - for plugin in plugins_manager - .cached_global_remote_discoverable_plugins_for_config(&plugins_input, auth) - { - let is_configured_plugin = configured_plugin_ids.contains(plugin.config_id.as_str()) - || configured_plugin_ids.contains(plugin.remote_plugin_id.as_str()); - let is_fallback_plugin = - TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.config_id.as_str()); - let matches_installed_app = plugin - .app_ids - .iter() - .any(|app_id| installed_app_connector_ids.contains(app_id.as_str())); - let is_disabled = disabled_plugin_ids.contains(plugin.config_id.as_str()) - || disabled_plugin_ids.contains(plugin.remote_plugin_id.as_str()); - if installed_remote_plugin_ids.contains(&plugin.remote_plugin_id) - || plugin.install_policy == PluginInstallPolicy::NotAvailable - || plugin.availability == PluginAvailability::DisabledByAdmin - || is_disabled - || (!is_configured_plugin && !is_fallback_plugin && !matches_installed_app) - { - continue; - } - - discoverable_plugins.push(DiscoverablePluginInfo { - id: plugin.config_id, - name: plugin.name, - description: plugin.description, - has_skills: plugin.has_skills, - mcp_server_names: Vec::new(), - app_connector_ids: plugin.app_ids, - }); - } - } - discoverable_plugins.sort_by(|left, right| { - left.name - .cmp(&right.name) - .then_with(|| left.id.cmp(&right.id)) - }); - Ok(discoverable_plugins) + .filter(|discoverable| discoverable.kind == ToolSuggestDiscoverableType::Plugin) + .map(|discoverable| discoverable.id.clone()) + .collect::>(), + disabled_plugin_ids: config + .tool_suggest + .disabled_tools + .iter() + .filter(|disabled_tool| disabled_tool.kind == ToolSuggestDiscoverableType::Plugin) + .map(|disabled_tool| disabled_tool.id.clone()) + .collect::>(), + loaded_plugin_app_connector_ids: loaded_plugin_app_connector_ids + .iter() + .cloned() + .collect::>(), + }; + plugins_manager + .list_tool_suggest_discoverable_plugins(&input, auth) + .await + .map(|plugins| { + plugins + .into_iter() + .map(|plugin| DiscoverablePluginInfo { + id: plugin.id, + name: plugin.name, + description: plugin.description, + has_skills: plugin.has_skills, + mcp_server_names: plugin.mcp_server_names, + app_connector_ids: plugin.app_connector_ids, + }) + .collect() + }) } #[cfg(test)] From 67b805fc111706aca5b32d465c94d95659bab6aa Mon Sep 17 00:00:00 2001 From: xl-openai Date: Mon, 1 Jun 2026 23:33:02 -0700 Subject: [PATCH 08/59] [codex] Validate plugin skill base names (#25782) ## Summary - Validate skill base name length before plugin namespacing. - Bound the composed `plugin:skill` qualified name to 128 characters. - Keep plugin skill runtime names in the existing `plugin:skill` form. - Add regression tests for the max qualified-name boundary and rejection path. ## Root Cause Plugin skills are represented as `plugin_name:skill_name`, but the loader previously applied the 64-character skill name limit after adding the plugin namespace. Moving that check to the base name fixes valid plugin skills with longer namespaces, and the separate 128-character qualified-name limit keeps model-visible skill names bounded. ## Validation - `just fmt` - `just test -p codex-core-skills plugin_skill_name_length_limit` - `git diff --check` --- codex-rs/core-skills/src/loader.rs | 4 +- codex-rs/core-skills/src/loader_tests.rs | 78 ++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index d8b05c604f..7dac256010 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -110,6 +110,7 @@ const SKILLS_METADATA_DIR: &str = "agents"; const SKILLS_METADATA_FILENAME: &str = "openai.yaml"; const SKILLS_DIR_NAME: &str = "skills"; const MAX_NAME_LEN: usize = 64; +const MAX_QUALIFIED_NAME_LEN: usize = 128; const MAX_DESCRIPTION_LEN: usize = 1024; const MAX_SHORT_DESCRIPTION_LEN: usize = MAX_DESCRIPTION_LEN; const MAX_DEFAULT_PROMPT_LEN: usize = MAX_DESCRIPTION_LEN; @@ -660,7 +661,8 @@ async fn parse_skill_file( policy, } = load_skill_metadata(fs, path, plugin_root).await; - validate_len(&name, MAX_NAME_LEN, "name")?; + validate_len(&base_name, MAX_NAME_LEN, "name")?; + validate_len(&name, MAX_QUALIFIED_NAME_LEN, "qualified name")?; validate_len(&description, MAX_DESCRIPTION_LEN, "description")?; if let Some(short_description) = short_description.as_deref() { validate_len( diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index 69e357d93d..f6c71b2be5 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -1301,6 +1301,84 @@ async fn namespaces_plugin_skills_using_plugin_name() { ); } +#[tokio::test] +async fn plugin_skill_name_length_limit_allows_max_qualified_name() { + let root = tempfile::tempdir().expect("tempdir"); + let plugin_name = "p".repeat(MAX_NAME_LEN - 1); + let skill_name = "s".repeat(MAX_NAME_LEN); + let plugin_root = root.path().join("plugins").join(&plugin_name); + let frontmatter = format!("name: {skill_name}\ndescription: search sample data"); + let skill_path = write_raw_skill_at(&plugin_root.join("skills"), "sample-search", &frontmatter); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + ) + .unwrap(); + + let outcome = load_skills_from_roots([SkillRoot { + path: plugin_root.join("skills").abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("sample@test".to_string()), + plugin_root: Some(plugin_root.abs()), + }]) + .await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: format!("{plugin_name}:{skill_name}"), + description: "search sample data".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: Some("sample@test".to_string()), + }] + ); +} + +#[tokio::test] +async fn plugin_skill_name_length_limit_rejects_overlong_qualified_name() { + let root = tempfile::tempdir().expect("tempdir"); + let plugin_name = "p".repeat(MAX_NAME_LEN); + let skill_name = "s".repeat(MAX_NAME_LEN); + let plugin_root = root.path().join("plugins").join(&plugin_name); + let frontmatter = format!("name: {skill_name}\ndescription: search sample data"); + write_raw_skill_at(&plugin_root.join("skills"), "sample-search", &frontmatter); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + ) + .unwrap(); + + let outcome = load_skills_from_roots([SkillRoot { + path: plugin_root.join("skills").abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("sample@test".to_string()), + plugin_root: Some(plugin_root.abs()), + }]) + .await; + + assert_eq!(outcome.skills, Vec::new()); + assert_eq!(outcome.errors.len(), 1); + assert!( + outcome.errors[0].message.contains("invalid qualified name"), + "expected qualified name length error, got: {:?}", + outcome.errors + ); +} + #[tokio::test] async fn loads_short_description_from_metadata() { let codex_home = tempfile::tempdir().expect("tempdir"); From 45912a6dc69ed04f1e7ef8ec3ecb4c9dc0860b6d Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 2 Jun 2026 11:32:36 +0200 Subject: [PATCH 09/59] feat: reuse compressed rollout search snippets (#25814) ## Summary - teach rollout search to return precomputed snippets for compressed rollouts - reuse those snippets in local thread search instead of reopening matching compressed files - keep the no-`rg` fallback single-pass and add regression coverage for the compressed path ## Why `thread/search` currently decodes matching compressed rollouts twice: once to discover the matching path and again to extract the snippet shown in results. That defeats a meaningful part of the compressed-read optimization work. ## Impact Compressed rollout hits now pay one decode pass on the search path while plain `.jsonl` hits keep the existing ripgrep-driven flow. ## Validation - `just test -p codex-rollout` - `just test -p codex-thread-store` - `just fix -p codex-rollout` - `just fix -p codex-thread-store` - `just fmt` --- codex-rs/rollout/src/compression_tests.rs | 26 +++++++ codex-rs/rollout/src/lib.rs | 1 + codex-rs/rollout/src/search.rs | 75 ++++++++++++++----- .../thread-store/src/local/search_threads.rs | 23 +++--- 4 files changed, 93 insertions(+), 32 deletions(-) diff --git a/codex-rs/rollout/src/compression_tests.rs b/codex-rs/rollout/src/compression_tests.rs index 8eb2380536..52094c2b8a 100644 --- a/codex-rs/rollout/src/compression_tests.rs +++ b/codex-rs/rollout/src/compression_tests.rs @@ -23,6 +23,7 @@ use crate::RolloutConfig; use crate::RolloutRecorder; use crate::RolloutRecorderParams; use crate::append_rollout_item_to_path; +use crate::search_rollout_matches; #[tokio::test] async fn load_rollout_items_reads_compressed_rollout() -> anyhow::Result<()> { @@ -104,6 +105,31 @@ async fn append_rollout_item_materializes_compressed_rollout() -> anyhow::Result Ok(()) } +#[tokio::test] +async fn search_rollout_matches_returns_compressed_snippet() -> anyhow::Result<()> { + let home = TempDir::new()?; + let uuid = Uuid::from_u128(15); + let thread_id = ThreadId::from_string(&uuid.to_string())?; + let rollout_path = rollout_path(home.path(), "2025-01-03T12-00-00", uuid); + write_rollout(&rollout_path, thread_id, "targeted search term")?; + compress_now(&rollout_path)?; + let compressed_path = compressed_rollout_path(&rollout_path); + + let matches = search_rollout_matches( + std::path::Path::new("missing-rg-for-test"), + home.path(), + /*archived*/ false, + "search term", + ) + .await?; + + assert_eq!( + matches.get(compressed_path.as_path()), + Some(&Some("targeted search term".to_string())) + ); + Ok(()) +} + #[tokio::test] async fn worker_compresses_old_archived_rollouts_only() -> anyhow::Result<()> { let home = TempDir::new()?; diff --git a/codex-rs/rollout/src/lib.rs b/codex-rs/rollout/src/lib.rs index ec9fc27f34..9e6db0fb8d 100644 --- a/codex-rs/rollout/src/lib.rs +++ b/codex-rs/rollout/src/lib.rs @@ -67,6 +67,7 @@ pub use recorder::RolloutRecorder; pub use recorder::RolloutRecorderParams; pub use recorder::append_rollout_item_to_path; pub use search::first_rollout_content_match_snippet; +pub use search::search_rollout_matches; pub use search::search_rollout_paths; pub use session_index::append_thread_name; pub use session_index::find_thread_meta_by_name_str; diff --git a/codex-rs/rollout/src/search.rs b/codex-rs/rollout/src/search.rs index 2270913548..705d24e7b4 100644 --- a/codex-rs/rollout/src/search.rs +++ b/codex-rs/rollout/src/search.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::collections::HashSet; use std::io; use std::path::Path; @@ -20,21 +21,42 @@ use super::compression; const MATCH_CONTEXT_BEFORE_CHARS: usize = 48; const MATCH_CONTEXT_AFTER_CHARS: usize = 96; +pub type RolloutSearchMatches = HashMap>; + pub async fn search_rollout_paths( rg_command: &Path, codex_home: &Path, archived: bool, search_term: &str, ) -> io::Result> { + Ok( + search_rollout_matches(rg_command, codex_home, archived, search_term) + .await? + .into_keys() + .collect(), + ) +} + +pub async fn search_rollout_matches( + rg_command: &Path, + codex_home: &Path, + archived: bool, + search_term: &str, +) -> io::Result { let root = codex_home.join(if archived { ARCHIVED_SESSIONS_SUBDIR } else { SESSIONS_SUBDIR }); - let search_term = json_escaped_search_term(search_term)?; - let mut matches = - ripgrep_rollout_paths(rg_command, root.as_path(), search_term.as_str()).await?; - matches.extend(scan_compressed_rollout_paths(root.as_path(), search_term.as_str()).await?); + let json_search_term = json_escaped_search_term(search_term)?; + let Some(plain_matches) = + ripgrep_rollout_paths(rg_command, root.as_path(), json_search_term.as_str()).await? + else { + return scan_rollout_matches(root.as_path(), json_search_term.as_str(), search_term).await; + }; + let mut matches: RolloutSearchMatches = + plain_matches.into_iter().map(|path| (path, None)).collect(); + matches.extend(scan_compressed_rollout_matches(root.as_path(), search_term).await?); Ok(matches) } @@ -42,9 +64,9 @@ async fn ripgrep_rollout_paths( rg_command: &Path, root: &Path, search_term: &str, -) -> io::Result> { +) -> io::Result>> { if !tokio::fs::try_exists(root).await.unwrap_or(false) { - return Ok(HashSet::new()); + return Ok(Some(HashSet::new())); } let output = match Command::new(rg_command) @@ -62,13 +84,13 @@ async fn ripgrep_rollout_paths( { Ok(output) => output, Err(err) if err.kind() == io::ErrorKind::NotFound => { - return scan_rollout_paths(root, search_term).await; + return Ok(None); } Err(err) => return Err(err), }; if !output.status.success() { if output.status.code() == Some(1) && output.stderr.is_empty() { - return Ok(HashSet::new()); + return Ok(Some(HashSet::new())); } return Err(io::Error::other(format!( @@ -88,13 +110,17 @@ async fn ripgrep_rollout_paths( matches.insert(path); } - Ok(matches) + Ok(Some(matches)) } -async fn scan_rollout_paths(root: &Path, search_term: &str) -> io::Result> { - let mut matches = HashSet::new(); +async fn scan_rollout_matches( + root: &Path, + json_search_term: &str, + search_term: &str, +) -> io::Result { + let mut matches = HashMap::new(); let mut dirs = vec![root.to_path_buf()]; - let search_term = case_insensitive_literal_regex(search_term)?; + let json_search_term = case_insensitive_literal_regex(json_search_term)?; while let Some(dir) = dirs.pop() { let mut entries = match tokio::fs::read_dir(dir).await { @@ -115,8 +141,16 @@ async fn scan_rollout_paths(root: &Path, search_term: &str) -> io::Result io::Result> { - let mut matches = HashSet::new(); +) -> io::Result { + let mut matches = HashMap::new(); let mut dirs = vec![root.to_path_buf()]; - let search_term = case_insensitive_literal_regex(search_term)?; while let Some(dir) = dirs.pop() { let mut entries = match tokio::fs::read_dir(dir).await { @@ -181,8 +214,10 @@ async fn scan_compressed_rollout_paths( if !rollout_file.is_compressed() { continue; } - if rollout_contains(rollout_file.path(), &search_term).await? { - matches.insert(rollout_file.into_path()); + if let Some(snippet) = + first_rollout_content_match_snippet(rollout_file.path(), search_term).await? + { + matches.insert(rollout_file.into_path(), Some(snippet)); } } } diff --git a/codex-rs/thread-store/src/local/search_threads.rs b/codex-rs/thread-store/src/local/search_threads.rs index ed9ad3a0a2..20f5da8243 100644 --- a/codex-rs/thread-store/src/local/search_threads.rs +++ b/codex-rs/thread-store/src/local/search_threads.rs @@ -7,7 +7,7 @@ use codex_rollout::RolloutConfig; use codex_rollout::find_thread_names_by_ids; use codex_rollout::first_rollout_content_match_snippet; use codex_rollout::parse_cursor; -use codex_rollout::search_rollout_paths; +use codex_rollout::search_rollout_matches; use super::LocalThreadStore; use super::helpers::distinct_thread_metadata_title; @@ -64,7 +64,7 @@ pub(super) async fn search_threads( generate_memories: false, }; let rg_command = InstallContext::current().rg_command(); - let matching_paths = search_rollout_paths( + let matching_rollouts = search_rollout_matches( rg_command.as_path(), store.config.codex_home.as_path(), params.archived, @@ -74,7 +74,7 @@ pub(super) async fn search_threads( .map_err(|err| ThreadStoreError::Internal { message: format!("failed to search rollout contents: {err}"), })?; - if matching_paths.is_empty() { + if matching_rollouts.is_empty() { return Ok(ThreadSearchPage { items: Vec::new(), next_cursor: None, @@ -95,7 +95,7 @@ pub(super) async fn search_threads( search_term: None, use_state_db_only: state_db.is_some(), }; - let mut remaining_paths = matching_paths; + let mut remaining_rollouts = matching_rollouts; loop { let page = list_rollout_threads( @@ -109,16 +109,15 @@ pub(super) async fn search_threads( ) .await?; for item in page.items { - if !remaining_paths.remove(item.path.as_path()) { - continue; - } - let Some(snippet) = - first_rollout_content_match_snippet(item.path.as_path(), search_term) + let Some(snippet) = (match remaining_rollouts.remove(item.path.as_path()) { + Some(Some(snippet)) => Some(snippet), + Some(None) => first_rollout_content_match_snippet(item.path.as_path(), search_term) .await .map_err(|err| ThreadStoreError::Internal { message: format!("failed to read rollout search match: {err}"), - })? - else { + })?, + None => None, + }) else { continue; }; matching_items.push(ThreadSearchItem { item, snippet }); @@ -128,7 +127,7 @@ pub(super) async fn search_threads( } page_cursor = page.next_cursor; if matching_items.len() > params.page_size - || remaining_paths.is_empty() + || remaining_rollouts.is_empty() || page_cursor.is_none() { break; From 3f1fb7ed8b641542add19bb841e4e4be5651693e Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 2 Jun 2026 12:10:14 +0200 Subject: [PATCH 10/59] Add multi-agent runtime metadata types (#25720) Stack split from #25708. Original PR intentionally left open. This first PR adds the multi-agent runtime metadata types and catalog plumbing used by the rest of the stack. --- .../app-server/tests/common/models_cache.rs | 1 + codex-rs/app-server/tests/common/rollout.rs | 2 + .../app-server/tests/suite/v2/thread_fork.rs | 7 ++ .../tests/suite/v2/thread_resume.rs | 15 +++- .../codex-api/tests/models_integration.rs | 1 + .../src/context/environment_context_tests.rs | 1 + .../core/src/context_manager/history_tests.rs | 1 + .../core/src/personality_migration_tests.rs | 1 + .../session/rollout_reconstruction_tests.rs | 8 ++ codex-rs/core/src/session/tests.rs | 1 + codex-rs/core/src/session/turn_context.rs | 1 + codex-rs/core/tests/suite/auto_review.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 2 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + codex-rs/core/tests/suite/personality.rs | 2 + .../core/tests/suite/personality_migration.rs | 2 + codex-rs/core/tests/suite/remote_models.rs | 3 + codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/core/tests/suite/rmcp_client.rs | 1 + .../tests/suite/spawn_agent_description.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 1 + codex-rs/models-manager/src/model_info.rs | 1 + codex-rs/protocol/src/openai_models.rs | 24 ++++++ codex-rs/protocol/src/protocol.rs | 84 +++++++++++++++++++ codex-rs/rollout/src/compression_tests.rs | 1 + codex-rs/rollout/src/metadata_tests.rs | 4 + codex-rs/rollout/src/recorder.rs | 1 + codex-rs/rollout/src/recorder_tests.rs | 2 + codex-rs/rollout/src/session_index_tests.rs | 1 + codex-rs/rollout/src/state_db_tests.rs | 1 + codex-rs/rollout/src/tests.rs | 1 + codex-rs/state/src/extract.rs | 6 ++ codex-rs/state/src/runtime/threads.rs | 2 + codex-rs/tools/src/tool_config_tests.rs | 1 + 35 files changed, 181 insertions(+), 3 deletions(-) diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 56a9378662..666f6ed7cd 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -54,6 +54,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, } } diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 63681382ea..6d446e1c42 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -194,6 +194,7 @@ fn create_fake_rollout_with_source_and_parent_thread_id( base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }; let payload = serde_json::to_value(SessionMetaLine { meta, @@ -279,6 +280,7 @@ pub fn create_fake_rollout_with_text_elements( base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }; let payload = serde_json::to_value(SessionMetaLine { meta, 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 f89c9ec5e2..afbe62b321 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -30,6 +30,10 @@ use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; use codex_config::types::AuthCredentialsStoreMode; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_protocol::protocol::MultiAgentVersion; +use codex_protocol::protocol::RolloutItem; +use codex_rollout::append_rollout_item_to_path; +use codex_rollout::read_session_meta_line; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -82,6 +86,9 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { "expected original rollout to exist at {}", original_path.display() ); + let mut session_meta = read_session_meta_line(&original_path).await?; + session_meta.meta.multi_agent_version = Some(MultiAgentVersion::V1); + append_rollout_item_to_path(&original_path, &RolloutItem::SessionMeta(session_meta)).await?; let original_contents = std::fs::read_to_string(&original_path)?; let mut mcp = TestAppServer::new(codex_home.path()).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 ff5d537225..c016b38827 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -63,6 +63,8 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ImageGenerationEndEvent; use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::McpToolCallEndEvent; +use codex_protocol::protocol::MultiAgentVersion; +use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource as RolloutSessionSource; @@ -74,6 +76,8 @@ use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; +use codex_rollout::append_rollout_item_to_path; +use codex_rollout::read_session_meta_line; use codex_state::StateRuntime; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::responses; @@ -1805,6 +1809,7 @@ stream_max_retries = 0 base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }; std::fs::write( &rollout_path, @@ -2013,7 +2018,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; - let rollout = setup_rollout_fixture(codex_home.path(), &server.uri())?; + let rollout = setup_rollout_fixture(codex_home.path(), &server.uri()).await?; let thread_id = rollout.conversation_id.clone(); let mut mcp = TestAppServer::new(codex_home.path()).await?; @@ -3051,7 +3056,7 @@ async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Re async fn thread_resume_fails_when_required_mcp_server_fails_to_initialize() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; - let rollout = setup_rollout_fixture(codex_home.path(), &server.uri())?; + let rollout = setup_rollout_fixture(codex_home.path(), &server.uri()).await?; create_config_toml_with_required_broken_mcp(codex_home.path(), &server.uri())?; let mut mcp = TestAppServer::new(codex_home.path()).await?; @@ -3614,7 +3619,7 @@ struct RolloutFixture { before_modified: std::time::SystemTime, } -fn setup_rollout_fixture(codex_home: &Path, server_uri: &str) -> Result { +async fn setup_rollout_fixture(codex_home: &Path, server_uri: &str) -> Result { create_config_toml(codex_home, server_uri)?; let preview = "Saved user message"; @@ -3631,6 +3636,10 @@ fn setup_rollout_fixture(codex_home: &Path, server_uri: &str) -> Result TurnContextItem { model: "gpt-test".to_string(), personality: None, collaboration_mode: None, + multi_agent_version: None, realtime_active: Some(false), effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, diff --git a/codex-rs/core/src/personality_migration_tests.rs b/codex-rs/core/src/personality_migration_tests.rs index ad42a41baa..4cf8ab9d8f 100644 --- a/codex-rs/core/src/personality_migration_tests.rs +++ b/codex-rs/core/src/personality_migration_tests.rs @@ -59,6 +59,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }; diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 05be3e8967..0ca54e55e0 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -72,6 +72,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), + multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -110,6 +111,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), + multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -959,6 +961,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), + multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1037,6 +1040,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), + multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1065,6 +1069,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), + multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1183,6 +1188,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo model: current_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), + multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1300,6 +1306,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), + multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1458,6 +1465,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), + multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, summary: codex_protocol::config_types::ReasoningSummary::Auto, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index ec67f765d1..47b7647258 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2471,6 +2471,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), + multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, summary: codex_protocol::config_types::ReasoningSummary::Auto, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 1e0a3f17c5..b9ed483f1a 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -353,6 +353,7 @@ impl TurnContext { model: self.model_info.slug.clone(), personality: self.personality, collaboration_mode: Some(self.collaboration_mode.clone()), + multi_agent_version: None, realtime_active: Some(self.realtime_active), effort: self.reasoning_effort, summary: ReasoningSummaryConfig::Auto, diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index b3574cf5e2..570f97008f 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -233,6 +233,7 @@ fn remote_model_with_auto_review_override(slug: &str, review_model: &str) -> Mod supports_search_tool: false, auto_review_model_override: Some(review_model.to_string()), tool_mode: None, + multi_agent_version: None, priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index f7d2bf578c..b76a3522ec 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -114,6 +114,7 @@ fn test_model_info( supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), @@ -933,6 +934,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 5244a178a4..34fc98b59f 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -372,5 +372,6 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, } } diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 4219576d4f..231a6d826f 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -594,6 +594,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, }; let _models_mock = mount_models_once( @@ -706,6 +707,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, }; let _models_mock = mount_models_once( diff --git a/codex-rs/core/tests/suite/personality_migration.rs b/codex-rs/core/tests/suite/personality_migration.rs index 84a2e18824..5302274153 100644 --- a/codex-rs/core/tests/suite/personality_migration.rs +++ b/codex-rs/core/tests/suite/personality_migration.rs @@ -75,6 +75,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }; @@ -124,6 +125,7 @@ async fn write_rollout_with_meta_only(dir: &Path, thread_id: ThreadId) -> io::Re base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 00222a3a3a..fd6e01c075 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -479,6 +479,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), @@ -730,6 +731,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), @@ -1215,6 +1217,7 @@ fn test_remote_model_with_policy( supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, priority, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 506fa46ccd..54b046e66c 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -39,6 +39,7 @@ fn resume_history( model: previous_model.to_string(), personality: None, collaboration_mode: None, + multi_agent_version: None, realtime_active: None, effort: config.model_reasoning_effort, summary: config diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index c3d598c7ba..36ea7d4b2c 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1351,6 +1351,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, }], }, ) diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index 787ba10501..c1f0d522c7 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -62,6 +62,7 @@ fn test_model_info( supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, priority: 1, additional_speed_tiers: Vec::new(), service_tiers, diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 768220376c..94deeb48a0 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -222,6 +222,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 0c6cdf6d8b..2e34475387 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1357,6 +1357,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index 379d173e4e..58137a3f50 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -101,6 +101,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo { supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, } } diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 0214f450fd..4b678a9209 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -22,6 +22,7 @@ use crate::config_types::ReasoningSummary; use crate::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use crate::config_types::ServiceTier; use crate::config_types::Verbosity; +use crate::protocol::MultiAgentVersion; const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}"; pub const SPEED_TIER_FAST: &str = "fast"; @@ -347,6 +348,12 @@ pub struct ModelInfo { deserialize_with = "deserialize_optional_model_selector" )] pub tool_mode: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_optional_model_selector" + )] + pub multi_agent_version: Option, } impl ModelInfo { @@ -643,6 +650,7 @@ mod tests { supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, } } @@ -901,6 +909,22 @@ mod tests { assert!(!object.contains_key("tool_mode")); } + #[test] + fn model_info_treats_unknown_multi_agent_version_as_omitted() { + let mut value = + serde_json::to_value(test_model(/*spec*/ None)).expect("serialize test model"); + let object = value + .as_object_mut() + .expect("model info should be an object"); + object.insert( + "multi_agent_version".to_string(), + serde_json::Value::String("future_multi_agent_version".to_string()), + ); + let model = serde_json::from_value::(value).expect("deserialize model info"); + + assert_eq!(model.multi_agent_version, None); + } + #[test] fn resolved_context_window_prefers_context_window() { let model = ModelInfo { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 6f95ac8b88..2674492e88 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2450,6 +2450,18 @@ impl InitialHistory { } } + pub fn get_multi_agent_version(&self) -> Option { + match self { + InitialHistory::New | InitialHistory::Cleared => None, + InitialHistory::Resumed(resumed) => { + multi_agent_version_from_items(&resumed.history, Some(resumed.conversation_id)) + } + InitialHistory::Forked(items) => { + multi_agent_version_from_items(items, /*thread_id*/ None) + } + } + } + pub fn get_resumed_session_sources(&self) -> Option<(SessionSource, Option)> { let meta = self.get_resumed_session_meta()?; Some((meta.source.clone(), meta.thread_source)) @@ -2720,6 +2732,39 @@ impl fmt::Display for InternalSessionSource { } } +fn multi_agent_version_from_items( + items: &[RolloutItem], + thread_id: Option, +) -> Option { + let session_meta_version = items.iter().rev().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) + if thread_id.is_none_or(|thread_id| meta_line.meta.id == thread_id) => + { + meta_line.meta.multi_agent_version + } + _ => None, + }); + + session_meta_version.or_else(|| { + items.iter().rev().find_map(|item| match item { + RolloutItem::TurnContext(turn_context) => turn_context.multi_agent_version, + RolloutItem::SessionMeta(_) + | RolloutItem::ResponseItem(_) + | RolloutItem::Compacted(_) + | RolloutItem::EventMsg(_) => None, + }) + }) +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum MultiAgentVersion { + Disabled, + V1, + V2, +} + /// SessionMeta contains session-level data that doesn't correspond to a specific turn. /// /// NOTE: There used to be an `instructions` field here, which stored user_instructions, but we @@ -2759,6 +2804,8 @@ pub struct SessionMeta { pub dynamic_tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub memory_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub multi_agent_version: Option, } impl Default for SessionMeta { @@ -2780,6 +2827,7 @@ impl Default for SessionMeta { base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, } } } @@ -2859,6 +2907,8 @@ pub struct TurnContextItem { #[serde(default, skip_serializing_if = "Option::is_none")] pub collaboration_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub multi_agent_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub realtime_active: Option, #[serde(skip_serializing_if = "Option::is_none")] pub effort: Option, @@ -5280,6 +5330,39 @@ mod tests { Ok(()) } + #[test] + fn multi_agent_version_uses_newest_present_session_meta_value() -> Result<()> { + let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; + let older_meta = SessionMetaLine { + meta: SessionMeta { + id: thread_id, + multi_agent_version: Some(MultiAgentVersion::V2), + ..Default::default() + }, + git: None, + }; + let newer_meta_without_version = SessionMetaLine { + meta: SessionMeta { + id: thread_id, + multi_agent_version: None, + ..Default::default() + }, + git: None, + }; + + assert_eq!( + multi_agent_version_from_items( + &[ + RolloutItem::SessionMeta(older_meta), + RolloutItem::SessionMeta(newer_meta_without_version), + ], + Some(thread_id), + ), + Some(MultiAgentVersion::V2) + ); + Ok(()) + } + #[test] fn turn_context_item_serializes_network_when_present() -> Result<()> { let item = TurnContextItem { @@ -5306,6 +5389,7 @@ mod tests { model: "gpt-5".to_string(), personality: None, collaboration_mode: None, + multi_agent_version: None, realtime_active: None, effort: None, summary: ReasoningSummaryConfig::Auto, diff --git a/codex-rs/rollout/src/compression_tests.rs b/codex-rs/rollout/src/compression_tests.rs index 52094c2b8a..eb81112965 100644 --- a/codex-rs/rollout/src/compression_tests.rs +++ b/codex-rs/rollout/src/compression_tests.rs @@ -473,6 +473,7 @@ fn write_rollout(path: &std::path::Path, thread_id: ThreadId, message: &str) -> base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }; diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index 7ae3843085..b53c958ae8 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -49,6 +49,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() { base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }; let session_meta_line = SessionMetaLine { meta: session_meta, @@ -102,9 +103,11 @@ async fn extract_metadata_from_rollout_returns_latest_memory_mode() { base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }; let polluted_meta = SessionMeta { memory_mode: Some("polluted".to_string()), + multi_agent_version: None, ..session_meta.clone() }; let lines = vec![ @@ -363,6 +366,7 @@ fn write_rollout_in_sessions_with_cwd( base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }; let session_meta_line = SessionMetaLine { meta: session_meta, diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 94e3efe2ec..8f13d1844e 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -696,6 +696,7 @@ impl RolloutRecorder { Some(dynamic_tools) }, memory_mode: (!config.generate_memories()).then_some("disabled".to_string()), + multi_agent_version: None, }; (None, Some(log_file_info), path, Some(session_meta)) diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index a9ee6ca9aa..07029abe37 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -99,6 +99,7 @@ async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> { base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }; @@ -1145,6 +1146,7 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re model: "test-model".to_string(), personality: None, collaboration_mode: None, + multi_agent_version: None, realtime_active: None, effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, diff --git a/codex-rs/rollout/src/session_index_tests.rs b/codex-rs/rollout/src/session_index_tests.rs index 91b475caff..97496d394e 100644 --- a/codex-rs/rollout/src/session_index_tests.rs +++ b/codex-rs/rollout/src/session_index_tests.rs @@ -41,6 +41,7 @@ fn write_rollout_with_metadata(path: &Path, thread_id: ThreadId) -> std::io::Res base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }), diff --git a/codex-rs/rollout/src/state_db_tests.rs b/codex-rs/rollout/src/state_db_tests.rs index 22a3cb803a..a84ede199b 100644 --- a/codex-rs/rollout/src/state_db_tests.rs +++ b/codex-rs/rollout/src/state_db_tests.rs @@ -158,6 +158,7 @@ fn write_rollout_with_user_message( base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }), diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index 73431b0412..e489eaba59 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -1274,6 +1274,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }), diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index f64e6f7fed..1bba63fbf3 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -336,6 +336,7 @@ mod tests { base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }), @@ -357,6 +358,7 @@ mod tests { model: "gpt-5".to_string(), personality: None, collaboration_mode: None, + multi_agent_version: None, realtime_active: None, effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -394,6 +396,7 @@ mod tests { model: "gpt-5".to_string(), personality: None, collaboration_mode: None, + multi_agent_version: None, realtime_active: None, effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -428,6 +431,7 @@ mod tests { model: "gpt-5".to_string(), personality: None, collaboration_mode: None, + multi_agent_version: None, realtime_active: None, effort: Some(ReasoningEffort::High), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -458,6 +462,7 @@ mod tests { model: "gpt-5".to_string(), personality: None, collaboration_mode: None, + multi_agent_version: None, realtime_active: None, effort: Some(ReasoningEffort::High), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -494,6 +499,7 @@ mod tests { base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }), diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 9522b6176d..4cc7ee4dc1 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -1332,6 +1332,7 @@ mod tests { base_instructions: None, dynamic_tools: None, memory_mode: Some("polluted".to_string()), + multi_agent_version: None, }, git: None, })]; @@ -1392,6 +1393,7 @@ mod tests { base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: Some(GitInfo { commit_hash: Some(codex_git_utils::GitSha::new("rollout-sha")), diff --git a/codex-rs/tools/src/tool_config_tests.rs b/codex-rs/tools/src/tool_config_tests.rs index 165d6efa20..1b4d789394 100644 --- a/codex-rs/tools/src/tool_config_tests.rs +++ b/codex-rs/tools/src/tool_config_tests.rs @@ -46,6 +46,7 @@ fn model_with_shell_type(shell_type: ConfigShellToolType) -> ModelInfo { supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, } } From 0c5ccd18abda96efaed9e94e26ffe22def5e28ed Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 2 Jun 2026 13:05:20 +0200 Subject: [PATCH 11/59] Persist multi-agent runtime metadata (#25721) Stack split from #25708. Original PR intentionally left open. This second PR persists multi-agent runtime metadata through thread creation, rollout recording, and thread storage. --- .../tests/suite/conversation_summary.rs | 1 + .../app-server/tests/suite/v2/thread_read.rs | 1 + .../tests/suite/v2/thread_unarchive.rs | 1 + codex-rs/core/src/session/session.rs | 1 + codex-rs/core/src/session/tests.rs | 2 ++ codex-rs/rollout/src/recorder.rs | 20 ++++++++++- codex-rs/thread-store/src/in_memory.rs | 36 +++++++++++++++++-- .../thread-store/src/local/create_thread.rs | 3 +- codex-rs/thread-store/src/local/mod.rs | 1 + codex-rs/thread-store/src/types.rs | 3 ++ 10 files changed, 65 insertions(+), 4 deletions(-) diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 109999d90e..d54f5edc51 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -128,6 +128,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), + multi_agent_version: None, metadata: ThreadPersistenceMetadata { cwd: None, model_provider: "test-provider".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 1540a84b58..17c55a74a3 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -1362,6 +1362,7 @@ async fn seed_pathless_store_thread( thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), + multi_agent_version: None, metadata: ThreadPersistenceMetadata { cwd: None, model_provider: "test-provider".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 70a9ff23de..09c17bb79f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -215,6 +215,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), + multi_agent_version: None, metadata: ThreadPersistenceMetadata { cwd: None, model_provider: "test-provider".to_string(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index cdc92d3b45..966c96332d 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -558,6 +558,7 @@ impl Session { text: session_configuration.base_instructions.clone(), }, dynamic_tools: session_configuration.dynamic_tools.clone(), + multi_agent_version: None, metadata: ThreadPersistenceMetadata { cwd: Some(config.cwd.to_path_buf()), model_provider: config.model_provider_id.clone(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 47b7647258..28fa0ed27a 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3461,6 +3461,7 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf { thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), + multi_agent_version: None, metadata: ThreadPersistenceMetadata { cwd: Some(config.cwd.to_path_buf()), model_provider: config.model_provider_id.clone(), @@ -5957,6 +5958,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() { thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), + multi_agent_version: None, metadata: ThreadPersistenceMetadata { cwd: Some(config.cwd.to_path_buf()), model_provider: config.model_provider_id.clone(), diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 8f13d1844e..d102d5c540 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -52,6 +52,7 @@ use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_protocol::protocol::GitInfo as ProtocolGitInfo; use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::ResumedHistory; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; @@ -87,6 +88,7 @@ pub enum RolloutRecorderParams { thread_source: Option, base_instructions: BaseInstructions, dynamic_tools: Vec, + multi_agent_version: Option, }, Resume { path: PathBuf, @@ -172,9 +174,24 @@ impl RolloutRecorderParams { thread_source, base_instructions, dynamic_tools, + multi_agent_version: None, } } + pub fn with_multi_agent_version( + mut self, + multi_agent_version: Option, + ) -> Self { + if let Self::Create { + multi_agent_version: version, + .. + } = &mut self + { + *version = multi_agent_version; + } + self + } + pub fn resume(path: PathBuf) -> Self { Self::Resume { path } } @@ -661,6 +678,7 @@ impl RolloutRecorder { thread_source, base_instructions, dynamic_tools, + multi_agent_version, } => { let log_file_info = precompute_log_file_info(config, conversation_id)?; let path = log_file_info.path.clone(); @@ -696,7 +714,7 @@ impl RolloutRecorder { Some(dynamic_tools) }, memory_mode: (!config.generate_memories()).then_some("disabled".to_string()), - multi_agent_version: None, + multi_agent_version, }; (None, Some(log_file_info), path, Some(session_meta)) diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index 02e64a1ff9..b02fc15855 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -11,6 +11,9 @@ use codex_protocol::ThreadId; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SessionMeta; +use codex_protocol::protocol::SessionMetaLine; +use codex_protocol::protocol::ThreadMemoryMode; use crate::AppendThreadItemsParams; use crate::ArchiveThreadParams; @@ -164,7 +167,32 @@ impl ThreadStore for InMemoryThreadStore { async fn create_thread(&self, params: CreateThreadParams) -> ThreadStoreResult<()> { let mut state = self.state.lock().await; state.calls.create_thread += 1; - state.histories.entry(params.thread_id).or_default(); + let session_meta = SessionMeta { + id: params.thread_id, + forked_from_id: params.forked_from_id, + parent_thread_id: params.parent_thread_id, + cwd: params.metadata.cwd.clone().unwrap_or_default(), + agent_nickname: params.source.get_nickname(), + agent_role: params.source.get_agent_role(), + agent_path: params.source.get_agent_path().map(Into::into), + source: params.source.clone(), + thread_source: params.thread_source, + model_provider: Some(params.metadata.model_provider.clone()), + base_instructions: Some(params.base_instructions.clone()), + dynamic_tools: (!params.dynamic_tools.is_empty()).then(|| params.dynamic_tools.clone()), + memory_mode: matches!(params.metadata.memory_mode, ThreadMemoryMode::Disabled) + .then_some("disabled".to_string()), + multi_agent_version: params.multi_agent_version, + ..SessionMeta::default() + }; + state + .histories + .entry(params.thread_id) + .or_default() + .push(RolloutItem::SessionMeta(SessionMetaLine { + meta: session_meta, + git: None, + })); state.created_threads.insert(params.thread_id, params); Ok(()) } @@ -172,7 +200,11 @@ impl ThreadStore for InMemoryThreadStore { async fn resume_thread(&self, params: ResumeThreadParams) -> ThreadStoreResult<()> { let mut state = self.state.lock().await; state.calls.resume_thread += 1; - state.histories.entry(params.thread_id).or_default(); + if let Some(history) = params.history { + state.histories.insert(params.thread_id, history); + } else { + state.histories.entry(params.thread_id).or_default(); + } if let Some(rollout_path) = params.rollout_path { state.rollout_paths.insert(rollout_path, params.thread_id); } diff --git a/codex-rs/thread-store/src/local/create_thread.rs b/codex-rs/thread-store/src/local/create_thread.rs index a71bb48b0f..8c630f9956 100644 --- a/codex-rs/thread-store/src/local/create_thread.rs +++ b/codex-rs/thread-store/src/local/create_thread.rs @@ -35,7 +35,8 @@ pub(super) async fn create_thread( params.thread_source, params.base_instructions, params.dynamic_tools, - ), + ) + .with_multi_agent_version(params.multi_agent_version), ) .await .map_err(|err| ThreadStoreError::Internal { diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 85fa6ad78c..146bfbd843 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -1019,6 +1019,7 @@ mod tests { thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), + multi_agent_version: None, metadata: thread_metadata(), } } diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 7c09259a67..1ab42a3117 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -9,6 +9,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GitInfo; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadMemoryMode as MemoryMode; @@ -72,6 +73,8 @@ pub struct CreateThreadParams { pub base_instructions: BaseInstructions, /// Dynamic tools available to the thread at startup. pub dynamic_tools: Vec, + /// Multi-agent runtime selected when the thread was created. + pub multi_agent_version: Option, /// Metadata captured for the newly created thread. pub metadata: ThreadPersistenceMetadata, } From bf9fd885b2546e91fe3bab271aa481b070f47518 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 2 Jun 2026 14:31:00 +0200 Subject: [PATCH 12/59] Resolve per-thread multi-agent runtime (#25722) Stack split from #25708. Original PR intentionally left open. This third PR resolves the effective per-thread multi-agent runtime from persisted metadata, inherited runtime, and current model selection. --- codex-rs/core/src/agent/control.rs | 158 +++++++++++------- codex-rs/core/src/agent/control_tests.rs | 8 +- codex-rs/core/src/codex_delegate.rs | 2 + codex-rs/core/src/codex_thread.rs | 5 + codex-rs/core/src/config/config_tests.rs | 33 +++- codex-rs/core/src/config/mod.rs | 67 +++++--- codex-rs/core/src/session/mod.rs | 70 ++++++-- codex-rs/core/src/session/multi_agents.rs | 4 +- codex-rs/core/src/session/review.rs | 1 + codex-rs/core/src/session/session.rs | 9 +- codex-rs/core/src/session/tests.rs | 91 ++++++++++ .../core/src/session/tests/guardian_tests.rs | 2 +- codex-rs/core/src/session/turn_context.rs | 98 +++++++++-- codex-rs/core/src/session_startup_prewarm.rs | 2 +- codex-rs/core/src/tasks/mod.rs | 13 +- codex-rs/core/src/thread_manager.rs | 78 ++++++++- .../core/src/tools/handlers/agent_jobs.rs | 20 +-- .../handlers/multi_agents/resume_agent.rs | 2 +- .../src/tools/handlers/multi_agents/spawn.rs | 1 - .../src/tools/handlers/multi_agents_common.rs | 14 +- .../src/tools/handlers/multi_agents_tests.rs | 109 ++++++------ .../tools/handlers/multi_agents_v2/spawn.rs | 1 - codex-rs/core/src/tools/spec_plan.rs | 16 +- codex-rs/core/src/tools/spec_plan_tests.rs | 1 + 24 files changed, 599 insertions(+), 206 deletions(-) diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index eecc46d587..821611e60e 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -5,6 +5,7 @@ use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::resolve_role_config; use crate::agent::status::is_final; use crate::codex_thread::ThreadConfigSnapshot; +use crate::config::Config; use crate::session::emit_subagent_session_started; use crate::session_prefix::format_subagent_context_line; use crate::session_prefix::format_subagent_notification_message; @@ -12,7 +13,6 @@ use crate::shell_snapshot::ShellSnapshot; use crate::thread_manager::ResumeThreadWithHistoryOptions; use crate::thread_manager::ThreadManagerState; use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns; -use codex_features::Feature; use codex_protocol::AgentPath; use codex_protocol::SessionId; use codex_protocol::ThreadId; @@ -23,6 +23,7 @@ use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::Op; use codex_protocol::protocol::ResumedHistory; use codex_protocol::protocol::RolloutItem; @@ -58,6 +59,11 @@ pub(crate) struct SpawnAgentOptions { pub(crate) environments: Option>, } +struct SpawnAgentThreadInheritance { + shell_snapshot: Option>, + exec_policy: Option>, +} + #[derive(Clone, Debug)] pub(crate) struct LiveAgent { pub(crate) thread_id: ThreadId, @@ -80,10 +86,7 @@ fn default_agent_nickname_list() -> Vec<&'static str> { .collect() } -fn agent_nickname_candidates( - config: &crate::config::Config, - role_name: Option<&str>, -) -> Vec { +fn agent_nickname_candidates(config: &Config, role_name: Option<&str>) -> Vec { let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME); if let Some(candidates) = resolve_role_config(config, role_name).and_then(|role| role.nickname_candidates.clone()) @@ -185,7 +188,7 @@ impl AgentControl { #[cfg(test)] pub(crate) async fn spawn_agent( &self, - config: crate::config::Config, + config: Config, initial_operation: Op, session_source: Option, ) -> CodexResult { @@ -202,7 +205,7 @@ impl AgentControl { /// Spawn an agent thread with some metadata. pub(crate) async fn spawn_agent_with_metadata( &self, - config: crate::config::Config, + config: Config, initial_operation: Op, session_source: Option, options: SpawnAgentOptions, // TODO(jif) drop with new fork. @@ -213,19 +216,33 @@ impl AgentControl { async fn spawn_agent_internal( &self, - config: crate::config::Config, + config: Config, initial_operation: Op, session_source: Option, options: SpawnAgentOptions, ) -> CodexResult { let state = self.upgrade()?; - let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; - let inherited_shell_snapshot = self - .inherited_shell_snapshot_for_source(&state, session_source.as_ref()) - .await; - let inherited_exec_policy = self - .inherited_exec_policy_for_source(&state, session_source.as_ref(), &config) + let multi_agent_version = state + .effective_multi_agent_version_for_spawn( + &InitialHistory::New, + session_source.as_ref(), + options.parent_thread_id, + /*forked_from_thread_id*/ None, + &config, + ) .await; + let agent_max_threads = config + .effective_agent_max_threads(multi_agent_version) + .map_err(|err| CodexErr::InvalidRequest(err.to_string()))?; + let mut reservation = self.state.reserve_spawn_slot(agent_max_threads)?; + let inheritance = SpawnAgentThreadInheritance { + shell_snapshot: self + .inherited_shell_snapshot_for_source(&state, session_source.as_ref()) + .await, + exec_policy: self + .inherited_exec_policy_for_source(&state, session_source.as_ref(), &config) + .await, + }; let (session_source, mut agent_metadata) = match session_source { Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, @@ -250,19 +267,19 @@ impl AgentControl { let notification_source = session_source.clone(); // The same `AgentControl` is sent to spawn the thread. - let new_thread = match (session_source, options.fork_mode.as_ref()) { - (Some(session_source), Some(_)) => { + let new_thread = match (session_source, options.fork_mode.as_ref(), inheritance) { + (Some(session_source), Some(_), inheritance) => { Box::pin(self.spawn_forked_thread( &state, config, session_source, &options, - inherited_shell_snapshot, - inherited_exec_policy, + inheritance, + multi_agent_version, )) .await? } - (Some(session_source), None) => { + (Some(session_source), None, inheritance) => { Box::pin(state.spawn_new_thread_with_source( config.clone(), self.clone(), @@ -271,13 +288,13 @@ impl AgentControl { /*forked_from_thread_id*/ None, /*thread_source*/ Some(ThreadSource::Subagent), /*metrics_service_name*/ None, - inherited_shell_snapshot, - inherited_exec_policy, + inheritance.shell_snapshot, + inheritance.exec_policy, options.environments.clone(), )) .await? } - (None, _) => Box::pin(state.spawn_new_thread(config.clone(), self.clone())).await?, + (None, _, _) => Box::pin(state.spawn_new_thread(config.clone(), self.clone())).await?, }; agent_metadata.agent_id = Some(new_thread.thread_id); reservation.commit(agent_metadata.clone()); @@ -340,7 +357,7 @@ impl AgentControl { self.send_input(new_thread.thread_id, initial_operation) .await?; - if !new_thread.thread.enabled(Feature::MultiAgentV2) { + if multi_agent_version != MultiAgentVersion::V2 { let child_reference = agent_metadata .agent_path .as_ref() @@ -364,12 +381,16 @@ impl AgentControl { async fn spawn_forked_thread( &self, state: &Arc, - config: crate::config::Config, + config: Config, session_source: SessionSource, options: &SpawnAgentOptions, - inherited_shell_snapshot: Option>, - inherited_exec_policy: Option>, + inheritance: SpawnAgentThreadInheritance, + multi_agent_version: MultiAgentVersion, ) -> CodexResult { + let SpawnAgentThreadInheritance { + shell_snapshot: inherited_shell_snapshot, + exec_policy: inherited_exec_policy, + } = inheritance; if options.fork_parent_spawn_call_id.is_none() { return Err(CodexErr::Fatal( "spawn_agent fork requires a parent spawn call id".to_string(), @@ -419,7 +440,7 @@ impl AgentControl { } let multi_agent_v2_usage_hint_texts_to_filter: Vec = if let Some(parent_thread) = parent_thread.as_ref() { - if parent_thread.enabled(Feature::MultiAgentV2) { + if multi_agent_version == MultiAgentVersion::V2 { let parent_config = parent_thread.codex.session.get_config().await; [ parent_config @@ -437,7 +458,7 @@ impl AgentControl { } else { Vec::new() } - } else if config.features.enabled(Feature::MultiAgentV2) { + } else if multi_agent_version == MultiAgentVersion::V2 { [ config.multi_agent_v2.root_agent_usage_hint_text.clone(), config.multi_agent_v2.subagent_usage_hint_text.clone(), @@ -473,7 +494,7 @@ impl AgentControl { } } if preserve_reference_context_item - && config.features.enabled(Feature::MultiAgentV2) + && multi_agent_version == MultiAgentVersion::V2 && config.multi_agent_v2.usage_hint_enabled && let Some(subagent_usage_hint_text) = config.multi_agent_v2.subagent_usage_hint_text.clone() @@ -504,7 +525,7 @@ impl AgentControl { /// Resume an existing agent thread from a recorded rollout file. pub(crate) async fn resume_agent_from_rollout( &self, - config: crate::config::Config, + config: Config, thread_id: ThreadId, session_source: SessionSource, ) -> CodexResult { @@ -579,20 +600,42 @@ impl AgentControl { async fn resume_single_agent_from_rollout( &self, - mut config: crate::config::Config, + config: Config, thread_id: ThreadId, session_source: SessionSource, ) -> CodexResult { - if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = &session_source - && *depth >= config.agent_max_depth - && !config.features.enabled(Feature::MultiAgentV2) - { - let _ = config.features.disable(Feature::SpawnCsv); - let _ = config.features.disable(Feature::Collab); - } let state = self.upgrade()?; let state_db_ctx = state.state_db(); - let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; + let stored_thread = state + .read_stored_thread(ReadThreadParams { + thread_id, + include_archived: true, + include_history: true, + }) + .await?; + let history = stored_thread + .history + .ok_or_else(|| CodexErr::ThreadNotFound(thread_id))? + .items; + let initial_history = InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history, + rollout_path: stored_thread.rollout_path, + }); + let parent_thread_id = stored_thread.parent_thread_id; + let multi_agent_version = state + .effective_multi_agent_version_for_spawn( + &initial_history, + Some(&session_source), + parent_thread_id, + /*forked_from_thread_id*/ None, + &config, + ) + .await; + let agent_max_threads = config + .effective_agent_max_threads(multi_agent_version) + .map_err(|err| CodexErr::InvalidRequest(err.to_string()))?; + let mut reservation = self.state.reserve_spawn_slot(agent_max_threads)?; let (session_source, agent_metadata) = match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, @@ -629,27 +672,11 @@ impl AgentControl { let inherited_exec_policy = self .inherited_exec_policy_for_source(&state, Some(&session_source), &config) .await; - let stored_thread = state - .read_stored_thread(ReadThreadParams { - thread_id, - include_archived: true, - include_history: true, - }) - .await?; - let history = stored_thread - .history - .ok_or_else(|| CodexErr::ThreadNotFound(thread_id))? - .items; - let parent_thread_id = stored_thread.parent_thread_id; let resumed_thread = state .resume_thread_with_history_with_source(ResumeThreadWithHistoryOptions { config: config.clone(), - initial_history: InitialHistory::Resumed(ResumedHistory { - conversation_id: thread_id, - history, - rollout_path: stored_thread.rollout_path, - }), + initial_history, agent_control: self.clone(), session_source, parent_thread_id, @@ -663,7 +690,7 @@ impl AgentControl { // Resumed threads are re-registered in-memory and need the same listener // attachment path as freshly spawned threads. state.notify_thread_created(resumed_thread.thread_id); - if !resumed_thread.thread.enabled(Feature::MultiAgentV2) { + if multi_agent_version != MultiAgentVersion::V2 { let child_reference = agent_metadata .agent_path .as_ref() @@ -1048,12 +1075,13 @@ impl AgentControl { }; let child_thread = state.get_thread(child_thread_id).await.ok(); let message = format_subagent_notification_message(child_reference.as_str(), &status); - if child_agent_path.is_some() - && child_thread - .as_ref() - .map(|thread| thread.enabled(Feature::MultiAgentV2)) - .unwrap_or(true) - { + let child_uses_multi_agent_v2 = match child_thread.as_ref() { + Some(child_thread) => { + child_thread.multi_agent_version() == Some(MultiAgentVersion::V2) + } + None => true, + }; + if child_agent_path.is_some() && child_uses_multi_agent_v2 { let Some(child_agent_path) = child_agent_path.clone() else { return; }; @@ -1089,7 +1117,7 @@ impl AgentControl { fn prepare_thread_spawn( &self, reservation: &mut crate::agent::registry::SpawnReservation, - config: &crate::config::Config, + config: &Config, parent_thread_id: ThreadId, depth: i32, agent_path: Option, @@ -1151,7 +1179,7 @@ impl AgentControl { &self, state: &Arc, session_source: Option<&SessionSource>, - child_config: &crate::config::Config, + child_config: &Config, ) -> Option> { let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, .. diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 3e0242c037..f9462e94f6 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1587,9 +1587,15 @@ async fn spawn_child_completion_notifies_parent_history() { #[tokio::test] async fn multi_agent_v2_completion_ignores_dead_direct_parent() { let harness = AgentControlHarness::new().await; - let (root_thread_id, root_thread) = harness.start_thread().await; let mut config = harness.config.clone(); let _ = config.features.enable(Feature::MultiAgentV2); + let root = harness + .manager + .start_thread(config.clone()) + .await + .expect("root thread should start"); + let root_thread_id = root.thread_id; + let root_thread = root.thread; let worker_path = AgentPath::root().join("worker_a").expect("worker path"); let worker_thread_id = harness .control diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 74854b4005..646d5969e8 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -52,6 +52,7 @@ use codex_login::AuthManager; use codex_models_manager::manager::SharedModelsManager; use codex_protocol::error::CodexErr; use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::MultiAgentVersion; #[cfg(test)] use crate::session::completed_session_loop_termination; @@ -103,6 +104,7 @@ pub(crate) async fn run_codex_thread_interactive( analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), thread_store: Arc::clone(&parent_session.services.thread_store), attestation_provider: parent_session.services.attestation_provider.clone(), + inherited_multi_agent_version: Some(MultiAgentVersion::Disabled), })) .or_cancel(&cancel_token) .await??; diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 7a6bc69c5c..cd90829c0c 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -24,6 +24,7 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AdditionalContextEntry; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; @@ -502,6 +503,10 @@ impl CodexThread { self.codex.session.get_config().await } + pub fn multi_agent_version(&self) -> Option { + self.codex.session.multi_agent_version() + } + /// Refresh the thread's layer-backed user config state from a caller-supplied /// config snapshot. Thread-scoped layers and session-static settings remain /// unchanged. diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 70719843ba..05cbb9828f 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -86,6 +86,7 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::NetworkAccess; use codex_protocol::protocol::RealtimeVoice; use codex_protocol::protocol::SandboxPolicy; @@ -9801,7 +9802,13 @@ non_code_mode_only = true assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 2500); assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 120000); assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 30000); - assert_eq!(config.agent_max_threads, Some(4)); + assert_eq!( + ( + config.agent_max_threads, + config.effective_agent_max_threads(MultiAgentVersion::V2)? + ), + (None, Some(4)) + ); assert!(!config.multi_agent_v2.usage_hint_enabled); assert_eq!( config.multi_agent_v2.usage_hint_text.as_deref(), @@ -9845,7 +9852,13 @@ enabled = true assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 10_000); assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 3_600_000); assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 30_000); - assert_eq!(config.agent_max_threads, Some(3)); + assert_eq!( + ( + config.agent_max_threads, + config.effective_agent_max_threads(MultiAgentVersion::V2)? + ), + (None, Some(3)) + ); assert_eq!( config.multi_agent_v2.root_agent_usage_hint_text.as_deref(), Some(DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT) @@ -9912,17 +9925,19 @@ max_threads = 3 "#, )?; - let err = ConfigBuilder::without_managed_config_for_tests() + let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() - .await + .await?; + let err = config + .effective_agent_max_threads(MultiAgentVersion::V2) .expect_err("agents.max_threads should conflict with multi_agent_v2"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( err.to_string(), - "agents.max_threads cannot be set when multi_agent_v2 is enabled" + "agents.max_threads cannot be set when the multi-agent runtime is v2" ); Ok(()) @@ -10181,7 +10196,13 @@ max_concurrent_threads_per_session = 1 .await?; assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 1); - assert_eq!(config.agent_max_threads, Some(0)); + assert_eq!( + ( + config.agent_max_threads, + config.effective_agent_max_threads(MultiAgentVersion::V2)? + ), + (None, Some(0)) + ); Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index ff666b51e9..051064f279 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -99,6 +99,7 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; @@ -823,7 +824,7 @@ pub struct Config { /// Token budget applied when storing tool/function outputs in the context manager. pub tool_output_token_limit: Option, - /// Maximum number of agent threads that can be open concurrently. + /// User-configured maximum number of agent threads that can be open concurrently. pub agent_max_threads: Option, /// Maximum runtime in seconds for agent job workers before they are failed. pub agent_job_max_runtime_seconds: Option, @@ -1281,6 +1282,40 @@ impl ConfigBuilder { } impl Config { + pub(crate) fn multi_agent_version_from_features(&self) -> MultiAgentVersion { + if self.features.enabled(Feature::MultiAgentV2) { + MultiAgentVersion::V2 + } else if self.features.enabled(Feature::Collab) { + MultiAgentVersion::V1 + } else { + MultiAgentVersion::Disabled + } + } + + pub(crate) fn effective_agent_max_threads( + &self, + multi_agent_version: MultiAgentVersion, + ) -> std::io::Result> { + match multi_agent_version { + MultiAgentVersion::V2 => { + if self.agent_max_threads.is_some() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "agents.max_threads cannot be set when the multi-agent runtime is v2", + )); + } + Ok(Some( + self.multi_agent_v2 + .max_concurrent_threads_per_session + .saturating_sub(1), + )) + } + MultiAgentVersion::Disabled | MultiAgentVersion::V1 => { + Ok(self.agent_max_threads.or(DEFAULT_AGENT_MAX_THREADS)) + } + } + } + pub fn legacy_sandbox_policy(&self) -> SandboxPolicy { self.permissions.legacy_sandbox_policy(self.cwd.as_path()) } @@ -3063,29 +3098,13 @@ impl Config { )); } validate_multi_agent_v2_tool_namespace(multi_agent_v2.tool_namespace.as_deref())?; - let agent_max_threads_from_config = cfg.agents.as_ref().and_then(|agents| agents.max_threads); - let agent_max_threads = if features.enabled(Feature::MultiAgentV2) { - if agent_max_threads_from_config.is_some() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "agents.max_threads cannot be set when multi_agent_v2 is enabled", - )); - } - Some( - multi_agent_v2 - .max_concurrent_threads_per_session - .saturating_sub(1), - ) - } else { - let agent_max_threads = agent_max_threads_from_config.or(DEFAULT_AGENT_MAX_THREADS); - if agent_max_threads == Some(0) { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "agents.max_threads must be at least 1", - )); - } - agent_max_threads - }; + let agent_max_threads = cfg.agents.as_ref().and_then(|agents| agents.max_threads); + if agent_max_threads == Some(0) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "agents.max_threads must be at least 1", + )); + } let agent_max_depth = cfg .agents .as_ref() diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index e6e32a1b6e..a63b0a91f0 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -105,6 +105,7 @@ use codex_protocol::protocol::HasLegacyEvent; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::ItemStartedEvent; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::RawResponseItemEvent; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; @@ -418,6 +419,25 @@ pub(crate) struct CodexSpawnArgs { pub(crate) analytics_events_client: Option, pub(crate) thread_store: Arc, pub(crate) attestation_provider: Option>, + pub(crate) inherited_multi_agent_version: Option, +} + +pub(crate) fn resolve_multi_agent_version( + conversation_history: &InitialHistory, + inherited_multi_agent_version: Option, +) -> Option { + if inherited_multi_agent_version == Some(MultiAgentVersion::Disabled) { + return Some(MultiAgentVersion::Disabled); + } + + conversation_history + .get_multi_agent_version() + .or(inherited_multi_agent_version) + .or(match conversation_history { + InitialHistory::New | InitialHistory::Cleared => None, + // Threads created before runtime metadata existed keep the legacy V1 tool surface. + InitialHistory::Resumed(_) | InitialHistory::Forked(_) => Some(MultiAgentVersion::V1), + }) } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -479,18 +499,11 @@ impl Codex { analytics_events_client, thread_store, attestation_provider, + inherited_multi_agent_version, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); - if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = session_source - && depth >= config.agent_max_depth - && !config.features.enabled(Feature::MultiAgentV2) - { - let _ = config.features.disable(Feature::SpawnCsv); - let _ = config.features.disable(Feature::Collab); - } - let primary_environment = environment_selections.primary_environment(); let mut user_instruction_warnings = Vec::new(); let user_instructions = AgentsMdManager::new(&config) @@ -541,6 +554,14 @@ impl Codex { let model_info = models_manager .get_model_info(model.as_str(), &config.to_models_manager_config()) .await; + let multi_agent_version = + resolve_multi_agent_version(&conversation_history, inherited_multi_agent_version); + let startup_multi_agent_version = multi_agent_version + .or(model_info.multi_agent_version) + .unwrap_or_else(|| config.multi_agent_version_from_features()); + let _ = config + .effective_agent_max_threads(startup_multi_agent_version) + .map_err(|err| CodexErr::InvalidRequest(err.to_string()))?; let base_instructions = config .base_instructions .clone() @@ -625,6 +646,7 @@ impl Codex { thread_store, parent_rollout_thread_trace, attestation_provider, + multi_agent_version, ) .await .map_err(|e| { @@ -1166,7 +1188,6 @@ impl Session { } async fn record_initial_history(&self, conversation_history: InitialHistory) { - let turn_context = self.new_default_turn().await; let is_subagent = { let state = self.state.lock().await; state @@ -1187,6 +1208,7 @@ impl Session { .await; } InitialHistory::Resumed(resumed_history) => { + let turn_context = self.new_default_turn().await; let rollout_items = resumed_history.history; let previous_turn_settings = self .apply_rollout_reconstruction(&turn_context, &rollout_items) @@ -1226,6 +1248,7 @@ impl Session { } } InitialHistory::Forked(rollout_items) => { + let turn_context = self.new_default_turn().await; self.apply_rollout_reconstruction(&turn_context, &rollout_items) .await; @@ -1648,7 +1671,7 @@ impl Session { turn_context: &TurnContext, msg: &EventMsg, ) { - if !self.enabled(Feature::MultiAgentV2) { + if turn_context.multi_agent_version != MultiAgentVersion::V2 { return; } @@ -2619,6 +2642,33 @@ impl Session { state.session_configuration.collaboration_mode.clone() } + pub(crate) fn multi_agent_version(&self) -> Option { + self.multi_agent_version.get().copied() + } + + pub(crate) fn set_multi_agent_version_if_unset( + &self, + multi_agent_version: MultiAgentVersion, + ) -> MultiAgentVersion { + *self.multi_agent_version.get_or_init(|| multi_agent_version) + } + + pub(crate) fn resolve_multi_agent_version_for_model( + &self, + model_info: &ModelInfo, + config: &Config, + ) -> MultiAgentVersion { + if let Some(multi_agent_version) = self.multi_agent_version() { + return multi_agent_version; + } + + let selected = model_info + .multi_agent_version + .unwrap_or_else(|| config.multi_agent_version_from_features()); + + self.set_multi_agent_version_if_unset(selected) + } + async fn send_raw_response_items(&self, turn_context: &TurnContext, items: &[ResponseItem]) { for item in items { self.send_event( diff --git a/codex-rs/core/src/session/multi_agents.rs b/codex-rs/core/src/session/multi_agents.rs index eb3da0da4b..a78a9c938e 100644 --- a/codex-rs/core/src/session/multi_agents.rs +++ b/codex-rs/core/src/session/multi_agents.rs @@ -1,5 +1,5 @@ use crate::session::turn_context::TurnContext; -use codex_features::Feature; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; @@ -7,7 +7,7 @@ pub(super) fn usage_hint_text<'a>( turn_context: &'a TurnContext, session_source: &SessionSource, ) -> Option<&'a str> { - if !turn_context.features.enabled(Feature::MultiAgentV2) { + if turn_context.multi_agent_version != MultiAgentVersion::V2 { return None; } diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index c476436560..fe78e033ec 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -128,6 +128,7 @@ pub(super) async fn spawn_review_thread( user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), collaboration_mode: parent_turn_context.collaboration_mode.clone(), + multi_agent_version: MultiAgentVersion::Disabled, personality: parent_turn_context.personality, approval_policy: parent_turn_context.approval_policy.clone(), permission_profile: parent_turn_context.permission_profile(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 966c96332d..05773abb8c 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -9,8 +9,10 @@ use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnEnvironmentSelection; +use std::sync::OnceLock; use tokio::sync::Semaphore; /// Context for an initialized model agent @@ -29,6 +31,7 @@ pub(crate) struct Session { /// The set of enabled features should be invariant for the lifetime of the /// session. pub(super) features: ManagedFeatures, + pub(super) multi_agent_version: OnceLock, pub(super) pending_mcp_server_refresh_config: Mutex>, pub(crate) conversation: Arc, pub(crate) active_turn: Mutex>, @@ -503,6 +506,7 @@ impl Session { thread_store: Arc, parent_rollout_thread_trace: ThreadTraceContext, attestation_provider: Option>, + multi_agent_version: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -517,6 +521,8 @@ impl Session { .parent_thread_id .or_else(|| initial_history.get_resumed_parent_thread_id()); session_configuration.parent_thread_id = parent_thread_id; + let multi_agent_version = multi_agent_version.map(OnceLock::from).unwrap_or_default(); + let initial_multi_agent_version = multi_agent_version.get().copied(); let thread_id = match &initial_history { InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => { @@ -558,7 +564,7 @@ impl Session { text: session_configuration.base_instructions.clone(), }, dynamic_tools: session_configuration.dynamic_tools.clone(), - multi_agent_version: None, + multi_agent_version: initial_multi_agent_version, metadata: ThreadPersistenceMetadata { cwd: Some(config.cwd.to_path_buf()), model_provider: config.model_provider_id.clone(), @@ -1063,6 +1069,7 @@ impl Session { state: Mutex::new(state), managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1), features: config.features.clone(), + multi_agent_version, pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 28fa0ed27a..d4b4942a88 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -112,6 +112,7 @@ use codex_protocol::protocol::CreditsSnapshot; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::NetworkApprovalProtocol; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; @@ -121,6 +122,8 @@ use codex_protocol::protocol::RealtimeVoice; use codex_protocol::protocol::RealtimeVoicesList; use codex_protocol::protocol::ResumedHistory; use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SessionMeta; +use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::Submission; use codex_protocol::protocol::ThreadGoalStatus; @@ -176,6 +179,7 @@ use serde::Deserialize; use serde_json::json; use std::path::PathBuf; use std::sync::Arc; +use std::sync::OnceLock; use std::time::Duration as StdDuration; mod guardian_tests; @@ -1645,6 +1649,72 @@ async fn record_initial_history_reconstructs_resumed_transcript() { assert_eq!(expected, history.raw_items()); } +#[test] +fn resolve_multi_agent_version_handles_unset_and_legacy_history() { + let thread_id = ThreadId::default(); + + assert_eq!( + resolve_multi_agent_version( + &InitialHistory::New, + /*inherited_multi_agent_version*/ None + ), + None + ); + assert_eq!( + resolve_multi_agent_version( + &InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history: Vec::new(), + rollout_path: None, + }), + /*inherited_multi_agent_version*/ None, + ), + Some(MultiAgentVersion::V1) + ); + assert_eq!( + resolve_multi_agent_version( + &InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history: Vec::new(), + rollout_path: None, + }), + Some(MultiAgentVersion::V2), + ), + Some(MultiAgentVersion::V2) + ); + assert_eq!( + resolve_multi_agent_version( + &InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history: vec![session_meta_item( + thread_id, + Some(MultiAgentVersion::Disabled) + )], + rollout_path: None, + }), + Some(MultiAgentVersion::V2), + ), + Some(MultiAgentVersion::Disabled) + ); + assert_eq!( + resolve_multi_agent_version( + &InitialHistory::Forked(vec![session_meta_item( + thread_id, + Some(MultiAgentVersion::V2) + )]), + Some(MultiAgentVersion::Disabled), + ), + Some(MultiAgentVersion::Disabled) + ); + assert_eq!( + resolve_multi_agent_version( + &InitialHistory::Forked(Vec::new()), + /*inherited_multi_agent_version*/ None + ), + Some(MultiAgentVersion::V1) + ); +} + #[tokio::test] async fn record_initial_history_new_defers_initial_context_until_first_turn() { let (session, _turn_context) = make_session_and_context().await; @@ -1657,6 +1727,20 @@ async fn record_initial_history_new_defers_initial_context_until_first_turn() { assert_eq!(session.previous_turn_settings().await, None); } +fn session_meta_item( + thread_id: ThreadId, + multi_agent_version: Option, +) -> RolloutItem { + RolloutItem::SessionMeta(SessionMetaLine { + meta: SessionMeta { + id: thread_id, + multi_agent_version, + ..SessionMeta::default() + }, + git: None, + }) +} + #[tokio::test] async fn resumed_history_injects_initial_context_on_first_context_update_only() { let (session, turn_context) = make_session_and_context().await; @@ -4520,6 +4604,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { )), codex_rollout_trace::ThreadTraceContext::disabled(), /*attestation_provider*/ None, + Some(config.multi_agent_version_from_features()), ) .await; @@ -4718,6 +4803,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { &session_telemetry, session_configuration.provider.clone(), &session_configuration, + config.multi_agent_version_from_features(), services.user_shell.as_ref(), services.shell_zsh_path.as_ref(), services.main_execve_wrapper_exe.as_ref(), @@ -4741,6 +4827,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { state: Mutex::new(state), managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1), features: config.features.clone(), + multi_agent_version: OnceLock::from(config.multi_agent_version_from_features()), pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), @@ -4867,6 +4954,7 @@ async fn make_session_with_config_and_rx( )), codex_rollout_trace::ThreadTraceContext::disabled(), /*attestation_provider*/ None, + Some(config.multi_agent_version_from_features()), ) .await?; @@ -4978,6 +5066,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( )), codex_rollout_trace::ThreadTraceContext::disabled(), /*attestation_provider*/ None, + Some(config.multi_agent_version_from_features()), ) .await?; @@ -6563,6 +6652,7 @@ where &session_telemetry, session_configuration.provider.clone(), &session_configuration, + config.multi_agent_version_from_features(), services.user_shell.as_ref(), services.shell_zsh_path.as_ref(), services.main_execve_wrapper_exe.as_ref(), @@ -6586,6 +6676,7 @@ where state: Mutex::new(state), managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1), features: config.features.clone(), + multi_agent_version: OnceLock::from(config.multi_agent_version_from_features()), pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index cd2548ba09..2977e6bdf5 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -709,6 +709,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { analytics_events_client: None, thread_store, attestation_provider: None, + inherited_multi_agent_version: None, }) .await .expect("spawn guardian subagent"); @@ -728,6 +729,5 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { }], } ); - drop(codex); } diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index b9ed483f1a..9593984301 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -7,7 +7,9 @@ use codex_model_provider::create_model_provider; use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::models::AdditionalPermissionProfile; +use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ToolMode; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile; @@ -78,6 +80,7 @@ pub struct TurnContext { pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, pub(crate) collaboration_mode: CollaborationMode, + pub(crate) multi_agent_version: MultiAgentVersion, pub(crate) personality: Option, pub(crate) approval_policy: Constrained, pub(crate) permission_profile: PermissionProfile, @@ -101,6 +104,12 @@ pub struct TurnContext { pub(crate) server_model_warning_emitted: AtomicBool, pub(crate) model_verification_emitted: AtomicBool, } + +enum TurnMultiAgentRuntime { + ResolveAndStore, + Preview, +} + impl TurnContext { pub(crate) fn permission_profile(&self) -> PermissionProfile { self.permission_profile.clone() @@ -246,6 +255,7 @@ impl TurnContext { compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), collaboration_mode, + multi_agent_version: self.multi_agent_version, personality: self.personality, approval_policy: self.approval_policy.clone(), permission_profile: self.permission_profile.clone(), @@ -353,7 +363,7 @@ impl TurnContext { model: self.model_info.slug.clone(), personality: self.personality, collaboration_mode: Some(self.collaboration_mode.clone()), - multi_agent_version: None, + multi_agent_version: Some(self.multi_agent_version), realtime_active: Some(self.realtime_active), effort: self.reasoning_effort, summary: ReasoningSummaryConfig::Auto, @@ -455,6 +465,7 @@ impl Session { session_telemetry: &SessionTelemetry, provider: ModelProviderInfo, session_configuration: &SessionConfiguration, + multi_agent_version: MultiAgentVersion, user_shell: &shell::Shell, shell_zsh_path: Option<&PathBuf>, main_execve_wrapper_exe: Option<&PathBuf>, @@ -544,6 +555,7 @@ impl Session { compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), collaboration_mode: session_configuration.collaboration_mode.clone(), + multi_agent_version, personality: session_configuration.personality, approval_policy: session_configuration.approval_policy.clone(), permission_profile: session_configuration.permission_profile(), @@ -685,8 +697,43 @@ impl Session { final_output_json_schema: Option>, turn_environments: ResolvedTurnEnvironments, ) -> Arc { - let primary_turn_environment = turn_environments.primary(); + self.new_turn_context_from_configuration( + sub_id, + session_configuration, + final_output_json_schema, + turn_environments, + TurnMultiAgentRuntime::ResolveAndStore, + ) + .await + } + + async fn new_startup_prewarm_turn_from_configuration( + &self, + sub_id: String, + session_configuration: SessionConfiguration, + turn_environments: ResolvedTurnEnvironments, + ) -> Arc { + self.new_turn_context_from_configuration( + sub_id, + session_configuration, + /*final_output_json_schema*/ None, + turn_environments, + TurnMultiAgentRuntime::Preview, + ) + .await + } + + async fn new_turn_context_from_configuration( + &self, + sub_id: String, + session_configuration: SessionConfiguration, + final_output_json_schema: Option>, + turn_environments: ResolvedTurnEnvironments, + multi_agent_runtime: TurnMultiAgentRuntime, + ) -> Arc { + let primary_turn_environment = turn_environments.primary().cloned(); let cwd = primary_turn_environment + .as_ref() .map(|turn_environment| turn_environment.cwd.clone()) .unwrap_or_else(|| session_configuration.cwd.clone()); let per_turn_config = Self::build_per_turn_config(&session_configuration, cwd.clone()); @@ -705,6 +752,15 @@ impl Session { &per_turn_config.to_models_manager_config(), ) .await; + + let multi_agent_version = match multi_agent_runtime { + TurnMultiAgentRuntime::ResolveAndStore => { + self.resolve_multi_agent_version_for_model(&model_info, &per_turn_config) + } + TurnMultiAgentRuntime::Preview => model_info + .multi_agent_version + .unwrap_or_else(|| per_turn_config.multi_agent_version_from_features()), + }; let plugin_outcome = self .services .plugins_manager @@ -728,6 +784,7 @@ impl Session { &self.services.session_telemetry, session_configuration.provider.clone(), &session_configuration, + multi_agent_version, self.services.user_shell.as_ref(), self.services.shell_zsh_path.as_ref(), self.services.main_execve_wrapper_exe.as_ref(), @@ -781,6 +838,34 @@ impl Session { } pub(crate) async fn new_default_turn_with_sub_id(&self, sub_id: String) -> Arc { + let (session_configuration, turn_environments) = + self.default_turn_configuration_and_environments().await; + self.new_turn_from_configuration( + sub_id, + session_configuration, + /*final_output_json_schema*/ None, + turn_environments, + ) + .await + } + + pub(crate) async fn new_startup_prewarm_turn_with_sub_id( + &self, + sub_id: String, + ) -> Arc { + let (session_configuration, turn_environments) = + self.default_turn_configuration_and_environments().await; + self.new_startup_prewarm_turn_from_configuration( + sub_id, + session_configuration, + turn_environments, + ) + .await + } + + async fn default_turn_configuration_and_environments( + &self, + ) -> (SessionConfiguration, ResolvedTurnEnvironments) { let session_configuration = { let state = self.state.lock().await; state.session_configuration.clone() @@ -797,14 +882,7 @@ impl Session { ResolvedTurnEnvironments::default() } }; - - self.new_turn_from_configuration( - sub_id, - session_configuration, - /*final_output_json_schema*/ None, - turn_environments, - ) - .await + (session_configuration, turn_environments) } fn overlay_runtime_cwd_on_primary_environment( diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index 0669e54469..13e0871e92 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -222,7 +222,7 @@ async fn schedule_startup_prewarm_inner( ) -> CodexResult { let prewarm_started_at = Instant::now(); let startup_turn_context = session - .new_default_turn_with_sub_id(INITIAL_SUBMIT_ID.to_owned()) + .new_startup_prewarm_turn_with_sub_id(INITIAL_SUBMIT_ID.to_owned()) .await; startup_turn_context.session_telemetry.record_startup_phase( "startup_prewarm_create_turn_context", diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 38a8a96276..76d5e6478e 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -44,6 +44,7 @@ use codex_otel::TURN_TOKEN_USAGE_METRIC; use codex_otel::TURN_TOOL_CALL_METRIC; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; @@ -70,11 +71,14 @@ pub(crate) enum InterruptedTurnHistoryMarker { } impl InterruptedTurnHistoryMarker { - pub(crate) fn from_config(config: &Config) -> Self { + pub(crate) fn from_config_and_version( + config: &Config, + multi_agent_version: MultiAgentVersion, + ) -> Self { if !config.agent_interrupt_message_enabled { return Self::Disabled; } - if config.features.enabled(Feature::MultiAgentV2) { + if multi_agent_version == MultiAgentVersion::V2 { Self::Developer } else { Self::ContextualUser @@ -841,7 +845,10 @@ impl Session { if reason == TurnAbortReason::Interrupted && let Some(marker) = interrupted_turn_history_marker( - InterruptedTurnHistoryMarker::from_config(task.turn_context.config.as_ref()), + InterruptedTurnHistoryMarker::from_config_and_version( + task.turn_context.config.as_ref(), + task.turn_context.multi_agent_version, + ), ) { self.record_conversation_items( diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 3624440b24..0b8ebaa144 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -12,6 +12,7 @@ use crate::session::Codex; use crate::session::CodexSpawnArgs; use crate::session::CodexSpawnOk; use crate::session::INITIAL_SUBMIT_ID; +use crate::session::resolve_multi_agent_version; use crate::shell_snapshot::ShellSnapshot; use crate::tasks::InterruptedTurnHistoryMarker; use crate::tasks::interrupted_turn_history_marker; @@ -38,6 +39,7 @@ use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::Op; use codex_protocol::protocol::ResumedHistory; use codex_protocol::protocol::RolloutItem; @@ -639,10 +641,16 @@ impl ThreadManager { )) })?; let history = stored_thread_to_initial_history(stored_thread, fork_source.rollout_path())?; + let inherited_multi_agent_version = fork_source + .multi_agent_version() + .unwrap_or(MultiAgentVersion::V1); options.initial_history = fork_history_from_snapshot( ForkSnapshot::Interrupted, history, - InterruptedTurnHistoryMarker::from_config(&options.config), + InterruptedTurnHistoryMarker::from_config_and_version( + &options.config, + inherited_multi_agent_version, + ), ); self.start_thread_with_options_and_fork_source(options, Some(forked_from_thread_id)) .await @@ -894,7 +902,18 @@ impl ThreadManager { InitialHistory::Forked(_) => history.forked_from_id(), InitialHistory::New | InitialHistory::Cleared => None, }; - let interrupted_marker = InterruptedTurnHistoryMarker::from_config(&config); + let multi_agent_version = self + .state + .effective_multi_agent_version_for_spawn( + &history, + /*session_source*/ None, + /*parent_thread_id*/ None, + forked_from_thread_id, + &config, + ) + .await; + let interrupted_marker = + InterruptedTurnHistoryMarker::from_config_and_version(&config, multi_agent_version); let history = fork_history_from_snapshot(snapshot, history, interrupted_marker); let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), @@ -1018,6 +1037,52 @@ impl ThreadManagerState { self.threads.write().await.remove(thread_id) } + pub(crate) async fn effective_multi_agent_version_for_spawn( + &self, + initial_history: &InitialHistory, + session_source: Option<&SessionSource>, + parent_thread_id: Option, + forked_from_thread_id: Option, + config: &Config, + ) -> MultiAgentVersion { + self.initial_multi_agent_version_for_spawn( + initial_history, + session_source, + parent_thread_id, + forked_from_thread_id, + ) + .await + .unwrap_or_else(|| config.multi_agent_version_from_features()) + } + + async fn initial_multi_agent_version_for_spawn( + &self, + initial_history: &InitialHistory, + session_source: Option<&SessionSource>, + parent_thread_id: Option, + forked_from_thread_id: Option, + ) -> Option { + let inherited_thread_id = match session_source { + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, .. + })) => Some(*parent_thread_id), + _ => match initial_history { + InitialHistory::Resumed(resumed) => Some(resumed.conversation_id), + InitialHistory::Forked(_) => forked_from_thread_id.or(parent_thread_id), + InitialHistory::New | InitialHistory::Cleared => parent_thread_id, + }, + }; + let inherited_multi_agent_version = match inherited_thread_id { + Some(thread_id) => self + .get_thread(thread_id) + .await + .ok() + .and_then(|thread| thread.multi_agent_version()), + None => None, + }; + resolve_multi_agent_version(initial_history, inherited_multi_agent_version) + } + /// Spawn a new thread with no history using a provided config. pub(crate) async fn spawn_new_thread( &self, @@ -1233,6 +1298,14 @@ impl ThreadManagerState { .parent_rollout_thread_trace_for_source(&session_source, &initial_history) .await; let tracked_session_source = session_source.clone(); + let multi_agent_version = self + .initial_multi_agent_version_for_spawn( + &initial_history, + Some(&session_source), + parent_thread_id, + forked_from_thread_id, + ) + .await; let CodexSpawnOk { codex, thread_id, .. } = Codex::spawn(CodexSpawnArgs { @@ -1262,6 +1335,7 @@ impl ThreadManagerState { analytics_events_client: self.analytics_events_client.clone(), thread_store: Arc::clone(&self.thread_store), attestation_provider: self.attestation_provider.clone(), + inherited_multi_agent_version: multi_agent_version, }) .await?; let new_thread = self diff --git a/codex-rs/core/src/tools/handlers/agent_jobs.rs b/codex-rs/core/src/tools/handlers/agent_jobs.rs index 821b3619c4..48ba087434 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs.rs @@ -1,6 +1,4 @@ use crate::agent::control::SpawnAgentOptions; -use crate::agent::exceeds_thread_spawn_depth_limit; -use crate::agent::next_thread_spawn_depth; use crate::agent::status::is_final; use crate::config::Config; use crate::function_tool::FunctionCallError; @@ -11,6 +9,7 @@ use crate::tools::handlers::parse_arguments; use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; use codex_protocol::protocol::AgentStatus; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; @@ -111,21 +110,22 @@ async fn build_runner_options( turn: &Arc, requested_concurrency: Option, ) -> Result { - let session_source = turn.session_source.clone(); - let child_depth = next_thread_spawn_depth(&session_source); - let max_depth = turn.config.agent_max_depth; - if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { + let multi_agent_version = turn.multi_agent_version; + if multi_agent_version == MultiAgentVersion::Disabled { return Err(FunctionCallError::RespondToModel( - "agent depth limit reached; this session cannot spawn more subagents".to_string(), + "multi-agent runtime is disabled; this session cannot spawn workers".to_string(), )); } - if turn.config.agent_max_threads == Some(0) { + let agent_max_threads = turn + .config + .effective_agent_max_threads(multi_agent_version) + .map_err(|err| FunctionCallError::Fatal(err.to_string()))?; + if agent_max_threads == Some(0) { return Err(FunctionCallError::RespondToModel( "agent thread limit reached; this session cannot spawn more subagents".to_string(), )); } - let max_concurrency = - normalize_concurrency(requested_concurrency, turn.config.agent_max_threads); + let max_concurrency = normalize_concurrency(requested_concurrency, agent_max_threads); let base_instructions = session.get_base_instructions().await; let spawn_config = build_agent_spawn_config(&base_instructions, turn.as_ref())?; Ok(JobRunnerOptions { diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index ebe3678eb9..78b6aafb96 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -181,7 +181,7 @@ async fn try_resume_closed_agent( receiver_thread_id: ThreadId, child_depth: i32, ) -> Result<(), FunctionCallError> { - let config = build_agent_resume_config(turn.as_ref(), child_depth)?; + let config = build_agent_resume_config(turn.as_ref())?; Box::pin(session.services.agent_control.resume_agent_from_rollout( config, receiver_thread_id, 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 56707fb0ca..108ddd336a 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -116,7 +116,6 @@ async fn handle_spawn_agent( ) .await?; apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; - apply_spawn_agent_overrides(&mut config, child_depth); let result = Box::pin(session.services.agent_control.spawn_agent_with_metadata( config, 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 f1e7bcd429..8a4edbff35 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -8,7 +8,6 @@ use crate::session::turn_context::TurnContext; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; -use codex_features::Feature; use codex_models_manager::manager::RefreshStrategy; use codex_protocol::AgentPath; use codex_protocol::ThreadId; @@ -211,12 +210,8 @@ pub(crate) fn build_agent_spawn_config( Ok(config) } -pub(crate) fn build_agent_resume_config( - turn: &TurnContext, - child_depth: i32, -) -> Result { +pub(crate) fn build_agent_resume_config(turn: &TurnContext) -> Result { let mut config = build_agent_shared_config(turn)?; - apply_spawn_agent_overrides(&mut config, child_depth); // For resume, keep base instructions sourced from rollout/session metadata. config.base_instructions = None; Ok(config) @@ -280,13 +275,6 @@ pub(crate) fn apply_spawn_agent_runtime_overrides( Ok(()) } -pub(crate) fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) { - if child_depth >= config.agent_max_depth && !config.features.enabled(Feature::MultiAgentV2) { - let _ = config.features.disable(Feature::SpawnCsv); - let _ = config.features.disable(Feature::Collab); - } -} - pub(crate) async fn apply_requested_spawn_agent_model_overrides( session: &Session, turn: &TurnContext, 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 e3faf0742e..7a27197eae 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -134,6 +134,11 @@ model_reasoning_effort = "minimal" role_name } +fn set_turn_config(turn: &mut TurnContext, config: crate::config::Config) { + turn.multi_agent_version = config.multi_agent_version_from_features(); + turn.config = Arc::new(config); +} + fn expect_text_output(output: T) -> (String, Option) where T: ToolOutput, @@ -382,6 +387,7 @@ async fn multi_agent_v2_spawn_fork_turns_all_rejects_agent_type_override() { .expect("test config should allow feature update"); let turn = TurnContext { config: Arc::new(config), + multi_agent_version: codex_protocol::protocol::MultiAgentVersion::V2, ..turn }; @@ -424,7 +430,7 @@ async fn multi_agent_v2_spawn_defaults_to_full_fork_and_rejects_child_model_over .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let err = SpawnAgentHandlerV2::default() .handle(invocation( @@ -891,7 +897,7 @@ async fn multi_agent_v2_full_history_fork_accepts_explicit_service_tier() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let manager = thread_manager(); let root = manager .start_thread((*turn.config).clone()) @@ -959,6 +965,7 @@ async fn multi_agent_v2_spawn_partial_fork_turns_allows_agent_type_override() { .expect("test config should allow feature update"); let turn = TurnContext { config: Arc::new(config), + multi_agent_version: codex_protocol::protocol::MultiAgentVersion::V2, ..turn }; @@ -1040,7 +1047,7 @@ async fn multi_agent_v2_spawn_requires_task_name() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let invocation = invocation( Arc::new(session), @@ -1074,7 +1081,7 @@ async fn multi_agent_v2_spawn_rejects_legacy_items_field() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let invocation = invocation( Arc::new(session), @@ -1134,7 +1141,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -1231,7 +1238,7 @@ async fn multi_agent_v2_spawn_rejects_legacy_fork_context() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let err = SpawnAgentHandlerV2::default() .handle(invocation( @@ -1271,7 +1278,7 @@ async fn multi_agent_v2_spawn_rejects_invalid_fork_turns_string() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let err = SpawnAgentHandlerV2::default() .handle(invocation( @@ -1311,7 +1318,7 @@ async fn multi_agent_v2_spawn_rejects_zero_fork_turns() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let err = SpawnAgentHandlerV2::default() .handle(invocation( @@ -1340,18 +1347,18 @@ async fn multi_agent_v2_spawn_rejects_zero_fork_turns() { async fn multi_agent_v2_send_message_accepts_root_target_from_child() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + set_turn_config(&mut turn, config); 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 mut config = (*turn.config).clone(); - config - .features - .enable(Feature::MultiAgentV2) - .expect("test config should allow feature update"); - turn.config = Arc::new(config); let child_path = AgentPath::try_from("/root/worker").expect("agent path"); let child_thread_id = session @@ -1416,18 +1423,18 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() { async fn multi_agent_v2_followup_task_rejects_root_target_from_child() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + set_turn_config(&mut turn, config); 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 mut config = (*turn.config).clone(); - config - .features - .enable(Feature::MultiAgentV2) - .expect("test config should allow feature update"); - turn.config = Arc::new(config); let child_path = AgentPath::try_from("/root/worker").expect("agent path"); let child_thread_id = session @@ -1507,7 +1514,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa session.conversation_id = root.thread_id; let mut config = (*turn.config).clone(); let _ = config.features.enable(Feature::MultiAgentV2); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -1593,15 +1600,15 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); + let mut config = (*turn.config).clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + set_turn_config(&mut turn, config.clone()); 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 mut config = (*turn.config).clone(); - let _ = config.features.enable(Feature::MultiAgentV2); - turn.config = Arc::new(config.clone()); let researcher_path = AgentPath::from_string("/root/researcher".to_string()).expect("path"); let worker_path = AgentPath::from_string("/root/researcher/worker".to_string()).expect("path"); @@ -1688,7 +1695,7 @@ async fn multi_agent_v2_list_agents_omits_closed_agents() { session.conversation_id = root.thread_id; let mut config = (*turn.config).clone(); let _ = config.features.enable(Feature::MultiAgentV2); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -1752,7 +1759,7 @@ async fn multi_agent_v2_send_message_rejects_legacy_items_field() { session.conversation_id = root.thread_id; let mut config = turn.config.as_ref().clone(); let _ = config.features.enable(Feature::MultiAgentV2); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -1808,7 +1815,7 @@ async fn multi_agent_v2_send_message_rejects_interrupt_parameter() { session.conversation_id = root.thread_id; let mut config = turn.config.as_ref().clone(); let _ = config.features.enable(Feature::MultiAgentV2); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -1873,15 +1880,15 @@ async fn multi_agent_v2_send_message_rejects_interrupt_parameter() { async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); + let mut config = turn.config.as_ref().clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + set_turn_config(&mut turn, config); 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 mut config = turn.config.as_ref().clone(); - let _ = config.features.enable(Feature::MultiAgentV2); - turn.config = Arc::new(config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -2016,7 +2023,7 @@ async fn multi_agent_v2_followup_task_rejects_legacy_items_field() { session.conversation_id = root.thread_id; let mut config = turn.config.as_ref().clone(); let _ = config.features.enable(Feature::MultiAgentV2); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -2069,7 +2076,7 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { session.conversation_id = root.thread_id; let mut config = turn.config.as_ref().clone(); let _ = config.features.enable(Feature::MultiAgentV2); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -2149,7 +2156,7 @@ async fn multi_agent_v2_spawn_omits_agent_id_when_named() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let output = SpawnAgentHandlerV2::default() .handle(invocation( @@ -2188,7 +2195,7 @@ async fn multi_agent_v2_spawn_surfaces_task_name_validation_errors() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let invocation = invocation( Arc::new(session), @@ -2398,7 +2405,7 @@ async fn multi_agent_v2_spawn_agent_ignores_configured_max_depth() { .expect("root thread should start"); session.services.agent_control = manager.agent_control(); session.conversation_id = root.thread_id; - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let parent_path = AgentPath::try_from("/root/parent").expect("agent path"); turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: root.thread_id, @@ -2871,7 +2878,7 @@ async fn multi_agent_v2_wait_agent_accepts_timeout_only_argument() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -2956,7 +2963,7 @@ async fn multi_agent_v2_wait_agent_rejects_timeout_below_configured_min() { config.multi_agent_v2.min_wait_timeout_ms = 50; config.multi_agent_v2.max_wait_timeout_ms = 1_000; config.multi_agent_v2.default_wait_timeout_ms = 50; - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let Err(err) = WaitAgentHandlerV2::default() .handle(invocation( @@ -2986,7 +2993,7 @@ async fn multi_agent_v2_wait_agent_accepts_explicit_timeout_at_configured_min() config.multi_agent_v2.min_wait_timeout_ms = 1; config.multi_agent_v2.max_wait_timeout_ms = 1_000; config.multi_agent_v2.default_wait_timeout_ms = 50; - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let output = WaitAgentHandlerV2::default() .handle(invocation( @@ -3021,7 +3028,7 @@ async fn multi_agent_v2_wait_agent_uses_configured_default_timeout() { config.multi_agent_v2.min_wait_timeout_ms = 1; config.multi_agent_v2.max_wait_timeout_ms = 1_000; config.multi_agent_v2.default_wait_timeout_ms = 50; - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3076,7 +3083,7 @@ async fn multi_agent_v2_wait_agent_allows_zero_configured_timeout() { config.multi_agent_v2.min_wait_timeout_ms = 0; config.multi_agent_v2.max_wait_timeout_ms = 0; config.multi_agent_v2.default_wait_timeout_ms = 0; - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3116,7 +3123,7 @@ async fn multi_agent_v2_wait_agent_rejects_timeout_above_configured_max() { config.multi_agent_v2.min_wait_timeout_ms = 1; config.multi_agent_v2.max_wait_timeout_ms = 50; config.multi_agent_v2.default_wait_timeout_ms = 1; - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let Err(err) = WaitAgentHandlerV2::default() .handle(invocation( @@ -3146,7 +3153,7 @@ async fn multi_agent_v2_wait_agent_accepts_explicit_timeout_at_configured_max() config.multi_agent_v2.min_wait_timeout_ms = 1; config.multi_agent_v2.max_wait_timeout_ms = 1; config.multi_agent_v2.default_wait_timeout_ms = 1; - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let output = WaitAgentHandlerV2::default() .handle(invocation( @@ -3354,7 +3361,7 @@ async fn multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3448,7 +3455,7 @@ async fn multi_agent_v2_wait_agent_returns_for_already_queued_mail() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3529,7 +3536,7 @@ async fn multi_agent_v2_wait_agent_wakes_on_any_mailbox_notification() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3620,7 +3627,7 @@ async fn multi_agent_v2_wait_agent_does_not_return_completed_content() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3709,7 +3716,7 @@ async fn multi_agent_v2_close_agent_accepts_task_name_target() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3757,7 +3764,7 @@ async fn multi_agent_v2_close_agent_accepts_task_name_target() { async fn multi_agent_v2_close_agent_reaps_stale_task_name_target() { let (mut session, mut turn) = make_session_and_context().await; let mut config = (*turn.config).clone(); - config.agent_max_threads = Some(1); + config.multi_agent_v2.max_concurrent_threads_per_session = 2; config .features .enable(Feature::MultiAgentV2) @@ -3782,7 +3789,7 @@ async fn multi_agent_v2_close_agent_reaps_stale_task_name_target() { .expect("root thread should start"); session.services.agent_control = manager.agent_control(); session.conversation_id = root.thread_id; - turn.config = Arc::new(config.clone()); + set_turn_config(&mut turn, config.clone()); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3888,7 +3895,7 @@ async fn multi_agent_v2_close_agent_rejects_root_target_and_id() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -4283,7 +4290,7 @@ async fn build_agent_resume_config_clears_base_instructions() { .set(AskForApproval::OnRequest) .expect("approval policy set"); - let config = build_agent_resume_config(&turn, /*child_depth*/ 0).expect("resume config"); + let config = build_agent_resume_config(&turn).expect("resume config"); let mut expected = (*turn.config).clone(); expected.base_instructions = None; 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 f767f88d78..ed85d329bd 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 @@ -108,7 +108,6 @@ async fn handle_spawn_agent( ) .await?; apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; - apply_spawn_agent_overrides(&mut config, child_depth); let spawn_source = thread_spawn_source( session.conversation_id, diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 56d3714074..9d5aa8f8ee 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -1,3 +1,5 @@ +use crate::agent::exceeds_thread_spawn_depth_limit; +use crate::agent::next_thread_spawn_depth; use crate::session::turn_context::TurnContext; use crate::tools::code_mode::execute_spec::create_code_mode_tool; use crate::tools::context::ToolInvocation; @@ -61,6 +63,7 @@ use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ToolMode; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_tools::DiscoverableTool; @@ -288,11 +291,18 @@ fn namespace_tools_enabled(turn_context: &TurnContext) -> bool { } fn multi_agent_v2_enabled(turn_context: &TurnContext) -> bool { - turn_context.features.get().enabled(Feature::MultiAgentV2) + turn_context.multi_agent_version == MultiAgentVersion::V2 } fn collab_tools_enabled(turn_context: &TurnContext) -> bool { - multi_agent_v2_enabled(turn_context) || turn_context.features.get().enabled(Feature::Collab) + match turn_context.multi_agent_version { + MultiAgentVersion::Disabled => false, + MultiAgentVersion::V1 => !exceeds_thread_spawn_depth_limit( + next_thread_spawn_depth(&turn_context.session_source), + turn_context.config.agent_max_depth, + ), + MultiAgentVersion::V2 => true, + } } fn goal_tools_enabled(turn_context: &TurnContext) -> bool { @@ -304,7 +314,7 @@ fn goal_tools_enabled(turn_context: &TurnContext) -> bool { } fn agent_jobs_tools_enabled(turn_context: &TurnContext) -> bool { - turn_context.features.get().enabled(Feature::SpawnCsv) + turn_context.features.get().enabled(Feature::SpawnCsv) && collab_tools_enabled(turn_context) } fn agent_jobs_worker_tools_enabled(turn_context: &TurnContext) -> bool { diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index b75e6d2ed2..1cb36e474d 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -215,6 +215,7 @@ fn set_feature(turn: &mut TurnContext, feature: Feature, enabled: bool) { .disable(feature) .expect("test feature should be disableable in config"); } + turn.multi_agent_version = config.multi_agent_version_from_features(); turn.config = Arc::new(config); turn.tool_mode = turn.model_info.tool_mode.unwrap_or_else(|| { if turn.config.features.enabled(Feature::CodeModeOnly) { From 3cf6f08da562ee1bf6866fbc2db45a3c3af620ff Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 2 Jun 2026 14:35:26 +0200 Subject: [PATCH 13/59] session: keep startup prewarm aligned with resolved multi-agent runtime (#25841) ## Why Follow-up to #25722. Startup prewarm builds a preview `TurnContext` before the first real turn so it can precompute the initial prompt and tool surface. After the per-thread runtime work landed, that preview path still recomputed multi-agent mode from `model_info` and feature defaults instead of reusing the runtime the session had already resolved from persisted metadata or inheritance. That could leave the prewarmed session primed for a different multi-agent mode than the first real turn, which is especially risky because collaboration tool exposure depends on `turn_context.multi_agent_version`. ## What changed - In the `TurnMultiAgentRuntime::Preview` path, prefer `Session::multi_agent_version()` when it is already known. - Only fall back to `model_info.multi_agent_version` and feature defaults when the session has not resolved a runtime yet. - Keep preview mode read-only: this still avoids storing a runtime during startup prewarm. ## Testing - Not run (small runtime-selection follow-up) --- codex-rs/core/src/session/turn_context.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 9593984301..d600368137 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -757,8 +757,9 @@ impl Session { TurnMultiAgentRuntime::ResolveAndStore => { self.resolve_multi_agent_version_for_model(&model_info, &per_turn_config) } - TurnMultiAgentRuntime::Preview => model_info - .multi_agent_version + TurnMultiAgentRuntime::Preview => self + .multi_agent_version() + .or(model_info.multi_agent_version) .unwrap_or_else(|| per_turn_config.multi_agent_version_from_features()), }; let plugin_outcome = self From 06e9a33d090d7d1d66ff4df0a423424c95f78123 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 2 Jun 2026 14:48:04 +0200 Subject: [PATCH 14/59] fix: main oops (#25840) Fix main, comment is self-explainatory --- codex-rs/core/src/tools/handlers/multi_agents_tests.rs | 3 +++ 1 file changed, 3 insertions(+) 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 7a27197eae..d4ce84ad55 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1887,6 +1887,9 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() .start_thread((*turn.config).clone()) .await .expect("root thread should start"); + // Production spawn_agent calls happen after the parent turn has resolved + // and stored its runtime; mirror that before using the synthetic handler. + root.thread.codex.session.new_default_turn().await; session.services.agent_control = manager.agent_control(); session.conversation_id = root.thread_id; let session = Arc::new(session); From 66991c949f4352577d7d3bf83aa63abd8af15b95 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 2 Jun 2026 14:48:13 +0200 Subject: [PATCH 15/59] Test remote multi-agent runtime selector override (#25723) Stack split from #25708. Original PR intentionally left open. This fourth PR adds coverage that remote model multi-agent runtime selectors override local feature flag defaults. --- .../tests/suite/model_runtime_selectors.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/codex-rs/core/tests/suite/model_runtime_selectors.rs b/codex-rs/core/tests/suite/model_runtime_selectors.rs index b6f2ea3c7d..90755940d8 100644 --- a/codex-rs/core/tests/suite/model_runtime_selectors.rs +++ b/codex-rs/core/tests/suite/model_runtime_selectors.rs @@ -11,6 +11,7 @@ use codex_protocol::openai_models::ModelVisibility; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ToolMode; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::Op; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; @@ -171,3 +172,40 @@ async fn remote_tool_mode_selector_overrides_feature_flags() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_multi_agent_selector_overrides_feature_flags() -> Result<()> { + skip_if_no_network!(Ok(())); + + let mut v2_model = remote_model("test-multi-agent-v2"); + v2_model.multi_agent_version = Some(MultiAgentVersion::V2); + let v2_body = response_body_for_remote_model(v2_model, |config| { + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + config + .features + .disable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + }) + .await?; + assert!(tool_names(&v2_body).contains(&"send_message".to_string())); + + let mut disabled_model = remote_model("test-multi-agent-disabled"); + disabled_model.multi_agent_version = Some(MultiAgentVersion::Disabled); + let disabled_body = response_body_for_remote_model(disabled_model, |config| { + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + }) + .await?; + let disabled_tools = tool_names(&disabled_body); + assert!(disabled_tools.iter().all(|name| !matches!( + name.as_str(), + "multi_agent_v1" | "spawn_agent" | "send_message" | "wait_agent" | "list_agents" + ))); + + Ok(()) +} From 33273e4258386ad1a073827b32a6346e37536adc Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 2 Jun 2026 15:01:10 +0200 Subject: [PATCH 16/59] Test runtime selector before first turn (#25724) Stack split from #25708. Original PR intentionally left open. This fifth PR adds coverage that a remotely selected multi-agent runtime is applied when the model is selected before the first turn. --- .../tests/suite/model_runtime_selectors.rs | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/codex-rs/core/tests/suite/model_runtime_selectors.rs b/codex-rs/core/tests/suite/model_runtime_selectors.rs index 90755940d8..06385f6561 100644 --- a/codex-rs/core/tests/suite/model_runtime_selectors.rs +++ b/codex-rs/core/tests/suite/model_runtime_selectors.rs @@ -32,6 +32,10 @@ use tokio::time::Duration; use tokio::time::Instant; use tokio::time::sleep; +const CHILD_MODEL: &str = "test-multi-agent-child"; +const ROOT_MODEL: &str = "test-multi-agent-root"; +const ROOT_PROMPT: &str = "spawn a child"; + fn remote_model(slug: &str) -> ModelInfo { ModelInfo { visibility: ModelVisibility::List, @@ -209,3 +213,89 @@ async fn remote_multi_agent_selector_overrides_feature_flags() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_multi_agent_selector_uses_model_selected_before_first_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = wiremock::MockServer::start().await; + let mut initial_model = remote_model(ROOT_MODEL); + initial_model.multi_agent_version = Some(MultiAgentVersion::V1); + let mut selected_model = remote_model(CHILD_MODEL); + selected_model.multi_agent_version = Some(MultiAgentVersion::V2); + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![initial_model, selected_model], + }, + ) + .await; + let response_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config.model = Some(ROOT_MODEL.to_string()); + }); + let test = builder.build(&server).await?; + assert_eq!( + ( + models_mock.requests().len(), + test.codex.multi_agent_version(), + ), + (1, None) + ); + + submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + model: Some(CHILD_MODEL.to_string()), + ..Default::default() + }, + ) + .await?; + assert_eq!(test.codex.multi_agent_version(), None); + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: ROOT_PROMPT.into(), + text_elements: Vec::new(), + }], + environments: None, + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + assert_eq!( + ( + models_mock.requests().len(), + test.codex.multi_agent_version(), + tool_names( + &response_mock + .last_request() + .expect("expected response request") + .body_json(), + ) + .contains(&"send_message".to_string()), + ), + (1, Some(MultiAgentVersion::V2), true) + ); + + Ok(()) +} From 1dd731305ad709b792fb3bf345733d932ee426ed Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 2 Jun 2026 15:42:47 +0200 Subject: [PATCH 17/59] Reduce stack pressure in session startup and config rebuilds (#25844) ## Why `/clear` starts a fresh thread with `InitialHistory::Cleared`, which re-enters the thread/session startup path. That path now builds large async futures through `ThreadManagerState::spawn_thread_with_source`, `Codex::spawn`, and `Session::new`. Separately, TUI config rebuilds for cwd and permission-profile changes build a similarly heavy `ConfigBuilder::build()` future inside the app task. In debug and Bazel runs, those call chains can put enough state on the caller stack to abort before startup or config refresh completes. This change keeps the behavior the same while moving the heaviest future frames off the caller stack. ## What changed - Box `Codex::spawn(...)` in `codex-rs/core/src/thread_manager.rs` before awaiting it from `spawn_thread_with_source`. - Box `Session::new(...)` in `codex-rs/core/src/session/mod.rs` before awaiting it from `Codex::spawn_internal`. - Route `ConfigBuilder::build()` through a small `tokio::spawn` helper in `codex-rs/tui/src/app/config_persistence.rs` so cwd and permission-profile config rebuilds run on a runtime worker stack while preserving error context. ## Verification CI is running on the PR. No new targeted tests were added. This is a mechanical stack-pressure reduction that keeps the existing behavior and error propagation intact. --- codex-rs/core/src/session/mod.rs | 4 +-- codex-rs/core/src/thread_manager.rs | 4 +-- codex-rs/tui/src/app/config_persistence.rs | 37 +++++++++++++++------- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index a63b0a91f0..b3dcf504ee 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -625,7 +625,7 @@ impl Codex { let session_source_clone = session_configuration.session_source.clone(); let (agent_status_tx, agent_status_rx) = watch::channel(AgentStatus::PendingInit); - let session = Session::new( + let session = Box::pin(Session::new( session_configuration, config.clone(), installation_id, @@ -647,7 +647,7 @@ impl Codex { parent_rollout_thread_trace, attestation_provider, multi_agent_version, - ) + )) .await .map_err(|e| { error!("Failed to create session: {e:#}"); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 0b8ebaa144..d130a35815 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1308,7 +1308,7 @@ impl ThreadManagerState { .await; let CodexSpawnOk { codex, thread_id, .. - } = Codex::spawn(CodexSpawnArgs { + } = Box::pin(Codex::spawn(CodexSpawnArgs { config, installation_id: self.installation_id.clone(), auth_manager, @@ -1336,7 +1336,7 @@ impl ThreadManagerState { thread_store: Arc::clone(&self.thread_store), attestation_provider: self.attestation_provider.clone(), inherited_multi_agent_version: multi_agent_version, - }) + })) .await?; let new_thread = self .finalize_thread_spawn(codex, thread_id, tracked_session_source) diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 92ed5d470c..ec72c5974a 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -14,19 +14,32 @@ pub(super) struct WindowsSetupPermissions { pub(super) workspace_roots: Vec, } +async fn build_config_on_runtime_worker( + builder: ConfigBuilder, + error_context: String, +) -> Result { + match tokio::spawn(async move { builder.build().await }).await { + Ok(build_result) => build_result.wrap_err(error_context), + Err(err) if err.is_panic() => std::panic::resume_unwind(err.into_panic()), + Err(err) => Err(err).wrap_err_with(|| format!("{error_context} task failed")), + } +} + impl App { pub(super) async fn rebuild_config_for_cwd(&self, cwd: PathBuf) -> Result { let mut overrides = self.harness_overrides.clone(); overrides.cwd = Some(cwd.clone()); let cwd_display = cwd.display().to_string(); - ConfigBuilder::default() + let builder = ConfigBuilder::default() .codex_home(self.config.codex_home.to_path_buf()) .cli_overrides(self.cli_kv_overrides.clone()) .harness_overrides(overrides) - .loader_overrides(self.loader_overrides.clone()) - .build() - .await - .wrap_err_with(|| format!("Failed to rebuild config for cwd {cwd_display}")) + .loader_overrides(self.loader_overrides.clone()); + build_config_on_runtime_worker( + builder, + format!("Failed to rebuild config for cwd {cwd_display}"), + ) + .await } pub(super) async fn rebuild_config_for_permission_profile( @@ -38,16 +51,16 @@ impl App { overrides.sandbox_mode = None; overrides.permission_profile = None; overrides.default_permissions = Some(profile_id.to_string()); - ConfigBuilder::default() + let builder = ConfigBuilder::default() .codex_home(self.config.codex_home.to_path_buf()) .cli_overrides(self.cli_kv_overrides.clone()) .harness_overrides(overrides) - .loader_overrides(self.loader_overrides.clone()) - .build() - .await - .wrap_err_with(|| { - format!("Failed to rebuild config for permission profile {profile_id}") - }) + .loader_overrides(self.loader_overrides.clone()); + build_config_on_runtime_worker( + builder, + format!("Failed to rebuild config for permission profile {profile_id}"), + ) + .await } #[cfg(target_os = "windows")] From b9af5d1234429fa0c3de856b20200335330bb882 Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 2 Jun 2026 16:21:22 +0200 Subject: [PATCH 18/59] flake: Keep plugin test homes alive (#25857) ## Summary Keep the full `TestCodex` harness alive in plugin integration tests instead of returning only the `CodexThread`. ## Why The helper was moving a temporary `codex_home` into `TestCodex`, then immediately dropping the harness and returning only the thread. For plugin MCP tests, the MCP server cwd is inside that temporary home. If the temp directory is removed while MCP startup is still racing, the server launch can fail with `No such file or directory`. Keeping the harness in scope keeps the temp home alive for the test duration and removes the lifetime race behind the recent `explicit_plugin_mentions_inject_plugin_guidance` flake. ## Validation - `just fmt` - `just test -p codex-core explicit_plugin_mentions_inject_plugin_guidance` --- codex-rs/core/tests/suite/plugins.rs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index d7d7c7e8f5..3390e38ef6 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -18,6 +18,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::stdio_server_bin; +use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_mcp_server; @@ -101,7 +102,7 @@ fn write_plugin_app_plugin(home: &TempDir) { async fn build_analytics_plugin_test_codex( server: &MockServer, codex_home: Arc, -) -> Result> { +) -> Result { let chatgpt_base_url = server.uri(); let mut builder = test_codex() .with_home(codex_home) @@ -113,15 +114,14 @@ async fn build_analytics_plugin_test_codex( Ok(builder .build(server) .await - .expect("create new conversation") - .codex) + .expect("create new conversation")) } async fn build_apps_enabled_plugin_test_codex( server: &MockServer, codex_home: Arc, chatgpt_base_url: String, -) -> Result> { +) -> Result { let mut builder = test_codex() .with_home(codex_home) .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) @@ -135,8 +135,7 @@ async fn build_apps_enabled_plugin_test_codex( Ok(builder .build(server) .await - .expect("create new conversation") - .codex) + .expect("create new conversation")) } fn tool_names(body: &serde_json::Value) -> Vec { @@ -171,12 +170,13 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<() let codex_home = Arc::new(TempDir::new()?); write_plugin_skill_plugin(codex_home.as_ref()); write_plugin_app_plugin(codex_home.as_ref()); - let codex = build_apps_enabled_plugin_test_codex( + let test_codex = build_apps_enabled_plugin_test_codex( &server, Arc::clone(&codex_home), apps_server.chatgpt_base_url, ) .await?; + let codex = Arc::clone(&test_codex.codex); codex .submit(Op::UserInput { @@ -253,9 +253,10 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { write_plugin_mcp_plugin(codex_home.as_ref(), &rmcp_test_server_bin); write_plugin_app_plugin(codex_home.as_ref()); - let codex = + let test_codex = build_apps_enabled_plugin_test_codex(&server, codex_home, apps_server.chatgpt_base_url) .await?; + let codex = Arc::clone(&test_codex.codex); wait_for_mcp_server(&codex, "sample").await?; codex @@ -339,7 +340,8 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { let codex_home = Arc::new(TempDir::new()?); write_plugin_skill_plugin(codex_home.as_ref()); - let codex = build_analytics_plugin_test_codex(&server, codex_home).await?; + let test_codex = build_analytics_plugin_test_codex(&server, codex_home).await?; + let codex = Arc::clone(&test_codex.codex); codex .submit(Op::UserInput { From 3766941161b7a2e3cb4fed1441963e4317b7fb11 Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 2 Jun 2026 16:34:48 +0200 Subject: [PATCH 19/59] Run Codex async main on a sized stack (#25847) ## Why `Runtime::block_on` executes the top-level future on the caller's OS thread, not on one of Tokio's worker threads. That matters for the interactive CLI because the Tokio runtime already configures larger worker stacks, while the process main thread can still have a smaller platform default stack. This showed up as a `/clear` crash on macOS: starting a fresh TUI thread reloads config, and the stack-heavy TOML deserialization path can overflow before the new session is actually started. ## What Changed - Run the regular `arg0_dispatch_or_else` async entrypoint on a named `codex-main` thread. - Give that thread the same `TOKIO_WORKER_STACK_SIZE_BYTES` stack budget already used for Tokio worker threads. - Keep `Arg0DispatchPaths` and the arg0 alias guard lifetime behavior the same. - Resume panics from the spawned main thread so panic behavior is preserved. ## Verification - `cargo check -p codex-cli` currently fails because the top-level CLI/TUI future is not `Send` under the new thread boundary. --- codex-rs/arg0/src/lib.rs | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 2f6ae4653c..87940f1182 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -160,12 +160,13 @@ pub fn arg0_dispatch() -> Option { /// [`codex_linux_sandbox::run_main`] (which never returns). Otherwise we: /// /// 1. Load `.env` values from `~/.codex/.env` before creating any threads. -/// 2. Construct a Tokio multi-thread runtime. -/// 3. Capture the current executable path and derive the +/// 2. Spawn a main runtime thread with a controlled stack size. +/// 3. Construct a Tokio multi-thread runtime. +/// 4. Capture the current executable path and derive the /// `codex-linux-sandbox` helper path (falling back to the current /// executable if needed) so children can re-invoke the sandbox when running /// on Linux. -/// 4. Execute the provided async `main_fn` inside that runtime, forwarding any +/// 5. Execute the provided async `main_fn` inside that runtime, forwarding any /// error. Note that `main_fn` receives [`Arg0DispatchPaths`], which /// contains the helper executable paths needed to construct /// [`codex_core::config::Config`]. @@ -174,22 +175,33 @@ pub fn arg0_dispatch() -> Option { /// in this workspace that depends on these helper CLIs. pub fn arg0_dispatch_or_else(main_fn: F) -> anyhow::Result<()> where - F: FnOnce(Arg0DispatchPaths) -> Fut, + F: FnOnce(Arg0DispatchPaths) -> Fut + Send + 'static, Fut: Future>, { // Retain the TempDir so it exists for the lifetime of the invocation of // this executable. Admittedly, we could invoke `keep()` on it, but it // would be nice to avoid leaving temporary directories behind, if possible. let path_entry_guard = arg0_dispatch(); + let current_exe = std::env::current_exe().ok(); - // Regular invocation – create a Tokio runtime and execute the provided - // async entry-point. - let runtime = build_runtime()?; - runtime.block_on(run_main_with_arg0_guard( - path_entry_guard, - std::env::current_exe().ok(), - main_fn, - )) + // Regular invocation. Run the async entry point on a thread with the same + // stack budget as Tokio workers; `Runtime::block_on` otherwise runs the + // top-level future on the caller's OS stack. + let handle = std::thread::Builder::new() + .name("codex-main".to_string()) + .stack_size(TOKIO_WORKER_STACK_SIZE_BYTES) + .spawn(move || { + let runtime = build_runtime()?; + runtime.block_on(run_main_with_arg0_guard( + path_entry_guard, + current_exe, + main_fn, + )) + })?; + match handle.join() { + Ok(result) => result, + Err(payload) => std::panic::resume_unwind(payload), + } } async fn run_main_with_arg0_guard( From c097ad3e9e78f38699c6bec18f84b628f3fcde03 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 2 Jun 2026 07:39:41 -0700 Subject: [PATCH 20/59] [codex] Use git CLI for Cargo fetches across Rust workflows (#25775) ## Why Cargo's libgit2 transport has intermittently failed while fetching git dependencies with nested submodules. [#25644](https://github.com/openai/codex/pull/25644) applied `CARGO_NET_GIT_FETCH_WITH_CLI=true` to the main Rust release build after macOS SecureTransport/libgit2 failures while cloning `libwebrtc`'s nested `libyuv` submodule. Similar flakes can affect other Cargo-bearing Rust jobs. ## What changed Configure `CARGO_NET_GIT_FETCH_WITH_CLI=true` at workflow scope for the remaining Cargo-bearing Rust workflows: - fast Rust CI and `cargo-deny` - reusable Windows and argument-comment-lint release workflows - `rusty-v8-release` and `v8-canary` Cargo builds and smoke tests The full Rust CI, reusable nextest workflow, and primary Rust release build already had the override. Bazel-only workflows are unchanged because they use a different dependency fetch path. ## Validation - Parsed all `.github/workflows/*.yml` files as YAML. - Scanned Cargo-bearing workflows to confirm they configure `CARGO_NET_GIT_FETCH_WITH_CLI`. --- .github/workflows/cargo-deny.yml | 5 +++++ .github/workflows/rust-ci.yml | 5 +++++ .github/workflows/rust-release-argument-comment-lint.yml | 5 +++++ .github/workflows/rust-release-windows.yml | 5 +++++ .github/workflows/rusty-v8-release.yml | 5 +++++ .github/workflows/v8-canary.yml | 5 +++++ 6 files changed, 30 insertions(+) diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml index 656c50ef28..bbadb57f94 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -6,6 +6,11 @@ on: branches: - main +# Cargo's libgit2 transport has been flaky when fetching git dependencies with +# nested submodules. Prefer the system git CLI across every Cargo invocation. +env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + jobs: cargo-deny: runs-on: ubuntu-latest diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index d1d7c0df96..9b50ae4032 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -3,6 +3,11 @@ on: pull_request: {} workflow_dispatch: +# Cargo's libgit2 transport has been flaky when fetching git dependencies with +# nested submodules. Prefer the system git CLI across every Cargo invocation. +env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + jobs: # --- Detect what changed so the fast PR workflow only runs relevant jobs ---- changed: diff --git a/.github/workflows/rust-release-argument-comment-lint.yml b/.github/workflows/rust-release-argument-comment-lint.yml index 94f15af7d4..7f5ad01aa5 100644 --- a/.github/workflows/rust-release-argument-comment-lint.yml +++ b/.github/workflows/rust-release-argument-comment-lint.yml @@ -7,6 +7,11 @@ on: required: true type: boolean +# Cargo's libgit2 transport has been flaky when fetching git dependencies with +# nested submodules. Prefer the system git CLI across every Cargo invocation. +env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + jobs: skip: if: ${{ !inputs.publish }} diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 91887cbe0b..f0fd6b90fc 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -20,6 +20,11 @@ on: AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: required: true +# Cargo's libgit2 transport has been flaky when fetching git dependencies with +# nested submodules. Prefer the system git CLI across every Cargo invocation. +env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + jobs: build-windows-binaries: name: Build Windows binaries - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index d98cdb2c43..4b5320dcb9 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -5,6 +5,11 @@ on: tags: - "rusty-v8-v*.*.*" +# Cargo's libgit2 transport has been flaky when fetching git dependencies with +# nested submodules. Prefer the system git CLI for Cargo smoke tests. +env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + concurrency: group: ${{ github.workflow }}::${{ github.ref_name }} cancel-in-progress: false diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 6341f218a6..71ce5d7865 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -37,6 +37,11 @@ on: - "third_party/v8/**" workflow_dispatch: +# Cargo's libgit2 transport has been flaky when fetching git dependencies with +# nested submodules. Prefer the system git CLI for Cargo builds and smoke tests. +env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + concurrency: group: ${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }} cancel-in-progress: ${{ github.ref_name != 'main' }} From 4d80d808b4425fa31e086985264385b02e98932e Mon Sep 17 00:00:00 2001 From: Alex Zamoshchin Date: Tue, 2 Jun 2026 11:04:11 -0400 Subject: [PATCH 21/59] [app-server][core] Add connector-level Guardian reviewer overrides (#25167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context: https://openai.slack.com/archives/C0B4JAF0Q2C/p1779912328647229 ``` approvals_reviewer = "auto_review" [apps.connector_5f3c8c41a1e54ad7a76272c89e2554fa] enabled = true approvals_reviewer = "user" default_tools_approval_mode = "prompt" ``` Screenshot 2026-05-31 at 11 56 34 AM Screenshot 2026-05-31 at 11 52 42 AM ------- ``` approvals_reviewer = "user" [apps.connector_5f3c8c41a1e54ad7a76272c89e2554fa] enabled = true approvals_reviewer = "auto_review" default_tools_approval_mode = "prompt" ``` Screenshot 2026-05-31 at 12 02 27 PM Screenshot 2026-05-31 at 12 05 42 PM ## Summary - add `apps..approvals_reviewer` to override Guardian or user review routing per connected app - apply overrides across direct app MCP calls, delegated MCP prompts, and app-server MCP elicitation review while preserving global behavior for non-app MCP servers - expose and document the config through app-server v2 and generated schemas, while honoring global managed reviewer requirements --------- Co-authored-by: jif-oai --- .../codex_app_server_protocol.schemas.json | 10 +++ .../codex_app_server_protocol.v2.schemas.json | 10 +++ .../schema/json/v2/ConfigReadResponse.json | 10 +++ .../schema/typescript/v2/AppsConfig.ts | 3 +- .../src/protocol/v2/config.rs | 1 + codex-rs/app-server/README.md | 14 +++ .../src/config_manager_service_tests.rs | 1 + .../app-server/tests/suite/v2/config_rpc.rs | 13 +++ codex-rs/codex-mcp/src/mcp/mod.rs | 2 - codex-rs/codex-mcp/src/mcp/mod_tests.rs | 4 - codex-rs/config/src/types.rs | 4 + codex-rs/core/config.schema.json | 8 ++ codex-rs/core/src/codex_delegate.rs | 24 +++-- codex-rs/core/src/codex_delegate_tests.rs | 70 +++++++++++++++ codex-rs/core/src/connectors.rs | 30 +++++++ codex-rs/core/src/connectors_tests.rs | 90 +++++++++++++++++++ codex-rs/core/src/guardian/mod.rs | 1 + codex-rs/core/src/guardian/review.rs | 10 ++- codex-rs/core/src/guardian/tests.rs | 14 +++ codex-rs/core/src/mcp_tool_call.rs | 19 +++- codex-rs/core/src/mcp_tool_call_tests.rs | 1 + codex-rs/core/src/session/mcp.rs | 19 +++- 22 files changed, 337 insertions(+), 21 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index ee1a49069c..cdb8f95538 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 @@ -5952,6 +5952,16 @@ }, "AppConfig": { "properties": { + "approvals_reviewer": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ApprovalsReviewer" + }, + { + "type": "null" + } + ] + }, "default_tools_approval_mode": { "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 4d219f383b..6d859ee5f3 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 @@ -324,6 +324,16 @@ }, "AppConfig": { "properties": { + "approvals_reviewer": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ] + }, "default_tools_approval_mode": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index e714c0975d..09cf897898 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -19,6 +19,16 @@ }, "AppConfig": { "properties": { + "approvals_reviewer": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ] + }, "default_tools_approval_mode": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts index b22997de97..a4a0f22081 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts @@ -3,6 +3,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AppToolApproval } from "./AppToolApproval"; import type { AppToolsConfig } from "./AppToolsConfig"; +import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AppsDefaultConfig } from "./AppsDefaultConfig"; -export type AppsConfig = { _default: AppsDefaultConfig | null, } & ({ [key in string]?: { enabled: boolean, destructive_enabled: boolean | null, open_world_enabled: boolean | null, default_tools_approval_mode: AppToolApproval | null, default_tools_enabled: boolean | null, tools: AppToolsConfig | null, } }); +export type AppsConfig = { _default: AppsDefaultConfig | null, } & ({ [key in string]?: { enabled: boolean, approvals_reviewer: ApprovalsReviewer | null, destructive_enabled: boolean | null, open_world_enabled: boolean | null, default_tools_approval_mode: AppToolApproval | null, default_tools_enabled: boolean | null, tools: AppToolsConfig | null, } }); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 473bb1e03c..f40192ddc6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -200,6 +200,7 @@ pub struct AppToolsConfig { pub struct AppConfig { #[serde(default = "default_enabled")] pub enabled: bool, + pub approvals_reviewer: Option, pub destructive_enabled: Option, pub open_world_enabled: Option, pub default_tools_approval_mode: Option, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index c853eb50f3..f3b55337ce 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1716,6 +1716,20 @@ The server also emits `app/list/updated` notifications whenever either source (a } ``` +Connected apps may override the thread's approval reviewer in `config.toml`. +When omitted, the app inherits the top-level `approvals_reviewer` value: + +```toml +approvals_reviewer = "auto_review" + +[apps.demo-app] +approvals_reviewer = "user" +``` + +Setting the app value to `"user"` routes its approval prompts to the user +instead of Guardian; setting it to `"auto_review"` opts that app into Guardian +review when allowed by configuration requirements. + Invoke an app by inserting `$` in the text input. The slug is derived from the app name and lowercased with non-alphanumeric characters replaced by `-` (for example, "Demo App" becomes `$demo-app`). Add a `mention` input item (recommended) so the server uses the exact `app://` path rather than guessing by name. Plugins use the same `mention` item shape, but with `plugin://@` paths from `plugin/installed` or `plugin/list`. Example: diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index 5c6e3de5a1..da2437ca17 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -284,6 +284,7 @@ async fn write_value_supports_nested_app_paths() -> Result<()> { "app1".to_string(), AppConfig { enabled: false, + approvals_reviewer: None, destructive_enabled: None, open_world_enabled: None, default_tools_approval_mode: Some(AppToolApproval::Prompt), diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index dbcc12084a..649c4fe8e0 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -5,6 +5,7 @@ use app_test_support::test_tmp_path_buf; use app_test_support::to_response; use codex_app_server_protocol::AppConfig; use codex_app_server_protocol::AppToolApproval; +use codex_app_server_protocol::ApprovalsReviewer; use codex_app_server_protocol::AppsConfig; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ConfigBatchWriteParams; @@ -333,6 +334,7 @@ async fn config_read_includes_apps() -> Result<()> { r#" [apps.app1] enabled = false +approvals_reviewer = "user" destructive_enabled = false default_tools_approval_mode = "prompt" "#, @@ -368,6 +370,7 @@ default_tools_approval_mode = "prompt" "app1".to_string(), AppConfig { enabled: false, + approvals_reviewer: Some(ApprovalsReviewer::User), destructive_enabled: Some(false), open_world_enabled: None, default_tools_approval_mode: Some(AppToolApproval::Prompt), @@ -384,6 +387,16 @@ default_tools_approval_mode = "prompt" profile: None, } ); + assert_eq!( + origins + .get("apps.app1.approvals_reviewer") + .expect("origin") + .name, + ConfigLayerSource::User { + file: user_file.clone(), + profile: None, + } + ); assert_eq!( origins .get("apps.app1.destructive_enabled") diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 51a6f18686..9633655846 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -21,7 +21,6 @@ use codex_config::Constrained; use codex_config::McpServerConfig; use codex_config::McpServerTransportConfig; use codex_config::types::AppToolApproval; -use codex_config::types::ApprovalsReviewer; use codex_config::types::OAuthCredentialsStoreMode; use codex_login::CodexAuth; use codex_plugin::PluginCapabilitySummary; @@ -91,7 +90,6 @@ pub fn mcp_permission_prompt_is_auto_approved( #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct McpPermissionPromptAutoApproveContext { - pub approvals_reviewer: Option, pub tool_approval_mode: Option, } diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index d4d888f202..b29f7a9e58 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -1,7 +1,6 @@ use super::*; use codex_config::Constrained; use codex_config::types::AppToolApproval; -use codex_config::types::ApprovalsReviewer; use codex_login::CodexAuth; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; @@ -96,7 +95,6 @@ fn mcp_prompt_auto_approval_honors_approved_tools_in_all_permission_modes() { approval_policy, &PermissionProfile::read_only(), McpPermissionPromptAutoApproveContext { - approvals_reviewer: Some(ApprovalsReviewer::User), tool_approval_mode: Some(AppToolApproval::Approve), }, )); @@ -106,7 +104,6 @@ fn mcp_prompt_auto_approval_honors_approved_tools_in_all_permission_modes() { AskForApproval::OnRequest, &PermissionProfile::read_only(), McpPermissionPromptAutoApproveContext { - approvals_reviewer: Some(ApprovalsReviewer::AutoReview), tool_approval_mode: Some(AppToolApproval::Auto), }, )); @@ -118,7 +115,6 @@ fn mcp_prompt_auto_approval_rejects_auto_mode_in_default_permission_mode() { AskForApproval::OnRequest, &PermissionProfile::read_only(), McpPermissionPromptAutoApproveContext { - approvals_reviewer: Some(ApprovalsReviewer::User), tool_approval_mode: Some(AppToolApproval::Auto), }, )); diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 598b7fdee7..7b634a5188 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -425,6 +425,10 @@ pub struct AppConfig { #[serde(default = "default_enabled")] pub enabled: bool, + /// Reviewer for approval prompts from this app, overriding the thread default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approvals_reviewer: Option, + /// Whether tools with `destructive_hint = true` are allowed for this app. #[serde(default, skip_serializing_if = "Option::is_none")] pub destructive_enabled: Option, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 6dd85dffc2..b281b44bb7 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -102,6 +102,14 @@ "additionalProperties": false, "description": "Config values for a single app/connector.", "properties": { + "approvals_reviewer": { + "allOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + } + ], + "description": "Reviewer for approval prompts from this app, overriding the thread default." + }, "default_tools_approval_mode": { "allOf": [ { diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 646d5969e8..3132420e4c 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -34,6 +34,7 @@ use crate::config::Config; use crate::guardian::GuardianApprovalRequest; use crate::guardian::new_guardian_review_id; use crate::guardian::routes_approval_to_guardian; +use crate::guardian::routes_approval_to_guardian_with_reviewer; use crate::guardian::spawn_approval_request_review; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION; @@ -41,6 +42,7 @@ use crate::mcp_tool_call::MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC; use crate::mcp_tool_call::build_guardian_mcp_tool_review_request; use crate::mcp_tool_call::is_mcp_tool_approval_question_id; use crate::mcp_tool_call::lookup_mcp_tool_metadata; +use crate::mcp_tool_call::mcp_approvals_reviewer; use crate::session::Codex; use crate::session::CodexSpawnArgs; use crate::session::CodexSpawnOk; @@ -632,15 +634,14 @@ async fn handle_request_user_input( event: RequestUserInputEvent, cancel_token: &CancellationToken, ) { - if routes_approval_to_guardian(parent_ctx) - && let Some(response) = maybe_auto_review_mcp_request_user_input( - parent_session, - parent_ctx, - pending_mcp_invocations, - &event, - cancel_token, - ) - .await + if let Some(response) = maybe_auto_review_mcp_request_user_input( + parent_session, + parent_ctx, + pending_mcp_invocations, + &event, + cancel_token, + ) + .await { let _ = codex.submit(Op::UserInputAnswer { id, response }).await; return; @@ -694,6 +695,11 @@ async fn maybe_auto_review_mcp_request_user_input( &invocation.tool, ) .await; + let approvals_reviewer = + mcp_approvals_reviewer(parent_ctx, &invocation.server, metadata.as_ref()); + if !routes_approval_to_guardian_with_reviewer(parent_ctx, approvals_reviewer) { + return None; + } let review_cancel = cancel_token.child_token(); let review_rx = spawn_approval_request_review( Arc::clone(parent_session), diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 04f6ae3965..64e0c16416 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX; use async_channel::bounded; +use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::models::NetworkPermissions; use codex_protocol::models::ResponseItem; @@ -445,3 +446,72 @@ async fn delegated_mcp_guardian_abort_returns_synthetic_decline_answer() { }) ); } + +#[tokio::test] +async fn delegated_mcp_user_reviewer_waits_for_metadata_lookup() { + let (parent_session, parent_ctx, _rx_events) = + crate::session::tests::make_session_and_context_with_rx().await; + let pending_mcp_invocations = Arc::new(Mutex::new(HashMap::from([( + "call-1".to_string(), + McpInvocation { + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool: "dangerous_tool".to_string(), + arguments: None, + }, + )]))); + let cancel_token = CancellationToken::new(); + let manager = Arc::clone(&parent_session.services.mcp_connection_manager); + let (manager_locked_tx, manager_locked_rx) = std::sync::mpsc::sync_channel(0); + let (release_manager_tx, release_manager_rx) = std::sync::mpsc::sync_channel(0); + let manager_lock = tokio::task::spawn_blocking(move || { + let _manager_guard = manager.blocking_write(); + manager_locked_tx + .send(()) + .expect("manager lock receiver should remain open"); + release_manager_rx + .recv() + .expect("manager lock release sender should remain open"); + }); + manager_locked_rx + .recv_timeout(Duration::from_secs(1)) + .expect("manager write lock should be acquired"); + + let event = RequestUserInputEvent { + call_id: "call-1".to_string(), + turn_id: "child-turn-1".to_string(), + questions: vec![RequestUserInputQuestion { + id: format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_call-1"), + header: "Approve app tool call?".to_string(), + question: "Allow this app tool?".to_string(), + is_other: false, + is_secret: false, + options: None, + }], + }; + let response = maybe_auto_review_mcp_request_user_input( + &parent_session, + &parent_ctx, + &pending_mcp_invocations, + &event, + &cancel_token, + ); + tokio::pin!(response); + assert!( + timeout(Duration::from_millis(100), &mut response) + .await + .is_err(), + "manual reviewer should wait for MCP metadata" + ); + release_manager_tx + .send(()) + .expect("manager lock holder should remain open"); + manager_lock + .await + .expect("manager lock task should not panic"); + assert_eq!( + timeout(Duration::from_secs(1), response) + .await + .expect("manual reviewer should finish after MCP metadata lookup"), + None + ); +} diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index c0b49cb633..7a8344b3d7 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -26,6 +26,7 @@ use crate::plugins::list_tool_suggest_discoverable_plugins; use crate::session::INITIAL_SUBMIT_ID; use codex_config::AppsRequirementsToml; use codex_config::types::AppToolApproval; +use codex_config::types::ApprovalsReviewer; use codex_config::types::AppsConfigToml; use codex_config::types::ToolSuggestDiscoverableType; use codex_core_plugins::PluginsManager; @@ -571,6 +572,35 @@ pub(crate) fn codex_app_tool_is_enabled(config: &Config, tool_info: &ToolInfo) - .enabled } +pub(crate) fn mcp_approvals_reviewer( + config: &Config, + server_name: &str, + connector_id: Option<&str>, +) -> ApprovalsReviewer { + let app_reviewer = if server_name == CODEX_APPS_MCP_SERVER_NAME { + read_user_apps_config(config).and_then(|apps_config| { + connector_id + .and_then(|connector_id| apps_config.apps.get(connector_id)) + .and_then(|app| app.approvals_reviewer) + }) + } else { + None + }; + + if let Some(reviewer) = app_reviewer + && config + .config_layer_stack + .requirements() + .approvals_reviewer + .can_set(&reviewer) + .is_ok() + { + return reviewer; + } + + config.approvals_reviewer +} + fn read_apps_config(config: &Config) -> Option { let apps_config = read_user_apps_config(config); let had_apps_config = apps_config.is_some(); diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index 5db026c3b1..c01fd5467a 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -12,6 +12,7 @@ use codex_config::ConfigRequirementsToml; use codex_config::types::AppConfig; use codex_config::types::AppToolConfig; use codex_config::types::AppToolsConfig; +use codex_config::types::ApprovalsReviewer; use codex_config::types::AppsDefaultConfig; use codex_connectors::merge::plugin_connector_to_app_info; use codex_connectors::metadata::connector_install_url; @@ -377,6 +378,7 @@ fn app_is_enabled_prefers_per_app_override_over_default() { "calendar".to_string(), AppConfig { enabled: true, + approvals_reviewer: None, destructive_enabled: None, open_world_enabled: None, default_tools_approval_mode: None, @@ -390,6 +392,88 @@ fn app_is_enabled_prefers_per_app_override_over_default() { assert!(!app_is_enabled(&apps_config, Some("drive"))); } +#[tokio::test] +async fn app_approvals_reviewer_overrides_global_reviewer() { + for (global, app, expected_global, expected_app) in [ + ( + "user", + "auto_review", + ApprovalsReviewer::User, + ApprovalsReviewer::AutoReview, + ), + ( + "auto_review", + "user", + ApprovalsReviewer::AutoReview, + ApprovalsReviewer::User, + ), + ] { + let codex_home = tempdir().expect("tempdir should succeed"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + format!( + r#" +approvals_reviewer = "{global}" + +[apps.calendar] +approvals_reviewer = "{app}" +"# + ), + ) + .expect("write config"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config should build"); + + assert_eq!( + mcp_approvals_reviewer(&config, CODEX_APPS_MCP_SERVER_NAME, Some("calendar")), + expected_app + ); + assert_eq!( + mcp_approvals_reviewer(&config, CODEX_APPS_MCP_SERVER_NAME, Some("drive")), + expected_global + ); + assert_eq!( + mcp_approvals_reviewer(&config, "custom_server", Some("calendar")), + expected_global + ); + } +} + +#[tokio::test] +async fn app_approvals_reviewer_respects_global_reviewer_requirements() { + let codex_home = tempdir().expect("tempdir should succeed"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +approvals_reviewer = "auto_review" + +[apps.calendar] +approvals_reviewer = "user" +"#, + ) + .expect("write config"); + let requirements = ConfigRequirementsToml { + allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), + ..Default::default() + }; + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async move { + Ok(Some(requirements)) + })) + .build() + .await + .expect("config should build"); + + assert_eq!( + mcp_approvals_reviewer(&config, CODEX_APPS_MCP_SERVER_NAME, Some("calendar")), + ApprovalsReviewer::AutoReview + ); +} + #[test] fn requirements_disabled_connector_overrides_enabled_connector() { let mut effective_apps = AppsConfigToml { @@ -932,6 +1016,7 @@ fn app_tool_policy_allows_per_app_enable_when_default_is_disabled() { "calendar".to_string(), AppConfig { enabled: true, + approvals_reviewer: None, destructive_enabled: None, open_world_enabled: None, default_tools_approval_mode: None, @@ -969,6 +1054,7 @@ fn app_tool_policy_per_tool_enabled_true_overrides_app_level_disable_flags() { "calendar".to_string(), AppConfig { enabled: true, + approvals_reviewer: None, destructive_enabled: Some(false), open_world_enabled: Some(false), default_tools_approval_mode: None, @@ -1012,6 +1098,7 @@ fn app_tool_policy_default_tools_enabled_true_overrides_app_level_tool_hints() { "calendar".to_string(), AppConfig { enabled: true, + approvals_reviewer: None, destructive_enabled: Some(false), open_world_enabled: Some(false), default_tools_approval_mode: None, @@ -1047,6 +1134,7 @@ fn app_tool_policy_default_tools_enabled_false_overrides_app_level_tool_hints() "calendar".to_string(), AppConfig { enabled: true, + approvals_reviewer: None, destructive_enabled: Some(true), open_world_enabled: Some(true), default_tools_approval_mode: Some(AppToolApproval::Approve), @@ -1084,6 +1172,7 @@ fn app_tool_policy_uses_default_tools_approval_mode() { "calendar".to_string(), AppConfig { enabled: true, + approvals_reviewer: None, destructive_enabled: None, open_world_enabled: None, default_tools_approval_mode: Some(AppToolApproval::Prompt), @@ -1123,6 +1212,7 @@ fn app_tool_policy_matches_prefix_stripped_tool_name_for_tool_config() { "calendar".to_string(), AppConfig { enabled: true, + approvals_reviewer: None, destructive_enabled: Some(false), open_world_enabled: Some(false), default_tools_approval_mode: Some(AppToolApproval::Auto), diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index 8cc04e5c42..b4920f1ff6 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -39,6 +39,7 @@ pub(crate) use review::review_approval_request; #[cfg(test)] pub(crate) use review::review_approval_request_with_cancel; pub(crate) use review::routes_approval_to_guardian; +pub(crate) use review::routes_approval_to_guardian_with_reviewer; pub(crate) use review::spawn_approval_request_review; pub(crate) use review_session::GuardianReviewSessionManager; pub(crate) use review_session::prompt_cache_key_override_for_review_session; diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 244d6e09e0..167b153e20 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -145,10 +145,18 @@ fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { /// reviewer instead of surfacing them to the user. ARC may still block actions /// earlier in the flow. pub(crate) fn routes_approval_to_guardian(turn: &TurnContext) -> bool { + routes_approval_to_guardian_with_reviewer(turn, turn.config.approvals_reviewer) +} + +/// Whether an approval with its own reviewer selection should be routed through guardian. +pub(crate) fn routes_approval_to_guardian_with_reviewer( + turn: &TurnContext, + approvals_reviewer: ApprovalsReviewer, +) -> bool { matches!( turn.approval_policy.value(), AskForApproval::OnRequest | AskForApproval::Granular(_) - ) && turn.config.approvals_reviewer == ApprovalsReviewer::AutoReview + ) && approvals_reviewer == ApprovalsReviewer::AutoReview } pub(crate) fn is_guardian_reviewer_source( diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 40a07d3802..aba67d8500 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -1099,6 +1099,20 @@ async fn routes_approval_to_guardian_requires_guardian_reviewer() { assert!(routes_approval_to_guardian(&turn)); } +#[tokio::test] +async fn routes_approval_to_guardian_can_use_app_reviewer_override() { + let (_session, turn) = crate::session::tests::make_session_and_context().await; + + assert!(!routes_approval_to_guardian_with_reviewer( + &turn, + ApprovalsReviewer::User + )); + assert!(routes_approval_to_guardian_with_reviewer( + &turn, + ApprovalsReviewer::AutoReview + )); +} + #[tokio::test] async fn routes_approval_to_guardian_allows_granular_review_policy() { let (_session, mut turn) = crate::session::tests::make_session_and_context().await; diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index e514fa0d03..9c00ba2be6 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -13,7 +13,7 @@ use crate::guardian::guardian_rejection_message; use crate::guardian::guardian_timeout_message; use crate::guardian::new_guardian_review_id; use crate::guardian::review_approval_request; -use crate::guardian::routes_approval_to_guardian; +use crate::guardian::routes_approval_to_guardian_with_reviewer; use crate::hook_runtime::run_permission_request_hooks; use crate::mcp_openai_file::rewrite_mcp_tool_arguments_for_openai_files; use crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam; @@ -32,6 +32,7 @@ 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_config::types::ApprovalsReviewer; use codex_features::Feature; use codex_hooks::PermissionRequestDecision; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; @@ -1162,11 +1163,11 @@ async fn maybe_request_mcp_tool_approval( metadata: Option<&McpToolApprovalMetadata>, approval_mode: AppToolApproval, ) -> Option { + let approvals_reviewer = mcp_approvals_reviewer(turn_context, &invocation.server, metadata); if mcp_permission_prompt_is_auto_approved( turn_context.approval_policy.value(), &turn_context.permission_profile(), McpPermissionPromptAutoApproveContext { - approvals_reviewer: Some(turn_context.config.approvals_reviewer), tool_approval_mode: Some(approval_mode), }, ) { @@ -1218,7 +1219,7 @@ async fn maybe_request_mcp_tool_approval( .features .enabled(Feature::ToolCallMcpElicitation); - if routes_approval_to_guardian(turn_context) { + if routes_approval_to_guardian_with_reviewer(turn_context, approvals_reviewer) { let review_id = new_guardian_review_id(); let decision = review_approval_request( sess, @@ -1328,6 +1329,18 @@ async fn maybe_request_mcp_tool_approval( Some(decision) } +pub(crate) fn mcp_approvals_reviewer( + turn_context: &TurnContext, + server_name: &str, + metadata: Option<&McpToolApprovalMetadata>, +) -> ApprovalsReviewer { + connectors::mcp_approvals_reviewer( + turn_context.config.as_ref(), + server_name, + metadata.and_then(|metadata| metadata.connector_id.as_deref()), + ) +} + fn session_mcp_tool_approval_key( invocation: &McpInvocation, metadata: Option<&McpToolApprovalMetadata>, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 534c2f0118..c9259b8f2d 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1886,6 +1886,7 @@ async fn persist_codex_app_tool_approval_writes_tool_override() { "calendar".to_string(), AppConfig { enabled: true, + approvals_reviewer: None, destructive_enabled: None, open_world_enabled: None, default_tools_approval_mode: None, diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index a775c8b836..cc018343eb 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -455,7 +455,15 @@ async fn review_guardian_mcp_elicitation( return Ok(None); }; - if !crate::guardian::routes_approval_to_guardian(turn_context.as_ref()) { + let approvals_reviewer = crate::connectors::mcp_approvals_reviewer( + turn_context.config.as_ref(), + request.server_name.as_str(), + elicitation_connector_id(&request.elicitation), + ); + if !crate::guardian::routes_approval_to_guardian_with_reviewer( + turn_context.as_ref(), + approvals_reviewer, + ) { return Ok(None); } @@ -567,6 +575,15 @@ fn guardian_elicitation_review_request( )) } +fn elicitation_connector_id(elicitation: &CreateElicitationRequestParams) -> Option<&str> { + match elicitation { + CreateElicitationRequestParams::FormElicitationParams { meta, .. } + | CreateElicitationRequestParams::UrlElicitationParams { meta, .. } => meta + .as_ref() + .and_then(|meta| metadata_str(&meta.0, MCP_ELICITATION_CONNECTOR_ID_KEY)), + } +} + fn meta_requests_approval_request(meta: &Option) -> bool { meta.as_ref() .and_then(|meta| metadata_str(&meta.0, MCP_ELICITATION_REQUEST_TYPE_KEY)) From 859dbe27616c593238bad63be63e13a2d80579d9 Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 2 Jun 2026 17:27:30 +0200 Subject: [PATCH 22/59] Skip startup prewarm when websockets are disabled (#25868) ## Summary - skip startup websocket prewarm setup when the model client has Responses-over-WebSocket disabled - avoid making HTTP-only sessions build prewarm prompt/tool state that cannot produce a reusable websocket session ## Why Recent macOS timing flakes were timing out while waiting for first-turn events in HTTP-only core tests. Startup prewarm is only useful for websocket-capable providers, but it was scheduled for every session. For HTTP-only test providers this added unnecessary async startup work before the regular turn could reach the mocked response flow. ## Testing - bazel test //codex-rs/core:core-all-test --test_filter=suite::auto_review::remote_model_override_uses_catalog_model_for_strict_auto_review --test_output=errors - bazel test //codex-rs/core:core-all-test --test_filter=suite::request_permissions_tool::approved_folder_write_request_permissions_unblocks_later_apply_patch --test_output=errors --- codex-rs/core/src/session_startup_prewarm.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index 13e0871e92..d620648179 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -172,6 +172,10 @@ impl SessionStartupPrewarmHandle { impl Session { pub(crate) async fn schedule_startup_prewarm(self: &Arc, base_instructions: String) { + if !self.services.model_client.responses_websocket_enabled() { + return; + } + let session_telemetry = self.services.session_telemetry.clone(); let websocket_connect_timeout = self.provider().await.websocket_connect_timeout(); let started_at = Instant::now(); From ebb79803697acee75baf24073ef49af87ad7e483 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 2 Jun 2026 09:56:20 -0700 Subject: [PATCH 23/59] Route Bazel CI through shared BuildBuddy remote config wrapper (#25156) ## Why Bazel remote configuration was selected in several CI scripts and workflow steps. That made the BuildBuddy tenant policy easy to duplicate and harder to audit, especially for fork pull requests that must not use the OpenAI tenant. This builds on [sluongng/buildbuddy-ci-host-routing](https://github.com/openai/codex/compare/main...sluongng:codex:sluongng/buildbuddy-ci-host-routing) and consolidates the policy in one place. ## What to do if this breaks you See `codex-rs/docs/bazel.md` for details. TLDR: 1. make a BuildBuddy API key and put it in `~/.bazelrc` 2. if you're an OpenAI employee, add `common --config=buildbuddy-openai-rbe` to `user.bazelrc` in the repo root Run `just bazel-test` to ensure it works. Note that `just bazel-remote-test` no longer exists, you need to select a remote configuration as documented to use RBE. ## What changed - Add `.github/scripts/run_bazel_with_buildbuddy.py` as the shared Bazel wrapper and Python library. It selects the OpenAI host only for trusted upstream GitHub Actions runs, routes keyed fork runs to the generic host, and falls back to local Bazel execution when no key is available. - Move endpoint selection into explicit `.bazelrc` configurations and update Bazel CI, query helpers, and `rusty_v8` staging to use the shared policy. Loading-phase target-discovery queries remain local. - Add wrapper and `rusty_v8` unit coverage, plus `just test-scripts` for the `.github/scripts` Python tests. - Document local Bazel usage, `user.bazelrc` setup, BuildBuddy configurations, and CI behavior in `codex-rs/docs/bazel.md`. ## Validation - `just test-scripts` - `bash -n .github/scripts/run-bazel-ci.sh .github/scripts/run-bazel-query-ci.sh .github/scripts/run-argument-comment-lint-bazel.sh scripts/list-bazel-clippy-targets.sh` - `python3 -m py_compile .github/scripts/run_bazel_with_buildbuddy.py .github/scripts/test_run_bazel_with_buildbuddy.py .github/scripts/test_rusty_v8_bazel.py .github/scripts/rusty_v8_bazel.py` - `ruff check .github/scripts/run_bazel_with_buildbuddy.py .github/scripts/test_run_bazel_with_buildbuddy.py .github/scripts/test_rusty_v8_bazel.py .github/scripts/rusty_v8_bazel.py` --- .bazelrc | 46 +++-- .github/scripts/run-bazel-ci.sh | 126 +++++------- .github/scripts/run-bazel-query-ci.sh | 56 ++---- .github/scripts/run_bazel_with_buildbuddy.py | 142 ++++++++++++++ .github/scripts/rusty_v8_bazel.py | 63 +++--- .../scripts/test_run_bazel_with_buildbuddy.py | 184 ++++++++++++++++++ .github/scripts/test_rusty_v8_bazel.py | 49 +++-- .github/workflows/bazel.yml | 8 +- .github/workflows/rusty-v8-release.yml | 5 +- .github/workflows/v8-canary.yml | 7 +- codex-rs/docs/bazel.md | 114 ++++++++++- justfile | 11 +- scripts/list-bazel-clippy-targets.sh | 24 +-- 13 files changed, 617 insertions(+), 218 deletions(-) create mode 100755 .github/scripts/run_bazel_with_buildbuddy.py create mode 100644 .github/scripts/test_run_bazel_with_buildbuddy.py diff --git a/.bazelrc b/.bazelrc index 6357eb83e4..e39a3aff22 100644 --- a/.bazelrc +++ b/.bazelrc @@ -38,24 +38,50 @@ common:windows --test_env=WINDIR common --test_env=RUST_MIN_STACK=8388608 # 8 MiB common --test_output=errors -common --bes_results_url=https://app.buildbuddy.io/invocation/ -common --bes_backend=grpcs://remote.buildbuddy.io -common --remote_cache=grpcs://remote.buildbuddy.io -common --remote_download_toplevel common --nobuild_runfile_links +# These settings tune BuildBuddy/RBE behavior but do not contact a remote +# service unless a `buildbuddy-*` configuration below supplies an endpoint. +common --remote_download_toplevel common --remote_timeout=3600 common --noexperimental_throttle_remote_action_building common --experimental_remote_execution_keepalive common --grpc_keepalive_time=30s -common --experimental_remote_downloader=grpcs://remote.buildbuddy.io + +# Opt-in remote configurations selected by +# `.github/scripts/run_bazel_with_buildbuddy.py`. Plain Bazel commands do not +# contact BuildBuddy unless a user selects one of these configurations. +# Use the generic host for cache, BES, and downloads without remote execution. +common:buildbuddy-generic --bes_backend=grpcs://remote.buildbuddy.io +common:buildbuddy-generic --bes_results_url=https://app.buildbuddy.io/invocation/ +common:buildbuddy-generic --remote_cache=grpcs://remote.buildbuddy.io +common:buildbuddy-generic --experimental_remote_downloader=grpcs://remote.buildbuddy.io + +# Add remote execution on the generic host. +common:buildbuddy-generic-rbe --config=buildbuddy-generic +common:buildbuddy-generic-rbe --config=remote +common:buildbuddy-generic-rbe --remote_executor=grpcs://remote.buildbuddy.io + +# Use the OpenAI tenant for cache, BES, and downloads without remote execution. +common:buildbuddy-openai --bes_backend=grpcs://openai.buildbuddy.io +common:buildbuddy-openai --bes_results_url=https://openai.buildbuddy.io/invocation/ +common:buildbuddy-openai --remote_cache=grpcs://openai.buildbuddy.io +common:buildbuddy-openai --experimental_remote_downloader=grpcs://openai.buildbuddy.io + +# Add remote execution on the OpenAI tenant. +common:buildbuddy-openai-rbe --config=buildbuddy-openai +common:buildbuddy-openai-rbe --config=remote +common:buildbuddy-openai-rbe --remote_executor=grpcs://openai.buildbuddy.io # This limits both in-flight executions and concurrent downloads. Even with high number # of jobs execution will still be limited by CPU cores, so this just pays a bit of # memory in exchange for higher download concurrency. common --jobs=30 +# Shared remote execution policy. The endpoint-bearing `buildbuddy-*-rbe` +# configurations include this group; CI configs override TestRunner below +# when tests must remain local on their runner. +common:remote --strategy=remote common:remote --extra_execution_platforms=//:rbe -common:remote --remote_executor=grpcs://remote.buildbuddy.io common:remote --jobs=800 # TODO(team): Evaluate if this actually helps, zbarsky is not sure, everything seems bottlenecked on `core` either way. # Enable pipelined compilation since we are not bound by local CPU count. @@ -146,15 +172,11 @@ common:ci-windows --repo_contents_cache=D:/a/.cache/bazel-repo-contents-cache # Linux crossbuilds don't work until we untangle the libc constraint mess. common:ci-linux --config=ci-bazel common:ci-linux --build_metadata=TAG_os=linux -common:ci-linux --config=remote -common:ci-linux --strategy=remote common:ci-linux --platforms=//:rbe # On mac, we can run all the build actions remotely but test actions locally. common:ci-macos --config=ci-bazel common:ci-macos --build_metadata=TAG_os=macos -common:ci-macos --config=remote -common:ci-macos --strategy=remote common:ci-macos --strategy=TestRunner=darwin-sandbox,local # On Windows, use Linux remote execution for build actions but keep test actions @@ -162,9 +184,7 @@ common:ci-macos --strategy=TestRunner=darwin-sandbox,local # still run against Windows binaries. common:ci-windows-cross --config=ci-windows common:ci-windows-cross --build_metadata=TAG_windows_cross_compile=true -common:ci-windows-cross --config=remote common:ci-windows-cross --host_platform=//:rbe -common:ci-windows-cross --strategy=remote common:ci-windows-cross --strategy=TestRunner=local common:ci-windows-cross --local_test_jobs=4 common:ci-windows-cross --test_env=RUST_TEST_THREADS=1 @@ -180,8 +200,6 @@ common:ci-windows-cross --extra_toolchains=//:windows_gnullvm_tests_on_msvc_host common:ci-v8 --config=ci common:ci-v8 --build_metadata=TAG_workflow=v8 common:ci-v8 --build_metadata=TAG_os=linux -common:ci-v8 --config=remote -common:ci-v8 --strategy=remote # Source-built Bazel V8 artifacts use the in-process sandbox by default. This # does not affect Cargo's default prebuilt rusty_v8 path. diff --git a/.github/scripts/run-bazel-ci.sh b/.github/scripts/run-bazel-ci.sh index f98e4d8cb9..89f937a998 100755 --- a/.github/scripts/run-bazel-ci.sh +++ b/.github/scripts/run-bazel-ci.sh @@ -53,11 +53,20 @@ fi run_bazel() { if [[ "${RUNNER_OS:-}" == "Windows" ]]; then - MSYS2_ARG_CONV_EXCL='*' bazel "$@" + MSYS2_ARG_CONV_EXCL='*' "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@" return fi - bazel "$@" + "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@" +} + +run_bazel_with_startup_args() { + if (( ${#bazel_startup_args[@]} > 0 )); then + run_bazel "${bazel_startup_args[@]}" "$@" + return + fi + + run_bazel "$@" } ci_config=ci-linux @@ -77,23 +86,16 @@ esac print_bazel_test_log_tails() { local console_log="$1" local testlogs_dir - local -a bazel_info_cmd=(bazel) + local -a bazel_info_args=(info) - - if (( ${#bazel_startup_args[@]} > 0 )); then - bazel_info_cmd+=("${bazel_startup_args[@]}") - fi - - # `bazel info` needs the same CI config as the failed test invocation so - # platform-specific output roots match. On Windows, omitting `ci-windows` - # would point at `local_windows-fastbuild` even when the test ran with the - # MSVC host platform under `local_windows_msvc-fastbuild`. if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then - bazel_info_args+=( - "--config=${ci_config}" - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - ) + # `bazel info` needs the same CI config as the failed test invocation so + # platform-specific output roots match. On Windows, omitting `ci-windows` + # would point at `local_windows-fastbuild` even when the test ran with the + # MSVC host platform under `local_windows_msvc-fastbuild`. + bazel_info_args+=("--config=${ci_config}") fi + # Only pass flags that affect Bazel's output-root selection or repository # lookup. Test/build-only flags such as execution logs or remote download # mode can make `bazel info` fail, which would hide the real test log path. @@ -105,7 +107,7 @@ print_bazel_test_log_tails() { esac done - testlogs_dir="$(run_bazel "${bazel_info_cmd[@]:1}" \ + testlogs_dir="$(run_bazel_with_startup_args \ --noexperimental_remote_repo_contents_cache \ "${bazel_info_args[@]}" \ bazel-testlogs 2>/dev/null || echo bazel-testlogs)" @@ -254,8 +256,9 @@ if [[ ${#bazel_args[@]} -eq 0 || ${#bazel_targets[@]} -eq 0 ]]; then fi if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then - # Fork PRs do not receive the BuildBuddy secret needed for the remote - # cross-compile config. Preserve the previous local Windows build shape. + # Windows cross-compilation depends on authenticated RBE. Preserve the local + # Windows build shape when credentials are unavailable. + ci_config=ci-windows windows_msvc_host_platform=1 fi @@ -297,9 +300,9 @@ if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -n "${BUI fi if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then - # The Windows cross-compile config depends on remote execution. Fork PRs do - # not receive the BuildBuddy secret, so fall back to the existing local build - # shape and keep its lower concurrency cap. + # The Windows cross-compile config depends on authenticated remote + # execution. When credentials are unavailable, keep the local build shape + # and its lower concurrency cap. post_config_bazel_args+=(--jobs=8) fi @@ -377,70 +380,31 @@ fi bazel_console_log="$(mktemp)" trap 'rm -f "$bazel_console_log"' EXIT -bazel_cmd=(bazel) -if (( ${#bazel_startup_args[@]} > 0 )); then - bazel_cmd+=("${bazel_startup_args[@]}") -fi - +bazel_run_args=( + "${bazel_args[@]}" +) if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then echo "BuildBuddy API key is available; using remote Bazel configuration." - # Work around Bazel 9 remote repo contents cache / overlay materialization failures - # seen in CI (for example "is not a symlink" or permission errors while - # materializing external repos such as rules_perl). We still use BuildBuddy for - # remote execution/cache; this only disables the startup-level repo contents cache. - bazel_run_args=( - "${bazel_args[@]}" - "--config=${ci_config}" - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - ) - if (( ${#post_config_bazel_args[@]} > 0 )); then - bazel_run_args+=("${post_config_bazel_args[@]}") - fi - set +e - run_bazel "${bazel_cmd[@]:1}" \ - --noexperimental_remote_repo_contents_cache \ - "${bazel_run_args[@]}" \ - -- \ - "${bazel_targets[@]}" \ - 2>&1 | tee "$bazel_console_log" - bazel_status=${PIPESTATUS[0]} - set -e + bazel_run_args+=("--config=${ci_config}") else echo "BuildBuddy API key is not available; using local Bazel configuration." - # Keep fork/community PRs on Bazel but disable remote services that are - # configured in .bazelrc and require auth. - # - # Flag docs: - # - Command-line reference: https://bazel.build/reference/command-line-reference - # - Remote caching overview: https://bazel.build/remote/caching - # - Remote execution overview: https://bazel.build/remote/rbe - # - Build Event Protocol overview: https://bazel.build/remote/bep - # - # --noexperimental_remote_repo_contents_cache: - # disable remote repo contents cache enabled in .bazelrc startup options. - # https://bazel.build/reference/command-line-reference#startup_options-flag--experimental_remote_repo_contents_cache - # --remote_cache= and --remote_executor=: - # clear remote cache/execution endpoints configured in .bazelrc. - # https://bazel.build/reference/command-line-reference#common_options-flag--remote_cache - # https://bazel.build/reference/command-line-reference#common_options-flag--remote_executor - bazel_run_args=( - "${bazel_args[@]}" - --remote_cache= - --remote_executor= - ) - if (( ${#post_config_bazel_args[@]} > 0 )); then - bazel_run_args+=("${post_config_bazel_args[@]}") - fi - set +e - run_bazel "${bazel_cmd[@]:1}" \ - --noexperimental_remote_repo_contents_cache \ - "${bazel_run_args[@]}" \ - -- \ - "${bazel_targets[@]}" \ - 2>&1 | tee "$bazel_console_log" - bazel_status=${PIPESTATUS[0]} - set -e fi +if (( ${#post_config_bazel_args[@]} > 0 )); then + bazel_run_args+=("${post_config_bazel_args[@]}") +fi +set +e +# Work around Bazel 9 remote repo contents cache / overlay materialization +# failures seen in CI (for example "is not a symlink" or permission errors +# while materializing external repos such as rules_perl). This only disables +# the startup-level repo contents cache; keyed runs still use BuildBuddy. +run_bazel_with_startup_args \ + --noexperimental_remote_repo_contents_cache \ + "${bazel_run_args[@]}" \ + -- \ + "${bazel_targets[@]}" \ + 2>&1 | tee "$bazel_console_log" +bazel_status=${PIPESTATUS[0]} +set -e if [[ ${bazel_status:-0} -ne 0 ]]; then if [[ $print_failed_bazel_action_summary -eq 1 ]]; then diff --git a/.github/scripts/run-bazel-query-ci.sh b/.github/scripts/run-bazel-query-ci.sh index dd03b67169..f5d4f56f49 100755 --- a/.github/scripts/run-bazel-query-ci.sh +++ b/.github/scripts/run-bazel-query-ci.sh @@ -2,48 +2,17 @@ set -euo pipefail -# Run Bazel queries with the same CI startup settings as the main build/test -# invocation so target-discovery queries can reuse the same Bazel server. +# Run target-discovery queries with the same startup settings as the main +# build/test invocation so they can reuse the same Bazel server. Queries only +# enumerate labels, so they intentionally do not select CI or remote configs. -query_args=() -windows_cross_compile=0 -while [[ $# -gt 0 ]]; do - case "$1" in - --windows-cross-compile) - windows_cross_compile=1 - shift - ;; - --) - shift - break - ;; - *) - query_args+=("$1") - shift - ;; - esac -done - -if [[ $# -ne 1 ]]; then - echo "Usage: $0 [--windows-cross-compile] [...] -- " >&2 +if [[ $# -lt 2 || "${@: -2:1}" != "--" ]]; then + echo "Usage: $0 [...] -- " >&2 exit 1 fi -query_expression="$1" - -ci_config=ci-linux -case "${RUNNER_OS:-}" in - macOS) - ci_config=ci-macos - ;; - Windows) - if [[ $windows_cross_compile -eq 1 ]]; then - ci_config=ci-windows-cross - else - ci_config=ci-windows - fi - ;; -esac +query_args=("${@:1:$#-2}") +query_expression="${@: -1}" bazel_startup_args=() if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then @@ -60,12 +29,6 @@ run_bazel() { } bazel_query_args=(--noexperimental_remote_repo_contents_cache query) -if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then - bazel_query_args+=( - "--config=${ci_config}" - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - ) -fi if [[ -n "${BAZEL_REPO_CONTENTS_CACHE:-}" ]]; then bazel_query_args+=("--repo_contents_cache=${BAZEL_REPO_CONTENTS_CACHE}") @@ -75,7 +38,10 @@ if [[ -n "${BAZEL_REPOSITORY_CACHE:-}" ]]; then bazel_query_args+=("--repository_cache=${BAZEL_REPOSITORY_CACHE}") fi -bazel_query_args+=("${query_args[@]}" "$query_expression") +if (( ${#query_args[@]} > 0 )); then + bazel_query_args+=("${query_args[@]}") +fi +bazel_query_args+=("$query_expression") if (( ${#bazel_startup_args[@]} > 0 )); then run_bazel "${bazel_startup_args[@]}" "${bazel_query_args[@]}" diff --git a/.github/scripts/run_bazel_with_buildbuddy.py b/.github/scripts/run_bazel_with_buildbuddy.py new file mode 100755 index 0000000000..add95a99e5 --- /dev/null +++ b/.github/scripts/run_bazel_with_buildbuddy.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 + +import json +import os +import sys +from collections.abc import Mapping +from collections.abc import Sequence +from pathlib import Path + + +OPENAI_REPOSITORY = "openai/codex" +# Remote configurations select cache/BES/download endpoints. Their -rbe forms +# also select the matching remote executor endpoint. +GENERIC_REMOTE_CONFIG = "buildbuddy-generic" +OPENAI_REMOTE_CONFIG = "buildbuddy-openai" +# These CI configurations require remote build execution. The wrapper supplies +# an RBE configuration, which also includes the common `remote` settings. +REMOTE_EXECUTION_CONFIGS = { + "--config=ci-linux", + "--config=ci-macos", + "--config=ci-v8", + "--config=ci-windows-cross", +} +# Only authenticated workflow runs executing trusted upstream code may use the +# OpenAI BuildBuddy host. A pull request event without proof that its head is +# in the upstream repository fails closed to the generic host. +def is_trusted_upstream_run(env: Mapping[str, str]) -> bool: + # `GITHUB_REPOSITORY` is easy to set locally. Requiring GitHub's workflow + # marker prevents a local command from opting itself into the OpenAI host. + if ( + env.get("GITHUB_ACTIONS") != "true" + or env.get("GITHUB_REPOSITORY") != OPENAI_REPOSITORY + ): + return False + # Non-PR workflow runs in `openai/codex` execute upstream refs, so they are + # trusted. Fork code reaches these workflows only through pull requests. + if env.get("GITHUB_EVENT_NAME") != "pull_request": + return True + + event_path = env.get("GITHUB_EVENT_PATH") + if not event_path: + return False + try: + event = json.loads(Path(event_path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False + + try: + return event["pull_request"]["head"]["repo"]["fork"] is False + except (KeyError, TypeError): + return False + + +def uses_openai_host(env: Mapping[str, str]) -> bool: + return bool(env.get("BUILDBUDDY_API_KEY")) and is_trusted_upstream_run(env) + + +def uses_remote_execution(args: Sequence[str]) -> bool: + try: + separator_idx = args.index("--") + except ValueError: + separator_idx = len(args) + return any(arg in REMOTE_EXECUTION_CONFIGS for arg in args[:separator_idx]) + + +def remote_config(args: Sequence[str], env: Mapping[str, str]) -> str | None: + if not env.get("BUILDBUDDY_API_KEY"): + return None + + config = OPENAI_REMOTE_CONFIG if uses_openai_host(env) else GENERIC_REMOTE_CONFIG + if uses_remote_execution(args): + config += "-rbe" + return config + + +def bazel_args_without_remote_execution(args: Sequence[str]) -> list[str]: + # Remote CI configs require BuildBuddy credentials. Removing them preserves + # the local fallback used for fork pull requests. + try: + separator_idx = args.index("--") + except ValueError: + separator_idx = len(args) + return [ + *(arg for arg in args[:separator_idx] if arg not in REMOTE_EXECUTION_CONFIGS), + *args[separator_idx:], + ] + + +def bazel_args_with_remote_config( + args: Sequence[str], env: Mapping[str, str] +) -> list[str]: + config = remote_config(args, env) + if config is None: + return bazel_args_without_remote_execution(args) + + # `remote_config()` returns a configuration only when this key is present. + api_key = env["BUILDBUDDY_API_KEY"] + remote_args = [ + f"--config={config}", + f"--remote_header=x-buildbuddy-api-key={api_key}", + ] + + # Insert immediately after the Bazel command. This keeps wrapper-added + # options out of positional payloads and lets later CI configs override + # shared RBE defaults such as the Windows cross-compilation exec platforms. + insertion_idx = next( + (idx + 1 for idx, arg in enumerate(args) if not arg.startswith("-")), + len(args), + ) + return [*args[:insertion_idx], *remote_args, *args[insertion_idx:]] + + +def bazel_command(*args: str, env: Mapping[str, str] | None = None) -> list[str]: + env = os.environ if env is None else env + bazel = env.get("CODEX_BAZEL_BIN", "bazel") + return [bazel, *bazel_args_with_remote_config(args, env)] + + +def main() -> None: + config = remote_config(sys.argv[1:], os.environ) + if config is None: + print( + "BuildBuddy key unavailable; using local Bazel configuration.", + file=sys.stderr, + ) + else: + host_description = ( + "OpenAI tenant" if uses_openai_host(os.environ) else "generic" + ) + print( + f"Using {host_description} BuildBuddy configuration: {config}.", + file=sys.stderr, + ) + + command = bazel_command(*sys.argv[1:]) + # Replace the wrapper so Bazel receives signals directly and supplies the + # command exit status; a subprocess parent would have no remaining work. + os.execvp(command[0], command) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/rusty_v8_bazel.py b/.github/scripts/rusty_v8_bazel.py index 2f46daf45e..329d3f6c54 100644 --- a/.github/scripts/rusty_v8_bazel.py +++ b/.github/scripts/rusty_v8_bazel.py @@ -5,7 +5,6 @@ from __future__ import annotations import argparse import gzip import hashlib -import os import re import shutil import subprocess @@ -13,6 +12,7 @@ import sys import tomllib from pathlib import Path +from run_bazel_with_buildbuddy import bazel_command from rusty_v8_module_bazel import ( RustyV8ChecksumError, check_module_bazel, @@ -29,33 +29,22 @@ SANDBOX_ARTIFACT_PROFILE = "ptrcomp_sandbox_release" ARTIFACT_BAZEL_CONFIGS = ["rusty-v8-upstream-libcxx"] -def bazel_remote_args() -> list[str]: - buildbuddy_api_key = os.environ.get("BUILDBUDDY_API_KEY") - if not buildbuddy_api_key: - return [] - return [f"--remote_header=x-buildbuddy-api-key={buildbuddy_api_key}"] - - def bazel_execroot() -> Path: - result = subprocess.run( - ["bazel", "info", "execution_root"], + output = subprocess.check_output( + bazel_command("info", "execution_root"), cwd=ROOT, - check=True, - capture_output=True, text=True, ) - return Path(result.stdout.strip()) + return Path(output.strip()) def bazel_output_base() -> Path: - result = subprocess.run( - ["bazel", "info", "output_base"], + output = subprocess.check_output( + bazel_command("info", "output_base"), cwd=ROOT, - check=True, - capture_output=True, text=True, ) - return Path(result.stdout.strip()) + return Path(output.strip()) def bazel_output_path(path: str) -> Path: @@ -72,24 +61,22 @@ def bazel_output_files( ) -> list[Path]: expression = "set(" + " ".join(labels) + ")" bazel_configs = bazel_configs or [] - result = subprocess.run( - [ - "bazel", + output = subprocess.check_output( + bazel_command( "cquery", "-c", compilation_mode, f"--platforms=@llvm//platforms:{platform}", *[f"--config={config}" for config in bazel_configs], - *bazel_remote_args(), "--output=files", expression, - ], + ), cwd=ROOT, - check=True, - capture_output=True, text=True, ) - return [bazel_output_path(line.strip()) for line in result.stdout.splitlines() if line.strip()] + return [ + bazel_output_path(line.strip()) for line in output.splitlines() if line.strip() + ] def bazel_build( @@ -102,17 +89,15 @@ def bazel_build( bazel_configs = bazel_configs or [] download_args = ["--remote_download_toplevel"] if download_toplevel else [] subprocess.run( - [ - "bazel", + bazel_command( "build", "-c", compilation_mode, f"--platforms=@llvm//platforms:{platform}", *[f"--config={config}" for config in bazel_configs], - *bazel_remote_args(), *download_args, *labels, - ], + ), cwd=ROOT, check=True, ) @@ -172,7 +157,7 @@ def resolved_v8_crate_version() -> str: matches = sorted( set( re.findall( - r'https://static\.crates\.io/crates/v8/v8-([0-9]+\.[0-9]+\.[0-9]+)\.crate', + r"https://static\.crates\.io/crates/v8/v8-([0-9]+\.[0-9]+\.[0-9]+)\.crate", module_bazel, ) ) @@ -234,13 +219,17 @@ def stage_artifacts( output_dir: Path, sandbox: bool, ) -> None: - missing_paths = [str(path) for path in [lib_path, binding_path] if not path.exists()] + missing_paths = [ + str(path) for path in [lib_path, binding_path] if not path.exists() + ] if missing_paths: raise SystemExit(f"missing release outputs for {target}: {missing_paths}") output_dir.mkdir(parents=True, exist_ok=True) artifact_profile = SANDBOX_ARTIFACT_PROFILE if sandbox else RELEASE_ARTIFACT_PROFILE - staged_library = output_dir / staged_archive_name(target, lib_path, artifact_profile) + staged_library = output_dir / staged_archive_name( + target, lib_path, artifact_profile + ) staged_binding = output_dir / staged_binding_name(target, artifact_profile) with lib_path.open("rb") as src, staged_library.open("wb") as dst: @@ -270,7 +259,9 @@ def stage_artifacts( def upstream_release_pair_paths(source_root: Path, target: str) -> tuple[Path, Path]: - lib_name = "rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a" + lib_name = ( + "rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a" + ) gn_out = source_root / "target" / target / "release" / "gn_out" return gn_out / "obj" / lib_name, gn_out / "src_binding.rs" @@ -338,7 +329,9 @@ def parse_args() -> argparse.Namespace: stage_upstream_release_pair_parser = subparsers.add_parser( "stage-upstream-release-pair" ) - stage_upstream_release_pair_parser.add_argument("--source-root", type=Path, required=True) + stage_upstream_release_pair_parser.add_argument( + "--source-root", type=Path, required=True + ) stage_upstream_release_pair_parser.add_argument("--target", required=True) stage_upstream_release_pair_parser.add_argument("--output-dir", required=True) stage_upstream_release_pair_parser.add_argument("--sandbox", action="store_true") diff --git a/.github/scripts/test_run_bazel_with_buildbuddy.py b/.github/scripts/test_run_bazel_with_buildbuddy.py new file mode 100644 index 0000000000..bab4ad5cce --- /dev/null +++ b/.github/scripts/test_run_bazel_with_buildbuddy.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 + +import json +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +import run_bazel_with_buildbuddy + + +class RunBazelWithBuildBuddyTest(unittest.TestCase): + def github_env( + self, + temp_dir: str, + *, + repository: str = "openai/codex", + fork: bool = False, + event_name: str = "pull_request", + ) -> dict[str, str]: + event_path = Path(temp_dir) / "event.json" + event_path.write_text( + json.dumps({"pull_request": {"head": {"repo": {"fork": fork}}}}), + encoding="utf-8", + ) + return { + "BUILDBUDDY_API_KEY": "token", + "GITHUB_ACTIONS": "true", + "GITHUB_EVENT_NAME": event_name, + "GITHUB_EVENT_PATH": str(event_path), + "GITHUB_REPOSITORY": repository, + } + + def test_keyless_invocation_drops_remote_ci_configuration(self) -> None: + self.assertIsNone( + run_bazel_with_buildbuddy.remote_config( + ["build", "--config=ci-linux", "//codex-rs/cli:codex"], + {}, + ) + ) + self.assertEqual( + run_bazel_with_buildbuddy.bazel_args_with_remote_config( + ["build", "--config=ci-linux", "--", "//codex-rs/cli:codex"], + {}, + ), + ["build", "--", "//codex-rs/cli:codex"], + ) + + def test_program_arguments_after_separator_do_not_select_or_lose_rbe(self) -> None: + args = ["run", "//codex-rs/cli:codex", "--", "--config=remote"] + + self.assertEqual( + run_bazel_with_buildbuddy.bazel_args_with_remote_config(args, {}), + args, + ) + self.assertEqual( + run_bazel_with_buildbuddy.remote_config( + args, {"BUILDBUDDY_API_KEY": "fork-token"} + ), + "buildbuddy-generic", + ) + + def test_upstream_push_selects_openai_rbe_before_target_separator(self) -> None: + with TemporaryDirectory() as temp_dir: + env = self.github_env(temp_dir, event_name="push") + + self.assertEqual( + run_bazel_with_buildbuddy.bazel_args_with_remote_config( + ["build", "--config=ci-linux", "--", "//codex-rs/cli:codex"], + env, + ), + [ + "build", + "--config=buildbuddy-openai-rbe", + "--remote_header=x-buildbuddy-api-key=token", + "--config=ci-linux", + "--", + "//codex-rs/cli:codex", + ], + ) + + def test_windows_cross_ci_configuration_follows_remote_configuration(self) -> None: + env = {"BUILDBUDDY_API_KEY": "fork-token"} + + self.assertEqual( + run_bazel_with_buildbuddy.bazel_args_with_remote_config( + ["build", "--config=ci-windows-cross", "//codex-rs/cli:codex"], + env, + ), + [ + "build", + "--config=buildbuddy-generic-rbe", + "--remote_header=x-buildbuddy-api-key=fork-token", + "--config=ci-windows-cross", + "//codex-rs/cli:codex", + ], + ) + + def test_query_remote_configuration_is_inserted_before_expression(self) -> None: + expression = 'kind("rust_library rule", //codex-rs/...)' + env = {"BUILDBUDDY_API_KEY": "fork-token"} + + for command in ("query", "cquery", "aquery"): + with self.subTest(command=command): + self.assertEqual( + run_bazel_with_buildbuddy.bazel_args_with_remote_config( + [ + command, + "--config=ci-windows-cross", + "--output=label", + expression, + ], + env, + ), + [ + command, + "--config=buildbuddy-generic-rbe", + "--remote_header=x-buildbuddy-api-key=fork-token", + "--config=ci-windows-cross", + "--output=label", + expression, + ], + ) + + def test_same_repository_pull_request_selects_openai_host(self) -> None: + with TemporaryDirectory() as temp_dir: + self.assertEqual( + run_bazel_with_buildbuddy.remote_config( + ["build", "--config=ci-v8"], self.github_env(temp_dir) + ), + "buildbuddy-openai-rbe", + ) + + def test_fork_pull_request_cannot_select_openai_host(self) -> None: + with TemporaryDirectory() as temp_dir: + env = self.github_env(temp_dir, fork=True) + + self.assertEqual( + run_bazel_with_buildbuddy.remote_config( + ["build", "--config=ci-v8"], env + ), + "buildbuddy-generic-rbe", + ) + + def test_run_in_fork_repository_cannot_select_openai_host(self) -> None: + with TemporaryDirectory() as temp_dir: + env = self.github_env(temp_dir, repository="contributor/codex") + + self.assertEqual( + run_bazel_with_buildbuddy.remote_config( + ["build", "--config=ci-v8"], env + ), + "buildbuddy-generic-rbe", + ) + + def test_pull_request_without_readable_event_payload_fails_closed(self) -> None: + for event_path in (None, "missing-event.json"): + env = { + "BUILDBUDDY_API_KEY": "token", + "GITHUB_ACTIONS": "true", + "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_REPOSITORY": "openai/codex", + } + if event_path is not None: + env["GITHUB_EVENT_PATH"] = event_path + + with self.subTest(event_path=event_path): + self.assertEqual( + run_bazel_with_buildbuddy.remote_config(["build"], env), + "buildbuddy-generic", + ) + + def test_bazel_command_uses_configured_binary_locally(self) -> None: + self.assertEqual( + run_bazel_with_buildbuddy.bazel_command( + "info", + "execution_root", + env={"CODEX_BAZEL_BIN": "fake-bazel"}, + ), + ["fake-bazel", "info", "execution_root"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test_rusty_v8_bazel.py b/.github/scripts/test_rusty_v8_bazel.py index 19690dbece..0b5c03f436 100644 --- a/.github/scripts/test_rusty_v8_bazel.py +++ b/.github/scripts/test_rusty_v8_bazel.py @@ -88,24 +88,49 @@ class RustyV8BazelTest(unittest.TestCase): ), ) - def test_bazel_remote_args_include_buildbuddy_header_when_present(self) -> None: - with patch.dict(environ, {"BUILDBUDDY_API_KEY": "token"}, clear=False): + def test_bazel_commands_use_shared_buildbuddy_remote_config_library(self) -> None: + with patch.dict(environ, {}, clear=True): self.assertEqual( - ["--remote_header=x-buildbuddy-api-key=token"], - rusty_v8_bazel.bazel_remote_args(), + [ + "bazel", + "build", + "//third_party/v8:release", + ], + rusty_v8_bazel.bazel_command( + "build", + "--config=ci-v8", + "//third_party/v8:release", + ), + ) + with patch.dict(environ, {"BUILDBUDDY_API_KEY": "token"}, clear=True): + self.assertEqual( + [ + "bazel", + "build", + "--config=buildbuddy-generic-rbe", + "--remote_header=x-buildbuddy-api-key=token", + "--config=ci-v8", + "//third_party/v8:release", + ], + rusty_v8_bazel.bazel_command( + "build", + "--config=ci-v8", + "//third_party/v8:release", + ), ) - with patch.dict(environ, {}, clear=True): - self.assertEqual([], rusty_v8_bazel.bazel_remote_args()) - - def test_release_pair_labels_and_staged_names_distinguish_sandbox_artifacts(self) -> None: + def test_release_pair_labels_and_staged_names_distinguish_sandbox_artifacts( + self, + ) -> None: self.assertEqual( "//third_party/v8:rusty_v8_release_pair_x86_64_unknown_linux_musl", rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl"), ) self.assertEqual( "//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_unknown_linux_musl", - rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl", sandbox=True), + rusty_v8_bazel.release_pair_label( + "x86_64-unknown-linux-musl", sandbox=True + ), ) self.assertEqual( "//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_apple_darwin", @@ -205,11 +230,7 @@ class RustyV8BazelTest(unittest.TestCase): with TemporaryDirectory() as source_dir, TemporaryDirectory() as output_dir: source_root = Path(source_dir) gn_out = ( - source_root - / "target" - / "x86_64-pc-windows-msvc" - / "release" - / "gn_out" + source_root / "target" / "x86_64-pc-windows-msvc" / "release" / "gn_out" ) (gn_out / "obj").mkdir(parents=True) (gn_out / "obj" / "rusty_v8.lib").write_bytes(b"archive") diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 153ace0fc9..e2782b4904 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -15,6 +15,7 @@ concurrency: # See https://docs.github.com/en/actions/using-jobs/using-concurrency and https://docs.github.com/en/actions/learn-github-actions/contexts for more info. group: concurrency-group::${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}${{ github.ref_name == 'main' && format('::{0}', github.run_id) || ''}} cancel-in-progress: ${{ github.ref_name != 'main' }} + jobs: test: # PRs use the sharded Windows cross-compiled test jobs below. Post-merge @@ -55,12 +56,17 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false + - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 + if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu' + with: + tool: just + - name: Check rusty_v8 MODULE.bazel checksums if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu' shell: bash run: | python3 .github/scripts/rusty_v8_bazel.py check-module-bazel - python3 -m unittest discover -s .github/scripts -p test_rusty_v8_bazel.py + just test-github-scripts - name: Prepare Bazel CI id: prepare_bazel diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index 4b5320dcb9..d6fb73e96d 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -191,11 +191,10 @@ jobs: bazel_args+=(--config=v8-release-compat) fi - bazel \ + ./.github/scripts/run_bazel_with_buildbuddy.py \ --noexperimental_remote_repo_contents_cache \ "${bazel_args[@]}" \ - "--config=${{ matrix.bazel_config }}" \ - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" + "--config=${{ matrix.bazel_config }}" - name: Stage release pair env: diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 71ce5d7865..0ad9f850d1 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -5,6 +5,7 @@ on: paths: - ".bazelrc" - ".github/actions/setup-bazel-ci/**" + - ".github/scripts/run_bazel_with_buildbuddy.py" - ".github/scripts/rusty_v8_bazel.py" - ".github/scripts/rusty_v8_module_bazel.py" - ".github/workflows/rusty-v8-release.yml" @@ -23,6 +24,7 @@ on: paths: - ".bazelrc" - ".github/actions/setup-bazel-ci/**" + - ".github/scripts/run_bazel_with_buildbuddy.py" - ".github/scripts/rusty_v8_bazel.py" - ".github/scripts/rusty_v8_module_bazel.py" - ".github/workflows/rusty-v8-release.yml" @@ -203,11 +205,10 @@ jobs: bazel_args+=(--config=v8-release-compat) fi - bazel \ + ./.github/scripts/run_bazel_with_buildbuddy.py \ --noexperimental_remote_repo_contents_cache \ "${bazel_args[@]}" \ - "--config=${{ matrix.bazel_config }}" \ - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" + "--config=${{ matrix.bazel_config }}" - name: Stage release pair env: diff --git a/codex-rs/docs/bazel.md b/codex-rs/docs/bazel.md index a124688a24..085c15992f 100644 --- a/codex-rs/docs/bazel.md +++ b/codex-rs/docs/bazel.md @@ -4,7 +4,7 @@ This repository uses Bazel to build the Rust workspace under `codex-rs`. Cargo remains the source of truth for crates and features, while Bazel provides hermetic builds, toolchains, and cross-platform artifacts. -As of 1/9/2026, this setup is still experimental as we stabilize it. +As of 6/1/2026, this setup is still experimental as we stabilize it. ## High-level layout @@ -20,6 +20,118 @@ As of 1/9/2026, this setup is still experimental as we stabilize it. makes some adjustments if the crate needs additional compile-time or runtime data, or other customizations. +## Running Bazel locally + +The repository root `justfile` exposes the common Bazel entry points: + +```bash +just bazel-test +just bazel-clippy +``` + +Ordinary local `bazel` and `just` invocations run locally. BuildBuddy cache, +build event upload, downloads, and remote execution are opt-in configurations. + +## BuildBuddy + +Codex uses BuildBuddy for a shared Bazel cache and remoted builds and tests. To use it +to speed up your builds and tests you'll need to provide an API key and select a +configuration. + +### BuildBuddy API key + +If you're an OpenAI employee, log in to https://openai.buildbuddy.io and use Google sign-in. + +Create a BuildBuddy API key as described in BuildBuddy's [Authentication Guide][bb-auth-guide], +then add it to `~/.bazelrc`: + +```bazelrc +# Local machine only; this file contains a BuildBuddy credential. +common --remote_header=x-buildbuddy-api-key= +``` + +Keeping the credential outside the workspace reduces the risk of accidentally +committing it. + +If you need different API keys for different projects, put the API key in +`%workspace%/user.bazelrc` instead. The checked-in `.bazelrc` optionally imports +that file, and `.gitignore` excludes it. Do not commit or share a file containing +the credential. + +[bb-auth-guide]: https://www.buildbuddy.io/docs/guide-auth/#managing-keys + +### Selecting a remote build configuration + +OpenAI employees should default to the OpenAI host with remote execution unless +they have a reason to choose another configuration. Add the following configuration +to `%workspace%/user.bazelrc`: + +```bazelrc +common --config=buildbuddy-openai-rbe +``` + +OpenAI employees who don't want remote execution can use `buildbuddy-openai`. External users +should use `buildbuddy-generic-rbe` or `buildbuddy-generic`. See below for details on these +configurations. + +### All remote configurations + +GitHub Actions routes Bazel build and output-resolution commands through +`.github/scripts/run_bazel_with_buildbuddy.py`. Higher-level helpers such as +`.github/scripts/run-bazel-ci.sh` and `.github/scripts/rusty_v8_bazel.py` +delegate remote configuration selection to that wrapper. The wrapper reads the +GitHub Actions repository and event payload rather than relying on workflow +files to duplicate tenant-selection logic. + +Loading-phase target-discovery `bazel query` commands run locally because they +only enumerate labels and do not need remote caches or execution. + +The `Cache/BES` host is also used for remote downloads. + +| Invocation/config | Key Required | Cache/BES | Build exec | Test exec | +| --- | --- | --- | --- | --- | +| `bazel ...` | No | None | Local | Local | +| `bazel ... --config=buildbuddy-generic` | Yes | `remote.buildbuddy.io` | Local | Local | +| `bazel ... --config=buildbuddy-generic-rbe` | Yes | `remote.buildbuddy.io` | Remote | Remote | +| `bazel ... --config=buildbuddy-openai` | Yes | `openai.buildbuddy.io` | Local | Local | +| `bazel ... --config=buildbuddy-openai-rbe` | Yes | `openai.buildbuddy.io` | Remote | Remote | + +Without an API key, the wrapper removes remote CI configurations and runs +locally. With a key, workflows choose the host as follows: + +| Run | Key | Uses OpenAI BuildBuddy Host | +| --- | --- | --- | +| Push to `main` in `openai/codex` | Yes | Yes | +| `workflow_dispatch` in `openai/codex` | Yes | Yes | +| Same-repository pull request in `openai/codex` | Yes | Yes | +| Fork pull request into `openai/codex` | No | No; local | +| Push or `workflow_dispatch` in a fork with a key | Yes | No; generic host | +| Pull request run in a fork repository with a key | Yes | No; generic host | + +CI configurations determine whether builds and tests execute remotely: + +| CI config | Remote config | Build exec | Test exec | +| --- | --- | --- | --- | +| `ci-linux` | `*-rbe` | Remote host | Remote host | +| `ci-v8` | `*-rbe` | Remote host | Remote host | +| `ci-macos` | `*-rbe` | Remote host | Local | +| `ci-windows-cross` | `*-rbe` | Remote host | Local | +| `ci-windows` | non-RBE | Local | Local | +| Keyless CI fallback | none | Local | Local | + +To exercise the generic remote configuration with your key: + +```bash +BUILDBUDDY_API_KEY=... GITHUB_REPOSITORY=my-fork/codex \ + ./.github/scripts/run_bazel_with_buildbuddy.py \ + build --config=ci-linux //codex-rs/cli:codex +``` + +The wrapper selects the OpenAI host only inside GitHub Actions for a trusted +run in `openai/codex`. A missing or malformed pull request event +payload fails closed to the generic host. For local OpenAI host access, use +the `user.bazelrc` configuration above. + ## Evolving the setup When you add or change Rust dependencies, update the Cargo.toml/Cargo.lock as normal. diff --git a/justfile b/justfile index 34b5115ee0..fe7e7349b3 100644 --- a/justfile +++ b/justfile @@ -83,6 +83,12 @@ test *args: $env:RUST_MIN_STACK = "{{ rust_min_stack }}"; cargo nextest run --no-fail-fast @($args | Select-Object -Skip 1) just bench-smoke +# Run from the repository root so scripts that resolve paths from `cwd` see +# the same layout they use in GitHub Actions. +[no-cd] +test-github-scripts: + {{ python }} -m unittest discover -s {{ justfile_directory() }}/.github/scripts -p 'test_*.py' + # Run explicit workspace benchmark targets. bench *args: cargo bench --workspace --bench '*' {args} @@ -129,11 +135,8 @@ bazel-clippy: bazel-argument-comment-lint: bazel build --config=argument-comment-lint -- $({{ justfile_directory() }}/tools/argument-comment-lint/list-bazel-targets.sh) -bazel-remote-test: - bazel test --test_tag_filters=-argument-comment-lint //... --config=remote --platforms=//:rbe --keep_going - build-for-release: - bazel build //codex-rs/cli:release_binaries --config=remote + bazel build //codex-rs/cli:release_binaries # Run the MCP server mcp-server-run *args: diff --git a/scripts/list-bazel-clippy-targets.sh b/scripts/list-bazel-clippy-targets.sh index b76fc2a830..d12a256d00 100755 --- a/scripts/list-bazel-clippy-targets.sh +++ b/scripts/list-bazel-clippy-targets.sh @@ -20,23 +20,13 @@ while [[ $# -gt 0 ]]; do done # Resolve the dynamic targets before printing anything so callers do not -# continue with a partial list if `bazel query` fails. Reuse the same CI Bazel -# server settings as the subsequent build so Windows jobs do not cold-start a -# second Bazel server just for target discovery. -if [[ $windows_cross_compile -eq 1 ]]; then - manual_rust_test_targets="$( - ./.github/scripts/run-bazel-query-ci.sh \ - --windows-cross-compile \ - --output=label \ - -- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))' - )" -else - manual_rust_test_targets="$( - ./.github/scripts/run-bazel-query-ci.sh \ - --output=label \ - -- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))' - )" -fi +# continue with a partial list if `bazel query` fails. Target discovery is +# local on all platforms. +manual_rust_test_targets="$( + ./.github/scripts/run-bazel-query-ci.sh \ + --output=label \ + -- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))' +)" if [[ "${RUNNER_OS:-}" != "Windows" ]]; then # Non-Windows clippy jobs lint the native test binaries; the # Windows-cross binaries exist only for the fast Windows test leg. From 593df8773d8deeff7d778fe39b59420ce293d7a5 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 2 Jun 2026 10:57:35 -0700 Subject: [PATCH 24/59] core: derive built-in permission profiles from raw policies (#25739) ## Why Permission profiles that extend a built-in profile should behave like other TOML inheritance: parent entries provide defaults, and child keys override matching fields before the profile is compiled. That was not true for `:workspace`. Previously, a profile with `extends = ":workspace"` seeded the compiled runtime `PermissionProfile::workspace_write()` policy and then appended child filesystem entries. A child override such as `":tmpdir" = "read"` therefore left the inherited `":tmpdir" = "write"` entry in the final policy. Since same-target `write` wins over `read` during runtime resolution, the child override was ineffective. This also needs a clear source of truth for the built-in profiles. The protocol-level sandbox policy constructors now define the raw built-in filesystem entries, and both `PermissionProfile` presets and config-profile inheritance derive from those same values. ## What Changed - Add a canonical `FileSystemSandboxPolicy::read_only()` constructor while keeping the read-only and workspace-write raw filesystem entries explicit and independent. - Derive `PermissionProfile::read_only()` from `FileSystemSandboxPolicy::read_only()`; `PermissionProfile::workspace_write()` continues to derive from `FileSystemSandboxPolicy::workspace_write()`. - Build extensible `:read-only` and `:workspace` parent profiles by projecting those canonical sandbox policies into `PermissionProfileToml`, then merge user overrides at the TOML layer before compilation. - Add config parsing support for `:slash_tmp` so the built-in `:workspace` parent can be expressed in the same TOML-shaped filesystem table as user profiles. - Document that `PermissionsToml::resolve_profile()` returns an already-merged `PermissionProfileToml`, and return that profile directly after removing the resolved-profile wrapper. - Extend the config test for `extends = ":workspace"` to assert that inherited `":slash_tmp" = "write"` is preserved and that a child `":tmpdir" = "read"` entry replaces the inherited `write` entry. ## Verification - `just test -p codex-config` - `just test -p codex-protocol` - `just test -p codex-core permissions_profiles_resolve_extends_parent_first_with_child_overrides` - `just test -p codex-core default_permissions_profile_can_extend_builtin_workspace` - `just test -p codex-core` - Result: 2596 passed, 4 failed, 1 timed out. - The failures were existing sandbox/environment-sensitive tests unrelated to this permissions change: `suite::user_shell_cmd::user_shell_command_does_not_set_network_sandbox_env_var`, `suite::user_shell_cmd::user_shell_command_history_is_persisted_and_shared_with_model`, `suite::abort_tasks::interrupt_persists_turn_aborted_marker_in_next_request`, `suite::abort_tasks::interrupt_tool_records_history_entries`, and `thread_manager::tests::start_thread_uses_all_default_environments_from_codex_home`. --- codex-rs/config/src/permissions_toml.rs | 25 +-- codex-rs/core/src/config/config_tests.rs | 44 ++++- codex-rs/core/src/config/permissions.rs | 160 ++++++++++++++---- codex-rs/core/src/config/permissions_tests.rs | 3 +- codex-rs/core/src/network_proxy_loader.rs | 2 +- codex-rs/protocol/src/models.rs | 11 +- codex-rs/protocol/src/permissions.rs | 24 +-- 7 files changed, 195 insertions(+), 74 deletions(-) diff --git a/codex-rs/config/src/permissions_toml.rs b/codex-rs/config/src/permissions_toml.rs index 0baec2a5cc..988e21e539 100644 --- a/codex-rs/config/src/permissions_toml.rs +++ b/codex-rs/config/src/permissions_toml.rs @@ -30,11 +30,18 @@ impl PermissionsToml { self.entries.is_empty() } + /// Resolve `profile_name` and all of its `extends` ancestors into one TOML + /// profile. + /// + /// Parent profiles are merged before their children, so child keys override + /// matching parent keys before callers compile the profile into runtime + /// permissions. The returned profile keeps the selected profile's + /// declaration metadata, such as `description` and `extends`. pub fn resolve_profile( &self, profile_name: &str, mut parent_profile: F, - ) -> Result + ) -> Result where F: FnMut(&str) -> Option, { @@ -96,10 +103,7 @@ impl PermissionsToml { .into_iter() .rev() .try_fold(profile, merge_permission_profiles)?; - return Ok(ResolvedPermissionProfileToml { - profile, - inherited_profile_names: profile_names.into_iter().skip(1).collect(), - }); + return Ok(profile); } } } @@ -114,17 +118,6 @@ pub struct PermissionProfileToml { 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}`")] diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 05cbb9828f..65d20d94f0 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -2357,7 +2357,13 @@ async fn default_permissions_profile_can_extend_builtin_workspace() -> std::io:: description: None, extends: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), workspace_roots: None, - filesystem: None, + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: BTreeMap::from([( + ":tmpdir".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), network: Some(NetworkToml { enabled: Some(true), ..Default::default() @@ -2384,6 +2390,42 @@ async fn default_permissions_profile_can_extend_builtin_workspace() -> std::io:: !policy.can_write_path_with_cwd(&cwd.path().join(".git"), cwd.path()), "expected profile extending :workspace to keep metadata carveouts, policy: {policy:?}" ); + assert!( + policy.entries.iter().any(|entry| matches!( + entry, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::SlashTmp, + }, + access: FileSystemAccessMode::Write, + } + )), + "expected profile extending :workspace to keep inherited :slash_tmp writes, policy: {policy:?}" + ); + assert!( + policy.entries.iter().any(|entry| matches!( + entry, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Tmpdir, + }, + access: FileSystemAccessMode::Read, + } + )), + "expected child :tmpdir read entry to replace the inherited write entry, policy: {policy:?}" + ); + assert!( + !policy.entries.iter().any(|entry| matches!( + entry, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Tmpdir, + }, + access: FileSystemAccessMode::Write, + } + )), + "expected inherited :tmpdir write entry to be removed, policy: {policy:?}" + ); assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Enabled diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index a8546858f1..f683d9c7eb 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::collections::BTreeMap; use std::io; use std::path::Component; use std::path::Path; @@ -13,7 +14,6 @@ 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; @@ -194,27 +194,132 @@ pub(crate) fn apply_network_proxy_feature_config( pub(crate) fn resolve_permission_profile( permissions: &PermissionsToml, profile_name: &str, -) -> io::Result { +) -> io::Result { permissions - .resolve_profile(profile_name, extensible_builtin_parent_profile_marker) + .resolve_profile(profile_name, extensible_builtin_parent_profile) .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 { +fn extensible_builtin_parent_profile(profile_name: &str) -> Option { + let file_system = match profile_name { + BUILT_IN_READ_ONLY_PROFILE => FileSystemSandboxPolicy::read_only(), + BUILT_IN_WORKSPACE_PROFILE => FileSystemSandboxPolicy::workspace_write( + &[], + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ), + _ => return None, + }; + Some(permission_profile_toml_from_file_system_policy(file_system)) +} + +fn permission_profile_toml_from_file_system_policy( + file_system: FileSystemSandboxPolicy, +) -> PermissionProfileToml { + let mut filesystem = FilesystemPermissionsToml { + glob_scan_max_depth: file_system.glob_scan_max_depth, + entries: BTreeMap::new(), + }; + for entry in file_system.entries { + insert_filesystem_permission_toml(&mut filesystem.entries, entry); + } + PermissionProfileToml { description: None, extends: None, workspace_roots: None, - filesystem: None, + filesystem: Some(filesystem), network: None, - }) + } +} + +fn insert_filesystem_permission_toml( + entries: &mut BTreeMap, + entry: FileSystemSandboxEntry, +) { + match entry.path { + FileSystemPath::Path { path } => { + entries.insert( + path.into_path_buf().to_string_lossy().into_owned(), + FilesystemPermissionToml::Access(entry.access), + ); + } + FileSystemPath::GlobPattern { pattern } => { + entries.insert(pattern, FilesystemPermissionToml::Access(entry.access)); + } + FileSystemPath::Special { value } => { + insert_special_filesystem_permission_toml(entries, value, entry.access); + } + } +} + +fn insert_special_filesystem_permission_toml( + entries: &mut BTreeMap, + value: FileSystemSpecialPath, + access: FileSystemAccessMode, +) { + match value { + FileSystemSpecialPath::Root => { + entries.insert( + ":root".to_string(), + FilesystemPermissionToml::Access(access), + ); + } + FileSystemSpecialPath::Minimal => { + entries.insert( + ":minimal".to_string(), + FilesystemPermissionToml::Access(access), + ); + } + FileSystemSpecialPath::ProjectRoots { subpath } => { + insert_scoped_filesystem_permission_toml( + entries, + ":workspace_roots".to_string(), + subpath.unwrap_or_else(|| PathBuf::from(".")), + access, + ); + } + FileSystemSpecialPath::Tmpdir => { + entries.insert( + ":tmpdir".to_string(), + FilesystemPermissionToml::Access(access), + ); + } + FileSystemSpecialPath::SlashTmp => { + entries.insert( + ":slash_tmp".to_string(), + FilesystemPermissionToml::Access(access), + ); + } + FileSystemSpecialPath::Unknown { path, subpath } => { + if let Some(subpath) = subpath { + insert_scoped_filesystem_permission_toml(entries, path, subpath, access); + } else { + entries.insert(path, FilesystemPermissionToml::Access(access)); + } + } + }; +} + +fn insert_scoped_filesystem_permission_toml( + entries: &mut BTreeMap, + path: String, + subpath: PathBuf, + access: FileSystemAccessMode, +) { + let permission = entries + .entry(path) + .or_insert_with(|| FilesystemPermissionToml::Scoped(BTreeMap::new())); + match permission { + FilesystemPermissionToml::Scoped(scoped_entries) => { + scoped_entries.insert(subpath.to_string_lossy().into_owned(), access); + } + FilesystemPermissionToml::Access(_) => { + *permission = FilesystemPermissionToml::Scoped(BTreeMap::from([( + subpath.to_string_lossy().into_owned(), + access, + )])); + } + } } pub(crate) fn network_proxy_config_for_profile_selection( @@ -232,7 +337,7 @@ pub(crate) fn network_proxy_config_for_profile_selection( "default_permissions requires a `[permissions]` table", ) })?; - let profile = resolve_permission_profile(permissions, profile_name)?.profile; + let profile = resolve_permission_profile(permissions, profile_name)?; Ok(network_proxy_config_from_profile_network( profile.network.as_ref(), )) @@ -244,25 +349,9 @@ pub(crate) fn compile_permission_profile( policy_cwd: &Path, startup_warnings: &mut Vec, ) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { - 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, - ) - }); + let profile = resolve_permission_profile(permissions, profile_name)?; + let mut file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(Vec::new()); + let base_network_sandbox_policy = NetworkSandboxPolicy::Restricted; if let Some(filesystem) = profile.filesystem.as_ref() { if filesystem.is_empty() && file_system_sandbox_policy.entries.is_empty() { push_warning( @@ -358,7 +447,7 @@ pub(crate) fn compile_permission_profile_workspace_roots( })?; let profile = resolve_permission_profile(permissions, profile_name)?; Ok(compile_workspace_roots( - profile.profile.workspace_roots.as_ref(), + profile.workspace_roots.as_ref(), policy_cwd, )) } @@ -693,6 +782,7 @@ fn parse_special_path(path: &str) -> Option { ":minimal" => Some(FileSystemSpecialPath::Minimal), ":workspace_roots" => Some(FileSystemSpecialPath::project_roots(/*subpath*/ None)), ":tmpdir" => Some(FileSystemSpecialPath::Tmpdir), + ":slash_tmp" => Some(FileSystemSpecialPath::SlashTmp), _ if path.starts_with(':') => { Some(FileSystemSpecialPath::unknown(path, /*subpath*/ None)) } diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index 41f4179ba8..88a757181e 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -326,8 +326,7 @@ allow_local_binding = true ) .expect("expected profile should deserialize"); - assert_eq!(resolved.profile, expected_profile); - assert_eq!(resolved.inherited_profile_names, vec!["base".to_string()]); + assert_eq!(resolved, expected_profile); } #[test] diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 7d67ad7859..a2d5fdaa19 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -210,7 +210,7 @@ fn selected_network_from_tables(parsed: NetworkTablesToml) -> Result Self { + let file_system = FileSystemSandboxPolicy::read_only(); Self::Managed { - file_system: ManagedFileSystemPermissions::Restricted { - entries: vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }], - glob_scan_max_depth: None, - }, + file_system: ManagedFileSystemPermissions::from_sandbox_policy(&file_system), network: NetworkSandboxPolicy::Restricted, } } diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs index f90859a92b..e5fbc5a0bb 100644 --- a/codex-rs/protocol/src/permissions.rs +++ b/codex-rs/protocol/src/permissions.rs @@ -358,22 +358,26 @@ pub fn project_roots_glob_pattern(subpath: &Path) -> String { format!("{PROJECT_ROOTS_GLOB_PATTERN_PREFIX}{}", subpath.display()) } +fn read_only_file_system_entries() -> Vec { + vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }] +} + impl Default for FileSystemSandboxPolicy { fn default() -> Self { - Self { - kind: FileSystemSandboxKind::Restricted, - glob_scan_max_depth: None, - entries: vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }], - } + Self::read_only() } } impl FileSystemSandboxPolicy { + pub fn read_only() -> Self { + Self::restricted(read_only_file_system_entries()) + } + pub fn unrestricted() -> Self { Self { kind: FileSystemSandboxKind::Unrestricted, From 9e3d5f29e2b6e2bcad3b0384e35e1faaa13e8272 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 2 Jun 2026 11:06:01 -0700 Subject: [PATCH 25/59] [codex] Revert shared BuildBuddy Bazel wrapper (#25909) ## Why PR #25905 intentionally adds a failing `codex-core` unit test, but its [Bazel test on Windows check](https://github.com/openai/codex/actions/runs/26837526950/job/79135369259) passed. That shows the Bazel configuration introduced by #25156 is not behaving as expected, so revert it while the configuration can be investigated separately. ## What changed Revert #25156 in full, restoring the previous Bazel remote configuration, CI scripts, workflows, `rusty_v8` handling, and documentation. This removes the shared BuildBuddy wrapper and its tests. ## Validation Not run locally; this exact revert was prioritized for a fast rollback. --- .bazelrc | 46 ++--- .github/scripts/run-bazel-ci.sh | 126 +++++++----- .github/scripts/run-bazel-query-ci.sh | 56 ++++-- .github/scripts/run_bazel_with_buildbuddy.py | 142 -------------- .github/scripts/rusty_v8_bazel.py | 63 +++--- .../scripts/test_run_bazel_with_buildbuddy.py | 184 ------------------ .github/scripts/test_rusty_v8_bazel.py | 49 ++--- .github/workflows/bazel.yml | 8 +- .github/workflows/rusty-v8-release.yml | 5 +- .github/workflows/v8-canary.yml | 7 +- codex-rs/docs/bazel.md | 114 +---------- justfile | 11 +- scripts/list-bazel-clippy-targets.sh | 24 ++- 13 files changed, 218 insertions(+), 617 deletions(-) delete mode 100755 .github/scripts/run_bazel_with_buildbuddy.py delete mode 100644 .github/scripts/test_run_bazel_with_buildbuddy.py diff --git a/.bazelrc b/.bazelrc index e39a3aff22..6357eb83e4 100644 --- a/.bazelrc +++ b/.bazelrc @@ -38,50 +38,24 @@ common:windows --test_env=WINDIR common --test_env=RUST_MIN_STACK=8388608 # 8 MiB common --test_output=errors -common --nobuild_runfile_links -# These settings tune BuildBuddy/RBE behavior but do not contact a remote -# service unless a `buildbuddy-*` configuration below supplies an endpoint. +common --bes_results_url=https://app.buildbuddy.io/invocation/ +common --bes_backend=grpcs://remote.buildbuddy.io +common --remote_cache=grpcs://remote.buildbuddy.io common --remote_download_toplevel +common --nobuild_runfile_links common --remote_timeout=3600 common --noexperimental_throttle_remote_action_building common --experimental_remote_execution_keepalive common --grpc_keepalive_time=30s - -# Opt-in remote configurations selected by -# `.github/scripts/run_bazel_with_buildbuddy.py`. Plain Bazel commands do not -# contact BuildBuddy unless a user selects one of these configurations. -# Use the generic host for cache, BES, and downloads without remote execution. -common:buildbuddy-generic --bes_backend=grpcs://remote.buildbuddy.io -common:buildbuddy-generic --bes_results_url=https://app.buildbuddy.io/invocation/ -common:buildbuddy-generic --remote_cache=grpcs://remote.buildbuddy.io -common:buildbuddy-generic --experimental_remote_downloader=grpcs://remote.buildbuddy.io - -# Add remote execution on the generic host. -common:buildbuddy-generic-rbe --config=buildbuddy-generic -common:buildbuddy-generic-rbe --config=remote -common:buildbuddy-generic-rbe --remote_executor=grpcs://remote.buildbuddy.io - -# Use the OpenAI tenant for cache, BES, and downloads without remote execution. -common:buildbuddy-openai --bes_backend=grpcs://openai.buildbuddy.io -common:buildbuddy-openai --bes_results_url=https://openai.buildbuddy.io/invocation/ -common:buildbuddy-openai --remote_cache=grpcs://openai.buildbuddy.io -common:buildbuddy-openai --experimental_remote_downloader=grpcs://openai.buildbuddy.io - -# Add remote execution on the OpenAI tenant. -common:buildbuddy-openai-rbe --config=buildbuddy-openai -common:buildbuddy-openai-rbe --config=remote -common:buildbuddy-openai-rbe --remote_executor=grpcs://openai.buildbuddy.io +common --experimental_remote_downloader=grpcs://remote.buildbuddy.io # This limits both in-flight executions and concurrent downloads. Even with high number # of jobs execution will still be limited by CPU cores, so this just pays a bit of # memory in exchange for higher download concurrency. common --jobs=30 -# Shared remote execution policy. The endpoint-bearing `buildbuddy-*-rbe` -# configurations include this group; CI configs override TestRunner below -# when tests must remain local on their runner. -common:remote --strategy=remote common:remote --extra_execution_platforms=//:rbe +common:remote --remote_executor=grpcs://remote.buildbuddy.io common:remote --jobs=800 # TODO(team): Evaluate if this actually helps, zbarsky is not sure, everything seems bottlenecked on `core` either way. # Enable pipelined compilation since we are not bound by local CPU count. @@ -172,11 +146,15 @@ common:ci-windows --repo_contents_cache=D:/a/.cache/bazel-repo-contents-cache # Linux crossbuilds don't work until we untangle the libc constraint mess. common:ci-linux --config=ci-bazel common:ci-linux --build_metadata=TAG_os=linux +common:ci-linux --config=remote +common:ci-linux --strategy=remote common:ci-linux --platforms=//:rbe # On mac, we can run all the build actions remotely but test actions locally. common:ci-macos --config=ci-bazel common:ci-macos --build_metadata=TAG_os=macos +common:ci-macos --config=remote +common:ci-macos --strategy=remote common:ci-macos --strategy=TestRunner=darwin-sandbox,local # On Windows, use Linux remote execution for build actions but keep test actions @@ -184,7 +162,9 @@ common:ci-macos --strategy=TestRunner=darwin-sandbox,local # still run against Windows binaries. common:ci-windows-cross --config=ci-windows common:ci-windows-cross --build_metadata=TAG_windows_cross_compile=true +common:ci-windows-cross --config=remote common:ci-windows-cross --host_platform=//:rbe +common:ci-windows-cross --strategy=remote common:ci-windows-cross --strategy=TestRunner=local common:ci-windows-cross --local_test_jobs=4 common:ci-windows-cross --test_env=RUST_TEST_THREADS=1 @@ -200,6 +180,8 @@ common:ci-windows-cross --extra_toolchains=//:windows_gnullvm_tests_on_msvc_host common:ci-v8 --config=ci common:ci-v8 --build_metadata=TAG_workflow=v8 common:ci-v8 --build_metadata=TAG_os=linux +common:ci-v8 --config=remote +common:ci-v8 --strategy=remote # Source-built Bazel V8 artifacts use the in-process sandbox by default. This # does not affect Cargo's default prebuilt rusty_v8 path. diff --git a/.github/scripts/run-bazel-ci.sh b/.github/scripts/run-bazel-ci.sh index 89f937a998..f98e4d8cb9 100755 --- a/.github/scripts/run-bazel-ci.sh +++ b/.github/scripts/run-bazel-ci.sh @@ -53,20 +53,11 @@ fi run_bazel() { if [[ "${RUNNER_OS:-}" == "Windows" ]]; then - MSYS2_ARG_CONV_EXCL='*' "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@" + MSYS2_ARG_CONV_EXCL='*' bazel "$@" return fi - "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@" -} - -run_bazel_with_startup_args() { - if (( ${#bazel_startup_args[@]} > 0 )); then - run_bazel "${bazel_startup_args[@]}" "$@" - return - fi - - run_bazel "$@" + bazel "$@" } ci_config=ci-linux @@ -86,16 +77,23 @@ esac print_bazel_test_log_tails() { local console_log="$1" local testlogs_dir - + local -a bazel_info_cmd=(bazel) local -a bazel_info_args=(info) - if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then - # `bazel info` needs the same CI config as the failed test invocation so - # platform-specific output roots match. On Windows, omitting `ci-windows` - # would point at `local_windows-fastbuild` even when the test ran with the - # MSVC host platform under `local_windows_msvc-fastbuild`. - bazel_info_args+=("--config=${ci_config}") + + if (( ${#bazel_startup_args[@]} > 0 )); then + bazel_info_cmd+=("${bazel_startup_args[@]}") fi + # `bazel info` needs the same CI config as the failed test invocation so + # platform-specific output roots match. On Windows, omitting `ci-windows` + # would point at `local_windows-fastbuild` even when the test ran with the + # MSVC host platform under `local_windows_msvc-fastbuild`. + if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then + bazel_info_args+=( + "--config=${ci_config}" + "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" + ) + fi # Only pass flags that affect Bazel's output-root selection or repository # lookup. Test/build-only flags such as execution logs or remote download # mode can make `bazel info` fail, which would hide the real test log path. @@ -107,7 +105,7 @@ print_bazel_test_log_tails() { esac done - testlogs_dir="$(run_bazel_with_startup_args \ + testlogs_dir="$(run_bazel "${bazel_info_cmd[@]:1}" \ --noexperimental_remote_repo_contents_cache \ "${bazel_info_args[@]}" \ bazel-testlogs 2>/dev/null || echo bazel-testlogs)" @@ -256,9 +254,8 @@ if [[ ${#bazel_args[@]} -eq 0 || ${#bazel_targets[@]} -eq 0 ]]; then fi if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then - # Windows cross-compilation depends on authenticated RBE. Preserve the local - # Windows build shape when credentials are unavailable. - ci_config=ci-windows + # Fork PRs do not receive the BuildBuddy secret needed for the remote + # cross-compile config. Preserve the previous local Windows build shape. windows_msvc_host_platform=1 fi @@ -300,9 +297,9 @@ if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -n "${BUI fi if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then - # The Windows cross-compile config depends on authenticated remote - # execution. When credentials are unavailable, keep the local build shape - # and its lower concurrency cap. + # The Windows cross-compile config depends on remote execution. Fork PRs do + # not receive the BuildBuddy secret, so fall back to the existing local build + # shape and keep its lower concurrency cap. post_config_bazel_args+=(--jobs=8) fi @@ -380,31 +377,70 @@ fi bazel_console_log="$(mktemp)" trap 'rm -f "$bazel_console_log"' EXIT -bazel_run_args=( - "${bazel_args[@]}" -) +bazel_cmd=(bazel) +if (( ${#bazel_startup_args[@]} > 0 )); then + bazel_cmd+=("${bazel_startup_args[@]}") +fi + if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then echo "BuildBuddy API key is available; using remote Bazel configuration." - bazel_run_args+=("--config=${ci_config}") + # Work around Bazel 9 remote repo contents cache / overlay materialization failures + # seen in CI (for example "is not a symlink" or permission errors while + # materializing external repos such as rules_perl). We still use BuildBuddy for + # remote execution/cache; this only disables the startup-level repo contents cache. + bazel_run_args=( + "${bazel_args[@]}" + "--config=${ci_config}" + "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" + ) + if (( ${#post_config_bazel_args[@]} > 0 )); then + bazel_run_args+=("${post_config_bazel_args[@]}") + fi + set +e + run_bazel "${bazel_cmd[@]:1}" \ + --noexperimental_remote_repo_contents_cache \ + "${bazel_run_args[@]}" \ + -- \ + "${bazel_targets[@]}" \ + 2>&1 | tee "$bazel_console_log" + bazel_status=${PIPESTATUS[0]} + set -e else echo "BuildBuddy API key is not available; using local Bazel configuration." + # Keep fork/community PRs on Bazel but disable remote services that are + # configured in .bazelrc and require auth. + # + # Flag docs: + # - Command-line reference: https://bazel.build/reference/command-line-reference + # - Remote caching overview: https://bazel.build/remote/caching + # - Remote execution overview: https://bazel.build/remote/rbe + # - Build Event Protocol overview: https://bazel.build/remote/bep + # + # --noexperimental_remote_repo_contents_cache: + # disable remote repo contents cache enabled in .bazelrc startup options. + # https://bazel.build/reference/command-line-reference#startup_options-flag--experimental_remote_repo_contents_cache + # --remote_cache= and --remote_executor=: + # clear remote cache/execution endpoints configured in .bazelrc. + # https://bazel.build/reference/command-line-reference#common_options-flag--remote_cache + # https://bazel.build/reference/command-line-reference#common_options-flag--remote_executor + bazel_run_args=( + "${bazel_args[@]}" + --remote_cache= + --remote_executor= + ) + if (( ${#post_config_bazel_args[@]} > 0 )); then + bazel_run_args+=("${post_config_bazel_args[@]}") + fi + set +e + run_bazel "${bazel_cmd[@]:1}" \ + --noexperimental_remote_repo_contents_cache \ + "${bazel_run_args[@]}" \ + -- \ + "${bazel_targets[@]}" \ + 2>&1 | tee "$bazel_console_log" + bazel_status=${PIPESTATUS[0]} + set -e fi -if (( ${#post_config_bazel_args[@]} > 0 )); then - bazel_run_args+=("${post_config_bazel_args[@]}") -fi -set +e -# Work around Bazel 9 remote repo contents cache / overlay materialization -# failures seen in CI (for example "is not a symlink" or permission errors -# while materializing external repos such as rules_perl). This only disables -# the startup-level repo contents cache; keyed runs still use BuildBuddy. -run_bazel_with_startup_args \ - --noexperimental_remote_repo_contents_cache \ - "${bazel_run_args[@]}" \ - -- \ - "${bazel_targets[@]}" \ - 2>&1 | tee "$bazel_console_log" -bazel_status=${PIPESTATUS[0]} -set -e if [[ ${bazel_status:-0} -ne 0 ]]; then if [[ $print_failed_bazel_action_summary -eq 1 ]]; then diff --git a/.github/scripts/run-bazel-query-ci.sh b/.github/scripts/run-bazel-query-ci.sh index f5d4f56f49..dd03b67169 100755 --- a/.github/scripts/run-bazel-query-ci.sh +++ b/.github/scripts/run-bazel-query-ci.sh @@ -2,17 +2,48 @@ set -euo pipefail -# Run target-discovery queries with the same startup settings as the main -# build/test invocation so they can reuse the same Bazel server. Queries only -# enumerate labels, so they intentionally do not select CI or remote configs. +# Run Bazel queries with the same CI startup settings as the main build/test +# invocation so target-discovery queries can reuse the same Bazel server. -if [[ $# -lt 2 || "${@: -2:1}" != "--" ]]; then - echo "Usage: $0 [...] -- " >&2 +query_args=() +windows_cross_compile=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --windows-cross-compile) + windows_cross_compile=1 + shift + ;; + --) + shift + break + ;; + *) + query_args+=("$1") + shift + ;; + esac +done + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 [--windows-cross-compile] [...] -- " >&2 exit 1 fi -query_args=("${@:1:$#-2}") -query_expression="${@: -1}" +query_expression="$1" + +ci_config=ci-linux +case "${RUNNER_OS:-}" in + macOS) + ci_config=ci-macos + ;; + Windows) + if [[ $windows_cross_compile -eq 1 ]]; then + ci_config=ci-windows-cross + else + ci_config=ci-windows + fi + ;; +esac bazel_startup_args=() if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then @@ -29,6 +60,12 @@ run_bazel() { } bazel_query_args=(--noexperimental_remote_repo_contents_cache query) +if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then + bazel_query_args+=( + "--config=${ci_config}" + "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" + ) +fi if [[ -n "${BAZEL_REPO_CONTENTS_CACHE:-}" ]]; then bazel_query_args+=("--repo_contents_cache=${BAZEL_REPO_CONTENTS_CACHE}") @@ -38,10 +75,7 @@ if [[ -n "${BAZEL_REPOSITORY_CACHE:-}" ]]; then bazel_query_args+=("--repository_cache=${BAZEL_REPOSITORY_CACHE}") fi -if (( ${#query_args[@]} > 0 )); then - bazel_query_args+=("${query_args[@]}") -fi -bazel_query_args+=("$query_expression") +bazel_query_args+=("${query_args[@]}" "$query_expression") if (( ${#bazel_startup_args[@]} > 0 )); then run_bazel "${bazel_startup_args[@]}" "${bazel_query_args[@]}" diff --git a/.github/scripts/run_bazel_with_buildbuddy.py b/.github/scripts/run_bazel_with_buildbuddy.py deleted file mode 100755 index add95a99e5..0000000000 --- a/.github/scripts/run_bazel_with_buildbuddy.py +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env python3 - -import json -import os -import sys -from collections.abc import Mapping -from collections.abc import Sequence -from pathlib import Path - - -OPENAI_REPOSITORY = "openai/codex" -# Remote configurations select cache/BES/download endpoints. Their -rbe forms -# also select the matching remote executor endpoint. -GENERIC_REMOTE_CONFIG = "buildbuddy-generic" -OPENAI_REMOTE_CONFIG = "buildbuddy-openai" -# These CI configurations require remote build execution. The wrapper supplies -# an RBE configuration, which also includes the common `remote` settings. -REMOTE_EXECUTION_CONFIGS = { - "--config=ci-linux", - "--config=ci-macos", - "--config=ci-v8", - "--config=ci-windows-cross", -} -# Only authenticated workflow runs executing trusted upstream code may use the -# OpenAI BuildBuddy host. A pull request event without proof that its head is -# in the upstream repository fails closed to the generic host. -def is_trusted_upstream_run(env: Mapping[str, str]) -> bool: - # `GITHUB_REPOSITORY` is easy to set locally. Requiring GitHub's workflow - # marker prevents a local command from opting itself into the OpenAI host. - if ( - env.get("GITHUB_ACTIONS") != "true" - or env.get("GITHUB_REPOSITORY") != OPENAI_REPOSITORY - ): - return False - # Non-PR workflow runs in `openai/codex` execute upstream refs, so they are - # trusted. Fork code reaches these workflows only through pull requests. - if env.get("GITHUB_EVENT_NAME") != "pull_request": - return True - - event_path = env.get("GITHUB_EVENT_PATH") - if not event_path: - return False - try: - event = json.loads(Path(event_path).read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return False - - try: - return event["pull_request"]["head"]["repo"]["fork"] is False - except (KeyError, TypeError): - return False - - -def uses_openai_host(env: Mapping[str, str]) -> bool: - return bool(env.get("BUILDBUDDY_API_KEY")) and is_trusted_upstream_run(env) - - -def uses_remote_execution(args: Sequence[str]) -> bool: - try: - separator_idx = args.index("--") - except ValueError: - separator_idx = len(args) - return any(arg in REMOTE_EXECUTION_CONFIGS for arg in args[:separator_idx]) - - -def remote_config(args: Sequence[str], env: Mapping[str, str]) -> str | None: - if not env.get("BUILDBUDDY_API_KEY"): - return None - - config = OPENAI_REMOTE_CONFIG if uses_openai_host(env) else GENERIC_REMOTE_CONFIG - if uses_remote_execution(args): - config += "-rbe" - return config - - -def bazel_args_without_remote_execution(args: Sequence[str]) -> list[str]: - # Remote CI configs require BuildBuddy credentials. Removing them preserves - # the local fallback used for fork pull requests. - try: - separator_idx = args.index("--") - except ValueError: - separator_idx = len(args) - return [ - *(arg for arg in args[:separator_idx] if arg not in REMOTE_EXECUTION_CONFIGS), - *args[separator_idx:], - ] - - -def bazel_args_with_remote_config( - args: Sequence[str], env: Mapping[str, str] -) -> list[str]: - config = remote_config(args, env) - if config is None: - return bazel_args_without_remote_execution(args) - - # `remote_config()` returns a configuration only when this key is present. - api_key = env["BUILDBUDDY_API_KEY"] - remote_args = [ - f"--config={config}", - f"--remote_header=x-buildbuddy-api-key={api_key}", - ] - - # Insert immediately after the Bazel command. This keeps wrapper-added - # options out of positional payloads and lets later CI configs override - # shared RBE defaults such as the Windows cross-compilation exec platforms. - insertion_idx = next( - (idx + 1 for idx, arg in enumerate(args) if not arg.startswith("-")), - len(args), - ) - return [*args[:insertion_idx], *remote_args, *args[insertion_idx:]] - - -def bazel_command(*args: str, env: Mapping[str, str] | None = None) -> list[str]: - env = os.environ if env is None else env - bazel = env.get("CODEX_BAZEL_BIN", "bazel") - return [bazel, *bazel_args_with_remote_config(args, env)] - - -def main() -> None: - config = remote_config(sys.argv[1:], os.environ) - if config is None: - print( - "BuildBuddy key unavailable; using local Bazel configuration.", - file=sys.stderr, - ) - else: - host_description = ( - "OpenAI tenant" if uses_openai_host(os.environ) else "generic" - ) - print( - f"Using {host_description} BuildBuddy configuration: {config}.", - file=sys.stderr, - ) - - command = bazel_command(*sys.argv[1:]) - # Replace the wrapper so Bazel receives signals directly and supplies the - # command exit status; a subprocess parent would have no remaining work. - os.execvp(command[0], command) - - -if __name__ == "__main__": - main() diff --git a/.github/scripts/rusty_v8_bazel.py b/.github/scripts/rusty_v8_bazel.py index 329d3f6c54..2f46daf45e 100644 --- a/.github/scripts/rusty_v8_bazel.py +++ b/.github/scripts/rusty_v8_bazel.py @@ -5,6 +5,7 @@ from __future__ import annotations import argparse import gzip import hashlib +import os import re import shutil import subprocess @@ -12,7 +13,6 @@ import sys import tomllib from pathlib import Path -from run_bazel_with_buildbuddy import bazel_command from rusty_v8_module_bazel import ( RustyV8ChecksumError, check_module_bazel, @@ -29,22 +29,33 @@ SANDBOX_ARTIFACT_PROFILE = "ptrcomp_sandbox_release" ARTIFACT_BAZEL_CONFIGS = ["rusty-v8-upstream-libcxx"] +def bazel_remote_args() -> list[str]: + buildbuddy_api_key = os.environ.get("BUILDBUDDY_API_KEY") + if not buildbuddy_api_key: + return [] + return [f"--remote_header=x-buildbuddy-api-key={buildbuddy_api_key}"] + + def bazel_execroot() -> Path: - output = subprocess.check_output( - bazel_command("info", "execution_root"), + result = subprocess.run( + ["bazel", "info", "execution_root"], cwd=ROOT, + check=True, + capture_output=True, text=True, ) - return Path(output.strip()) + return Path(result.stdout.strip()) def bazel_output_base() -> Path: - output = subprocess.check_output( - bazel_command("info", "output_base"), + result = subprocess.run( + ["bazel", "info", "output_base"], cwd=ROOT, + check=True, + capture_output=True, text=True, ) - return Path(output.strip()) + return Path(result.stdout.strip()) def bazel_output_path(path: str) -> Path: @@ -61,22 +72,24 @@ def bazel_output_files( ) -> list[Path]: expression = "set(" + " ".join(labels) + ")" bazel_configs = bazel_configs or [] - output = subprocess.check_output( - bazel_command( + result = subprocess.run( + [ + "bazel", "cquery", "-c", compilation_mode, f"--platforms=@llvm//platforms:{platform}", *[f"--config={config}" for config in bazel_configs], + *bazel_remote_args(), "--output=files", expression, - ), + ], cwd=ROOT, + check=True, + capture_output=True, text=True, ) - return [ - bazel_output_path(line.strip()) for line in output.splitlines() if line.strip() - ] + return [bazel_output_path(line.strip()) for line in result.stdout.splitlines() if line.strip()] def bazel_build( @@ -89,15 +102,17 @@ def bazel_build( bazel_configs = bazel_configs or [] download_args = ["--remote_download_toplevel"] if download_toplevel else [] subprocess.run( - bazel_command( + [ + "bazel", "build", "-c", compilation_mode, f"--platforms=@llvm//platforms:{platform}", *[f"--config={config}" for config in bazel_configs], + *bazel_remote_args(), *download_args, *labels, - ), + ], cwd=ROOT, check=True, ) @@ -157,7 +172,7 @@ def resolved_v8_crate_version() -> str: matches = sorted( set( re.findall( - r"https://static\.crates\.io/crates/v8/v8-([0-9]+\.[0-9]+\.[0-9]+)\.crate", + r'https://static\.crates\.io/crates/v8/v8-([0-9]+\.[0-9]+\.[0-9]+)\.crate', module_bazel, ) ) @@ -219,17 +234,13 @@ def stage_artifacts( output_dir: Path, sandbox: bool, ) -> None: - missing_paths = [ - str(path) for path in [lib_path, binding_path] if not path.exists() - ] + missing_paths = [str(path) for path in [lib_path, binding_path] if not path.exists()] if missing_paths: raise SystemExit(f"missing release outputs for {target}: {missing_paths}") output_dir.mkdir(parents=True, exist_ok=True) artifact_profile = SANDBOX_ARTIFACT_PROFILE if sandbox else RELEASE_ARTIFACT_PROFILE - staged_library = output_dir / staged_archive_name( - target, lib_path, artifact_profile - ) + staged_library = output_dir / staged_archive_name(target, lib_path, artifact_profile) staged_binding = output_dir / staged_binding_name(target, artifact_profile) with lib_path.open("rb") as src, staged_library.open("wb") as dst: @@ -259,9 +270,7 @@ def stage_artifacts( def upstream_release_pair_paths(source_root: Path, target: str) -> tuple[Path, Path]: - lib_name = ( - "rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a" - ) + lib_name = "rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a" gn_out = source_root / "target" / target / "release" / "gn_out" return gn_out / "obj" / lib_name, gn_out / "src_binding.rs" @@ -329,9 +338,7 @@ def parse_args() -> argparse.Namespace: stage_upstream_release_pair_parser = subparsers.add_parser( "stage-upstream-release-pair" ) - stage_upstream_release_pair_parser.add_argument( - "--source-root", type=Path, required=True - ) + stage_upstream_release_pair_parser.add_argument("--source-root", type=Path, required=True) stage_upstream_release_pair_parser.add_argument("--target", required=True) stage_upstream_release_pair_parser.add_argument("--output-dir", required=True) stage_upstream_release_pair_parser.add_argument("--sandbox", action="store_true") diff --git a/.github/scripts/test_run_bazel_with_buildbuddy.py b/.github/scripts/test_run_bazel_with_buildbuddy.py deleted file mode 100644 index bab4ad5cce..0000000000 --- a/.github/scripts/test_run_bazel_with_buildbuddy.py +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env python3 - -import json -import unittest -from pathlib import Path -from tempfile import TemporaryDirectory - -import run_bazel_with_buildbuddy - - -class RunBazelWithBuildBuddyTest(unittest.TestCase): - def github_env( - self, - temp_dir: str, - *, - repository: str = "openai/codex", - fork: bool = False, - event_name: str = "pull_request", - ) -> dict[str, str]: - event_path = Path(temp_dir) / "event.json" - event_path.write_text( - json.dumps({"pull_request": {"head": {"repo": {"fork": fork}}}}), - encoding="utf-8", - ) - return { - "BUILDBUDDY_API_KEY": "token", - "GITHUB_ACTIONS": "true", - "GITHUB_EVENT_NAME": event_name, - "GITHUB_EVENT_PATH": str(event_path), - "GITHUB_REPOSITORY": repository, - } - - def test_keyless_invocation_drops_remote_ci_configuration(self) -> None: - self.assertIsNone( - run_bazel_with_buildbuddy.remote_config( - ["build", "--config=ci-linux", "//codex-rs/cli:codex"], - {}, - ) - ) - self.assertEqual( - run_bazel_with_buildbuddy.bazel_args_with_remote_config( - ["build", "--config=ci-linux", "--", "//codex-rs/cli:codex"], - {}, - ), - ["build", "--", "//codex-rs/cli:codex"], - ) - - def test_program_arguments_after_separator_do_not_select_or_lose_rbe(self) -> None: - args = ["run", "//codex-rs/cli:codex", "--", "--config=remote"] - - self.assertEqual( - run_bazel_with_buildbuddy.bazel_args_with_remote_config(args, {}), - args, - ) - self.assertEqual( - run_bazel_with_buildbuddy.remote_config( - args, {"BUILDBUDDY_API_KEY": "fork-token"} - ), - "buildbuddy-generic", - ) - - def test_upstream_push_selects_openai_rbe_before_target_separator(self) -> None: - with TemporaryDirectory() as temp_dir: - env = self.github_env(temp_dir, event_name="push") - - self.assertEqual( - run_bazel_with_buildbuddy.bazel_args_with_remote_config( - ["build", "--config=ci-linux", "--", "//codex-rs/cli:codex"], - env, - ), - [ - "build", - "--config=buildbuddy-openai-rbe", - "--remote_header=x-buildbuddy-api-key=token", - "--config=ci-linux", - "--", - "//codex-rs/cli:codex", - ], - ) - - def test_windows_cross_ci_configuration_follows_remote_configuration(self) -> None: - env = {"BUILDBUDDY_API_KEY": "fork-token"} - - self.assertEqual( - run_bazel_with_buildbuddy.bazel_args_with_remote_config( - ["build", "--config=ci-windows-cross", "//codex-rs/cli:codex"], - env, - ), - [ - "build", - "--config=buildbuddy-generic-rbe", - "--remote_header=x-buildbuddy-api-key=fork-token", - "--config=ci-windows-cross", - "//codex-rs/cli:codex", - ], - ) - - def test_query_remote_configuration_is_inserted_before_expression(self) -> None: - expression = 'kind("rust_library rule", //codex-rs/...)' - env = {"BUILDBUDDY_API_KEY": "fork-token"} - - for command in ("query", "cquery", "aquery"): - with self.subTest(command=command): - self.assertEqual( - run_bazel_with_buildbuddy.bazel_args_with_remote_config( - [ - command, - "--config=ci-windows-cross", - "--output=label", - expression, - ], - env, - ), - [ - command, - "--config=buildbuddy-generic-rbe", - "--remote_header=x-buildbuddy-api-key=fork-token", - "--config=ci-windows-cross", - "--output=label", - expression, - ], - ) - - def test_same_repository_pull_request_selects_openai_host(self) -> None: - with TemporaryDirectory() as temp_dir: - self.assertEqual( - run_bazel_with_buildbuddy.remote_config( - ["build", "--config=ci-v8"], self.github_env(temp_dir) - ), - "buildbuddy-openai-rbe", - ) - - def test_fork_pull_request_cannot_select_openai_host(self) -> None: - with TemporaryDirectory() as temp_dir: - env = self.github_env(temp_dir, fork=True) - - self.assertEqual( - run_bazel_with_buildbuddy.remote_config( - ["build", "--config=ci-v8"], env - ), - "buildbuddy-generic-rbe", - ) - - def test_run_in_fork_repository_cannot_select_openai_host(self) -> None: - with TemporaryDirectory() as temp_dir: - env = self.github_env(temp_dir, repository="contributor/codex") - - self.assertEqual( - run_bazel_with_buildbuddy.remote_config( - ["build", "--config=ci-v8"], env - ), - "buildbuddy-generic-rbe", - ) - - def test_pull_request_without_readable_event_payload_fails_closed(self) -> None: - for event_path in (None, "missing-event.json"): - env = { - "BUILDBUDDY_API_KEY": "token", - "GITHUB_ACTIONS": "true", - "GITHUB_EVENT_NAME": "pull_request", - "GITHUB_REPOSITORY": "openai/codex", - } - if event_path is not None: - env["GITHUB_EVENT_PATH"] = event_path - - with self.subTest(event_path=event_path): - self.assertEqual( - run_bazel_with_buildbuddy.remote_config(["build"], env), - "buildbuddy-generic", - ) - - def test_bazel_command_uses_configured_binary_locally(self) -> None: - self.assertEqual( - run_bazel_with_buildbuddy.bazel_command( - "info", - "execution_root", - env={"CODEX_BAZEL_BIN": "fake-bazel"}, - ), - ["fake-bazel", "info", "execution_root"], - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/.github/scripts/test_rusty_v8_bazel.py b/.github/scripts/test_rusty_v8_bazel.py index 0b5c03f436..19690dbece 100644 --- a/.github/scripts/test_rusty_v8_bazel.py +++ b/.github/scripts/test_rusty_v8_bazel.py @@ -88,49 +88,24 @@ class RustyV8BazelTest(unittest.TestCase): ), ) - def test_bazel_commands_use_shared_buildbuddy_remote_config_library(self) -> None: - with patch.dict(environ, {}, clear=True): + def test_bazel_remote_args_include_buildbuddy_header_when_present(self) -> None: + with patch.dict(environ, {"BUILDBUDDY_API_KEY": "token"}, clear=False): self.assertEqual( - [ - "bazel", - "build", - "//third_party/v8:release", - ], - rusty_v8_bazel.bazel_command( - "build", - "--config=ci-v8", - "//third_party/v8:release", - ), - ) - with patch.dict(environ, {"BUILDBUDDY_API_KEY": "token"}, clear=True): - self.assertEqual( - [ - "bazel", - "build", - "--config=buildbuddy-generic-rbe", - "--remote_header=x-buildbuddy-api-key=token", - "--config=ci-v8", - "//third_party/v8:release", - ], - rusty_v8_bazel.bazel_command( - "build", - "--config=ci-v8", - "//third_party/v8:release", - ), + ["--remote_header=x-buildbuddy-api-key=token"], + rusty_v8_bazel.bazel_remote_args(), ) - def test_release_pair_labels_and_staged_names_distinguish_sandbox_artifacts( - self, - ) -> None: + with patch.dict(environ, {}, clear=True): + self.assertEqual([], rusty_v8_bazel.bazel_remote_args()) + + def test_release_pair_labels_and_staged_names_distinguish_sandbox_artifacts(self) -> None: self.assertEqual( "//third_party/v8:rusty_v8_release_pair_x86_64_unknown_linux_musl", rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl"), ) self.assertEqual( "//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_unknown_linux_musl", - rusty_v8_bazel.release_pair_label( - "x86_64-unknown-linux-musl", sandbox=True - ), + rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl", sandbox=True), ) self.assertEqual( "//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_apple_darwin", @@ -230,7 +205,11 @@ class RustyV8BazelTest(unittest.TestCase): with TemporaryDirectory() as source_dir, TemporaryDirectory() as output_dir: source_root = Path(source_dir) gn_out = ( - source_root / "target" / "x86_64-pc-windows-msvc" / "release" / "gn_out" + source_root + / "target" + / "x86_64-pc-windows-msvc" + / "release" + / "gn_out" ) (gn_out / "obj").mkdir(parents=True) (gn_out / "obj" / "rusty_v8.lib").write_bytes(b"archive") diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index e2782b4904..153ace0fc9 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -15,7 +15,6 @@ concurrency: # See https://docs.github.com/en/actions/using-jobs/using-concurrency and https://docs.github.com/en/actions/learn-github-actions/contexts for more info. group: concurrency-group::${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}${{ github.ref_name == 'main' && format('::{0}', github.run_id) || ''}} cancel-in-progress: ${{ github.ref_name != 'main' }} - jobs: test: # PRs use the sharded Windows cross-compiled test jobs below. Post-merge @@ -56,17 +55,12 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 - if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu' - with: - tool: just - - name: Check rusty_v8 MODULE.bazel checksums if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu' shell: bash run: | python3 .github/scripts/rusty_v8_bazel.py check-module-bazel - just test-github-scripts + python3 -m unittest discover -s .github/scripts -p test_rusty_v8_bazel.py - name: Prepare Bazel CI id: prepare_bazel diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index d6fb73e96d..4b5320dcb9 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -191,10 +191,11 @@ jobs: bazel_args+=(--config=v8-release-compat) fi - ./.github/scripts/run_bazel_with_buildbuddy.py \ + bazel \ --noexperimental_remote_repo_contents_cache \ "${bazel_args[@]}" \ - "--config=${{ matrix.bazel_config }}" + "--config=${{ matrix.bazel_config }}" \ + "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - name: Stage release pair env: diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 0ad9f850d1..71ce5d7865 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -5,7 +5,6 @@ on: paths: - ".bazelrc" - ".github/actions/setup-bazel-ci/**" - - ".github/scripts/run_bazel_with_buildbuddy.py" - ".github/scripts/rusty_v8_bazel.py" - ".github/scripts/rusty_v8_module_bazel.py" - ".github/workflows/rusty-v8-release.yml" @@ -24,7 +23,6 @@ on: paths: - ".bazelrc" - ".github/actions/setup-bazel-ci/**" - - ".github/scripts/run_bazel_with_buildbuddy.py" - ".github/scripts/rusty_v8_bazel.py" - ".github/scripts/rusty_v8_module_bazel.py" - ".github/workflows/rusty-v8-release.yml" @@ -205,10 +203,11 @@ jobs: bazel_args+=(--config=v8-release-compat) fi - ./.github/scripts/run_bazel_with_buildbuddy.py \ + bazel \ --noexperimental_remote_repo_contents_cache \ "${bazel_args[@]}" \ - "--config=${{ matrix.bazel_config }}" + "--config=${{ matrix.bazel_config }}" \ + "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - name: Stage release pair env: diff --git a/codex-rs/docs/bazel.md b/codex-rs/docs/bazel.md index 085c15992f..a124688a24 100644 --- a/codex-rs/docs/bazel.md +++ b/codex-rs/docs/bazel.md @@ -4,7 +4,7 @@ This repository uses Bazel to build the Rust workspace under `codex-rs`. Cargo remains the source of truth for crates and features, while Bazel provides hermetic builds, toolchains, and cross-platform artifacts. -As of 6/1/2026, this setup is still experimental as we stabilize it. +As of 1/9/2026, this setup is still experimental as we stabilize it. ## High-level layout @@ -20,118 +20,6 @@ As of 6/1/2026, this setup is still experimental as we stabilize it. makes some adjustments if the crate needs additional compile-time or runtime data, or other customizations. -## Running Bazel locally - -The repository root `justfile` exposes the common Bazel entry points: - -```bash -just bazel-test -just bazel-clippy -``` - -Ordinary local `bazel` and `just` invocations run locally. BuildBuddy cache, -build event upload, downloads, and remote execution are opt-in configurations. - -## BuildBuddy - -Codex uses BuildBuddy for a shared Bazel cache and remoted builds and tests. To use it -to speed up your builds and tests you'll need to provide an API key and select a -configuration. - -### BuildBuddy API key - -If you're an OpenAI employee, log in to https://openai.buildbuddy.io and use Google sign-in. - -Create a BuildBuddy API key as described in BuildBuddy's [Authentication Guide][bb-auth-guide], -then add it to `~/.bazelrc`: - -```bazelrc -# Local machine only; this file contains a BuildBuddy credential. -common --remote_header=x-buildbuddy-api-key= -``` - -Keeping the credential outside the workspace reduces the risk of accidentally -committing it. - -If you need different API keys for different projects, put the API key in -`%workspace%/user.bazelrc` instead. The checked-in `.bazelrc` optionally imports -that file, and `.gitignore` excludes it. Do not commit or share a file containing -the credential. - -[bb-auth-guide]: https://www.buildbuddy.io/docs/guide-auth/#managing-keys - -### Selecting a remote build configuration - -OpenAI employees should default to the OpenAI host with remote execution unless -they have a reason to choose another configuration. Add the following configuration -to `%workspace%/user.bazelrc`: - -```bazelrc -common --config=buildbuddy-openai-rbe -``` - -OpenAI employees who don't want remote execution can use `buildbuddy-openai`. External users -should use `buildbuddy-generic-rbe` or `buildbuddy-generic`. See below for details on these -configurations. - -### All remote configurations - -GitHub Actions routes Bazel build and output-resolution commands through -`.github/scripts/run_bazel_with_buildbuddy.py`. Higher-level helpers such as -`.github/scripts/run-bazel-ci.sh` and `.github/scripts/rusty_v8_bazel.py` -delegate remote configuration selection to that wrapper. The wrapper reads the -GitHub Actions repository and event payload rather than relying on workflow -files to duplicate tenant-selection logic. - -Loading-phase target-discovery `bazel query` commands run locally because they -only enumerate labels and do not need remote caches or execution. - -The `Cache/BES` host is also used for remote downloads. - -| Invocation/config | Key Required | Cache/BES | Build exec | Test exec | -| --- | --- | --- | --- | --- | -| `bazel ...` | No | None | Local | Local | -| `bazel ... --config=buildbuddy-generic` | Yes | `remote.buildbuddy.io` | Local | Local | -| `bazel ... --config=buildbuddy-generic-rbe` | Yes | `remote.buildbuddy.io` | Remote | Remote | -| `bazel ... --config=buildbuddy-openai` | Yes | `openai.buildbuddy.io` | Local | Local | -| `bazel ... --config=buildbuddy-openai-rbe` | Yes | `openai.buildbuddy.io` | Remote | Remote | - -Without an API key, the wrapper removes remote CI configurations and runs -locally. With a key, workflows choose the host as follows: - -| Run | Key | Uses OpenAI BuildBuddy Host | -| --- | --- | --- | -| Push to `main` in `openai/codex` | Yes | Yes | -| `workflow_dispatch` in `openai/codex` | Yes | Yes | -| Same-repository pull request in `openai/codex` | Yes | Yes | -| Fork pull request into `openai/codex` | No | No; local | -| Push or `workflow_dispatch` in a fork with a key | Yes | No; generic host | -| Pull request run in a fork repository with a key | Yes | No; generic host | - -CI configurations determine whether builds and tests execute remotely: - -| CI config | Remote config | Build exec | Test exec | -| --- | --- | --- | --- | -| `ci-linux` | `*-rbe` | Remote host | Remote host | -| `ci-v8` | `*-rbe` | Remote host | Remote host | -| `ci-macos` | `*-rbe` | Remote host | Local | -| `ci-windows-cross` | `*-rbe` | Remote host | Local | -| `ci-windows` | non-RBE | Local | Local | -| Keyless CI fallback | none | Local | Local | - -To exercise the generic remote configuration with your key: - -```bash -BUILDBUDDY_API_KEY=... GITHUB_REPOSITORY=my-fork/codex \ - ./.github/scripts/run_bazel_with_buildbuddy.py \ - build --config=ci-linux //codex-rs/cli:codex -``` - -The wrapper selects the OpenAI host only inside GitHub Actions for a trusted -run in `openai/codex`. A missing or malformed pull request event -payload fails closed to the generic host. For local OpenAI host access, use -the `user.bazelrc` configuration above. - ## Evolving the setup When you add or change Rust dependencies, update the Cargo.toml/Cargo.lock as normal. diff --git a/justfile b/justfile index fe7e7349b3..34b5115ee0 100644 --- a/justfile +++ b/justfile @@ -83,12 +83,6 @@ test *args: $env:RUST_MIN_STACK = "{{ rust_min_stack }}"; cargo nextest run --no-fail-fast @($args | Select-Object -Skip 1) just bench-smoke -# Run from the repository root so scripts that resolve paths from `cwd` see -# the same layout they use in GitHub Actions. -[no-cd] -test-github-scripts: - {{ python }} -m unittest discover -s {{ justfile_directory() }}/.github/scripts -p 'test_*.py' - # Run explicit workspace benchmark targets. bench *args: cargo bench --workspace --bench '*' {args} @@ -135,8 +129,11 @@ bazel-clippy: bazel-argument-comment-lint: bazel build --config=argument-comment-lint -- $({{ justfile_directory() }}/tools/argument-comment-lint/list-bazel-targets.sh) +bazel-remote-test: + bazel test --test_tag_filters=-argument-comment-lint //... --config=remote --platforms=//:rbe --keep_going + build-for-release: - bazel build //codex-rs/cli:release_binaries + bazel build //codex-rs/cli:release_binaries --config=remote # Run the MCP server mcp-server-run *args: diff --git a/scripts/list-bazel-clippy-targets.sh b/scripts/list-bazel-clippy-targets.sh index d12a256d00..b76fc2a830 100755 --- a/scripts/list-bazel-clippy-targets.sh +++ b/scripts/list-bazel-clippy-targets.sh @@ -20,13 +20,23 @@ while [[ $# -gt 0 ]]; do done # Resolve the dynamic targets before printing anything so callers do not -# continue with a partial list if `bazel query` fails. Target discovery is -# local on all platforms. -manual_rust_test_targets="$( - ./.github/scripts/run-bazel-query-ci.sh \ - --output=label \ - -- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))' -)" +# continue with a partial list if `bazel query` fails. Reuse the same CI Bazel +# server settings as the subsequent build so Windows jobs do not cold-start a +# second Bazel server just for target discovery. +if [[ $windows_cross_compile -eq 1 ]]; then + manual_rust_test_targets="$( + ./.github/scripts/run-bazel-query-ci.sh \ + --windows-cross-compile \ + --output=label \ + -- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))' + )" +else + manual_rust_test_targets="$( + ./.github/scripts/run-bazel-query-ci.sh \ + --output=label \ + -- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))' + )" +fi if [[ "${RUNNER_OS:-}" != "Windows" ]]; then # Non-Windows clippy jobs lint the native test binaries; the # Windows-cross binaries exist only for the fast Windows test leg. From 503ec190a88da1dfa43294a98ef5530769095778 Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 2 Jun 2026 20:16:57 +0200 Subject: [PATCH 26/59] Key request-permission grants by environment (#25850) ## Stack 1. This PR (#25850) - Key request-permission grants by environment: stores and applies sticky permission grants per environment id. 2. #25858 - Add `environmentId` to `request_permissions`: lets the model target a selected environment and resolves relative permission paths against it. 3. #25862 - Propagate permission approval environment id: carries the selected environment id through approval events, app-server requests, TUI prompts, and delegate forwarding. 4. #25867 - Add remote request permissions integration coverage: verifies the selected remote environment across request, approval, grant reuse, and exec. #25858, #25862, and #25867 are stacked on this PR and should be reviewed after it. ## Why Multi-environment CCA turns can attach both local and remote executors, but request-permission grants were still effectively cwd-only. Pending permission requests tracked a cwd, while stored turn/session grants had no environment identity, so sticky grants could be reused through the wrong executor context. This makes the first permission-grant step environment-aware without changing the external `request_permissions` payload shape: omitted environment targeting remains bound to the primary turn environment. ## What Changed - Store turn- and session-scoped request-permission grants by `environment_id`. - Keep the selected `TurnEnvironmentSelection` with pending `request_permissions` calls so approval responses normalize and record grants against the same environment. - Resolve relative `request_permissions` file paths against the primary turn environment cwd instead of deprecated `turn.cwd`. - Apply sticky grants in `shell`, `exec_command`, and `apply_patch` by selected environment id while still using the actual tool cwd for cwd-relative permission materialization. - Update Guardian and request-permissions coverage for the environment-keyed grant behavior. ## Testing Not run locally. Added or updated focused coverage for: - `request_permission_grants_are_environment_keyed` - `request_permissions_tool_resolves_relative_paths_against_primary_environment` - related Guardian/request-permissions sticky grant tests --- codex-rs/core/src/session/mod.rs | 75 +++++-- codex-rs/core/src/session/tests.rs | 195 +++++++++++++++++- .../core/src/session/tests/guardian_tests.rs | 27 ++- codex-rs/core/src/state/session.rs | 31 ++- codex-rs/core/src/state/turn.rs | 34 ++- .../core/src/tools/handlers/apply_patch.rs | 31 ++- codex-rs/core/src/tools/handlers/mod.rs | 7 +- .../src/tools/handlers/request_permissions.rs | 8 +- codex-rs/core/src/tools/handlers/shell.rs | 4 +- .../handlers/unified_exec/exec_command.rs | 1 + 10 files changed, 356 insertions(+), 57 deletions(-) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index b3dcf504ee..d8f660e2a1 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2107,12 +2107,18 @@ impl Session { args: RequestPermissionsArgs, cancellation_token: CancellationToken, ) -> Option { - self.request_permissions_for_cwd( + let Some(turn_environment) = turn_context.environments.primary() else { + return Some(RequestPermissionsResponse { + permissions: RequestPermissionProfile::default(), + scope: PermissionGrantScope::Turn, + strict_auto_review: false, + }); + }; + self.request_permissions_for_environment( turn_context, call_id, args, - #[allow(deprecated)] - turn_context.cwd.clone(), + turn_environment.selection(), cancellation_token, ) .await @@ -2122,12 +2128,12 @@ impl Session { clippy::await_holding_invalid_type, reason = "active turn checks and turn state updates must remain atomic" )] - pub(crate) async fn request_permissions_for_cwd( + pub(crate) async fn request_permissions_for_environment( self: &Arc, turn_context: &Arc, call_id: String, args: RequestPermissionsArgs, - cwd: AbsolutePathBuf, + environment: TurnEnvironmentSelection, cancellation_token: CancellationToken, ) -> Option { match turn_context.as_ref().approval_policy.value() { @@ -2221,10 +2227,11 @@ impl Session { let response = Self::normalize_request_permissions_response( requested_permissions, response, - cwd.as_path(), + environment.cwd.as_path(), ); self.record_granted_request_permissions_for_turn( &response, + &environment.environment_id, originating_turn_state.as_ref(), ) .await; @@ -2242,7 +2249,7 @@ impl Session { PendingRequestPermissions { tx_response, requested_permissions: requested_permissions.clone(), - cwd: cwd.clone(), + environment: environment.clone(), }, ) } @@ -2259,7 +2266,7 @@ impl Session { started_at_ms: now_unix_timestamp_ms(), reason: args.reason, permissions: requested_permissions, - cwd: Some(cwd), + cwd: Some(environment.cwd), }); self.send_event(turn_context.as_ref(), event).await; tokio::select! { @@ -2276,6 +2283,33 @@ impl Session { } } + pub(crate) async fn request_permissions_for_cwd( + self: &Arc, + turn_context: &Arc, + call_id: String, + args: RequestPermissionsArgs, + cwd: AbsolutePathBuf, + cancellation_token: CancellationToken, + ) -> Option { + let Some(primary_environment) = turn_context.environments.primary() else { + return Some(RequestPermissionsResponse { + permissions: RequestPermissionProfile::default(), + scope: PermissionGrantScope::Turn, + strict_auto_review: false, + }); + }; + let mut environment = primary_environment.selection(); + environment.cwd = cwd; + self.request_permissions_for_environment( + turn_context, + call_id, + args, + environment, + cancellation_token, + ) + .await + } + #[expect( clippy::await_holding_invalid_type, reason = "active turn checks and turn state updates must remain atomic" @@ -2370,10 +2404,11 @@ impl Session { let response = Self::normalize_request_permissions_response( entry.requested_permissions, response, - entry.cwd.as_path(), + entry.environment.cwd.as_path(), ); self.record_granted_request_permissions_for_turn( &response, + &entry.environment.environment_id, originating_turn_state.as_ref(), ) .await; @@ -2417,6 +2452,7 @@ impl Session { async fn record_granted_request_permissions_for_turn( &self, response: &RequestPermissionsResponse, + environment_id: &str, originating_turn_state: Option<&Arc>>, ) { if response.permissions.is_empty() { @@ -2428,7 +2464,7 @@ impl Session { let mut ts = turn_state.lock().await; let permissions: AdditionalPermissionProfile = response.permissions.clone().into(); - ts.record_granted_permissions(permissions); + ts.record_granted_permissions(environment_id, permissions); if response.strict_auto_review { ts.enable_strict_auto_review(); } @@ -2436,7 +2472,10 @@ impl Session { } PermissionGrantScope::Session => { let mut state = self.state.lock().await; - state.record_granted_permissions(response.permissions.clone().into()); + state.record_granted_permissions( + environment_id, + response.permissions.clone().into(), + ); } } } @@ -2445,11 +2484,14 @@ impl Session { clippy::await_holding_invalid_type, reason = "active turn reads must stay consistent with the matching turn state" )] - pub(crate) async fn granted_turn_permissions(&self) -> Option { + pub(crate) async fn granted_turn_permissions( + &self, + environment_id: &str, + ) -> Option { let active = self.active_turn.lock().await; let active = active.as_ref()?; let ts = active.turn_state.lock().await; - ts.granted_permissions() + ts.granted_permissions(environment_id) } #[expect( @@ -2465,9 +2507,12 @@ impl Session { ts.strict_auto_review_enabled() } - pub(crate) async fn granted_session_permissions(&self) -> Option { + pub(crate) async fn granted_session_permissions( + &self, + environment_id: &str, + ) -> Option { let state = self.state.lock().await; - state.granted_permissions() + state.granted_permissions(environment_id) } #[expect( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index d4b4942a88..260e91c092 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -73,6 +73,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::CreateGoalHandler; use crate::tools::handlers::ExecCommandHandler; +use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::UpdateGoalHandler; use crate::tools::registry::ToolExecutor; @@ -5155,7 +5156,12 @@ async fn notify_request_permissions_response_ignores_unmatched_call_id() { ) .await; - assert_eq!(session.granted_turn_permissions().await, None); + assert_eq!( + session + .granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) + .await, + None + ); } #[tokio::test] @@ -5182,16 +5188,84 @@ async fn record_granted_request_permissions_for_turn_uses_originating_turn() { scope: PermissionGrantScope::Turn, strict_auto_review: false, }, + codex_exec_server::LOCAL_ENVIRONMENT_ID, Some(&originating_turn_state), ) .await; assert_eq!( - originating_turn_state.lock().await.granted_permissions(), + originating_turn_state + .lock() + .await + .granted_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID), Some(requested_permissions.into()) ); - assert_eq!(current_turn_state.lock().await.granted_permissions(), None); - assert_eq!(session.granted_turn_permissions().await, None); + assert_eq!( + current_turn_state + .lock() + .await + .granted_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID), + None + ); + assert_eq!( + session + .granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) + .await, + None + ); +} + +#[tokio::test] +async fn request_permission_grants_are_environment_keyed() { + let (session, _turn_context) = make_session_and_context().await; + let originating_active_turn = ActiveTurn::default(); + let originating_turn_state = Arc::clone(&originating_active_turn.turn_state); + *session.active_turn.lock().await = Some(originating_active_turn); + + let requested_permissions = RequestPermissionProfile { + network: Some(codex_protocol::models::NetworkPermissions { + enabled: Some(true), + }), + ..RequestPermissionProfile::default() + }; + session + .record_granted_request_permissions_for_turn( + &codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: requested_permissions.clone(), + scope: PermissionGrantScope::Turn, + strict_auto_review: false, + }, + "remote", + Some(&originating_turn_state), + ) + .await; + + { + let turn_state = originating_turn_state.lock().await; + assert_eq!( + turn_state.granted_permissions("remote"), + Some(requested_permissions.clone().into()) + ); + assert_eq!(turn_state.granted_permissions("local"), None); + } + + session + .record_granted_request_permissions_for_turn( + &codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: requested_permissions.clone(), + scope: PermissionGrantScope::Session, + strict_auto_review: false, + }, + "remote", + /*originating_turn_state*/ None, + ) + .await; + + assert_eq!( + session.granted_session_permissions("remote").await, + Some(requested_permissions.into()) + ); + assert_eq!(session.granted_session_permissions("local").await, None); } #[tokio::test] @@ -5214,6 +5288,7 @@ async fn enable_strict_auto_review_for_turn_uses_originating_turn() { scope: PermissionGrantScope::Turn, strict_auto_review: true, }, + codex_exec_server::LOCAL_ENVIRONMENT_ID, Some(&originating_turn_state), ) .await; @@ -5333,6 +5408,107 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() assert_eq!(response, Some(expected_response)); } +#[tokio::test] +async fn request_permissions_tool_resolves_relative_paths_against_primary_environment() { + let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + let environment_cwd = { + #[allow(deprecated)] + let legacy_cwd = turn_context.cwd.clone(); + legacy_cwd.join("request-permissions-environment") + }; + std::fs::create_dir_all(environment_cwd.as_path()).expect("create environment cwd"); + let turn_context_mut = Arc::get_mut(&mut turn_context).expect("single thread settings ref"); + turn_context_mut + .approval_policy + .set(AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: true, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: true, + })) + .expect("test setup should allow updating approval policy"); + turn_context_mut.environments.turn_environments[0].cwd = environment_cwd.clone(); + + let call_id = "call-1".to_string(); + let handler = RequestPermissionsHandler; + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let handle = tokio::spawn({ + let session = Arc::clone(&session); + let turn_context = Arc::clone(&turn_context); + let tracker = Arc::clone(&tracker); + let call_id = call_id.clone(); + async move { + handler + .handle(ToolInvocation { + session, + turn: turn_context, + cancellation_token: CancellationToken::new(), + tracker, + call_id, + tool_name: codex_tools::ToolName::plain("request_permissions"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: json!({ + "reason": "need write", + "permissions": { + "file_system": { + "entries": [{ + "path": { + "type": "path", + "path": "relative.txt", + }, + "access": "write", + }], + }, + }, + }) + .to_string(), + }, + }) + .await + } + }); + + let request_event = tokio::time::timeout(StdDuration::from_secs(1), rx.recv()) + .await + .expect("request_permissions event timed out") + .expect("request_permissions event missing"); + let EventMsg::RequestPermissions(request) = request_event.msg else { + panic!("expected request_permissions event"); + }; + let expected_permissions = RequestPermissionProfile { + file_system: Some(FileSystemPermissions { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: environment_cwd.join("relative.txt"), + }, + access: FileSystemAccessMode::Write, + }], + glob_scan_max_depth: None, + }), + ..Default::default() + }; + assert_eq!(request.permissions, expected_permissions); + + session + .notify_request_permissions_response( + &request.call_id, + codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: request.permissions, + scope: PermissionGrantScope::Turn, + strict_auto_review: false, + }, + ) + .await; + tokio::time::timeout(StdDuration::from_secs(1), handle) + .await + .expect("request_permissions handler timed out") + .expect("request_permissions handler join error") + .expect("request_permissions handler should succeed"); +} + #[tokio::test] async fn request_permissions_response_materializes_session_cwd_grants_before_recording() { let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; @@ -5425,7 +5601,9 @@ async fn request_permissions_response_materializes_session_cwd_grants_before_rec assert_eq!(response, Some(expected_response)); assert_eq!( - session.granted_session_permissions().await, + session + .granted_session_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) + .await, Some(expected_permissions.into()) ); } @@ -10450,7 +10628,12 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { ); pretty_assertions::assert_eq!(output, expected); - pretty_assertions::assert_eq!(session.granted_turn_permissions().await, None); + pretty_assertions::assert_eq!( + session + .granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) + .await, + None + ); // The rejection should not poison the non-escalated path for the same // command. Force DangerFullAccess so this check stays focused on approval diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 2977e6bdf5..e8fccd7d72 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -148,7 +148,9 @@ async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() { }) ); assert_eq!( - session.granted_turn_permissions().await, + session + .granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) + .await, Some(requested_permissions.into()) ); @@ -246,7 +248,12 @@ async fn request_permissions_guardian_review_stops_when_cancelled() { .expect("request_permissions should stop when cancelled") .expect("request_permissions task should not panic"); assert_eq!(response, None); - assert_eq!(session.granted_turn_permissions().await, None); + assert_eq!( + session + .granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) + .await, + None + ); } #[tokio::test] @@ -380,6 +387,7 @@ async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_ scope: PermissionGrantScope::Turn, strict_auto_review: true, }, + codex_exec_server::LOCAL_ENVIRONMENT_ID, Some(&originating_turn_state), ) .await; @@ -564,12 +572,15 @@ async fn shell_command_allows_sticky_turn_permissions_without_inline_request_per let mut active_turn = session.active_turn.lock().await; let active_turn = active_turn.as_mut().expect("active turn"); let mut turn_state = active_turn.turn_state.lock().await; - turn_state.record_granted_permissions(PermissionProfile { - network: Some(NetworkPermissions { - enabled: Some(true), - }), - ..Default::default() - }); + turn_state.record_granted_permissions( + codex_exec_server::LOCAL_ENVIRONMENT_ID, + PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + ..Default::default() + }, + ); } let session = Arc::new(session); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 2663943f82..024f1904cc 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -3,6 +3,7 @@ use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::ResponseItem; use codex_sandboxing::policy_transforms::merge_permission_profiles; +use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; @@ -37,7 +38,7 @@ pub(crate) struct SessionState { pub(crate) startup_prewarm: Option, pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_sources: VecDeque, - granted_permissions: Option, + granted_permissions_by_environment_id: HashMap, next_turn_is_first: bool, } @@ -57,7 +58,7 @@ impl SessionState { startup_prewarm: None, active_connector_selection: HashSet::new(), pending_session_start_sources: VecDeque::new(), - granted_permissions: None, + granted_permissions_by_environment_id: HashMap::new(), next_turn_is_first: true, } } @@ -235,13 +236,29 @@ impl SessionState { self.pending_session_start_sources.pop_front() } - pub(crate) fn record_granted_permissions(&mut self, permissions: AdditionalPermissionProfile) { - self.granted_permissions = - merge_permission_profiles(self.granted_permissions.as_ref(), Some(&permissions)); + pub(crate) fn record_granted_permissions( + &mut self, + environment_id: &str, + permissions: AdditionalPermissionProfile, + ) { + let granted_permissions = merge_permission_profiles( + self.granted_permissions_by_environment_id + .get(environment_id), + Some(&permissions), + ); + if let Some(granted_permissions) = granted_permissions { + self.granted_permissions_by_environment_id + .insert(environment_id.to_string(), granted_permissions); + } } - pub(crate) fn granted_permissions(&self) -> Option { - self.granted_permissions.clone() + pub(crate) fn granted_permissions( + &self, + environment_id: &str, + ) -> Option { + self.granted_permissions_by_environment_id + .get(environment_id) + .cloned() } } diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index fe09235197..eaf26ba853 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -1,6 +1,5 @@ //! Turn-scoped state and active turn metadata scaffolding. -use codex_sandboxing::policy_transforms::merge_permission_profiles; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; @@ -10,11 +9,12 @@ use tokio_util::task::AbortOnDropHandle; use codex_extension_api::ExtensionData; use codex_protocol::dynamic_tools::DynamicToolResponse; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationResponse; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_sandboxing::policy_transforms::merge_permission_profiles; use rmcp::model::RequestId; use tokio::sync::oneshot; @@ -90,7 +90,7 @@ pub(crate) struct TurnState { pending_dynamic_tools: HashMap>, pub(crate) pending_input: TurnInputQueue, mailbox_delivery_phase: MailboxDeliveryPhase, - granted_permissions: Option, + granted_permissions_by_environment_id: HashMap, strict_auto_review_enabled: bool, pub(crate) tool_calls: u64, pub(crate) has_memory_citation: bool, @@ -100,7 +100,7 @@ pub(crate) struct TurnState { pub(crate) struct PendingRequestPermissions { pub(crate) tx_response: oneshot::Sender, pub(crate) requested_permissions: RequestPermissionProfile, - pub(crate) cwd: AbsolutePathBuf, + pub(crate) environment: TurnEnvironmentSelection, } impl TurnState { @@ -204,13 +204,29 @@ impl TurnState { self.mailbox_delivery_phase = phase; } - pub(crate) fn record_granted_permissions(&mut self, permissions: AdditionalPermissionProfile) { - self.granted_permissions = - merge_permission_profiles(self.granted_permissions.as_ref(), Some(&permissions)); + pub(crate) fn record_granted_permissions( + &mut self, + environment_id: &str, + permissions: AdditionalPermissionProfile, + ) { + let granted_permissions = merge_permission_profiles( + self.granted_permissions_by_environment_id + .get(environment_id), + Some(&permissions), + ); + if let Some(granted_permissions) = granted_permissions { + self.granted_permissions_by_environment_id + .insert(environment_id.to_string(), granted_permissions); + } } - pub(crate) fn granted_permissions(&self) -> Option { - self.granted_permissions.clone() + pub(crate) fn granted_permissions( + &self, + environment_id: &str, + ) -> Option { + self.granted_permissions_by_environment_id + .get(environment_id) + .cloned() } pub(crate) fn enable_strict_auto_review(&mut self) { diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index bfc3ab94b3..c12ba0517e 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -265,6 +265,7 @@ fn apply_patch_payload_command(payload: &ToolPayload) -> Option { async fn effective_patch_permissions( session: &Session, turn: &TurnContext, + environment_id: &str, action: &ApplyPatchAction, cwd: &AbsolutePathBuf, ) -> ( @@ -274,8 +275,14 @@ async fn effective_patch_permissions( ) { let file_paths = file_paths_for_action(action); let granted_permissions = merge_permission_profiles( - session.granted_session_permissions().await.as_ref(), - session.granted_turn_permissions().await.as_ref(), + session + .granted_session_permissions(environment_id) + .await + .as_ref(), + session + .granted_turn_permissions(environment_id) + .await + .as_ref(), ); let base_file_system_sandbox_policy = turn.file_system_sandbox_policy(); let file_system_sandbox_policy = effective_file_system_sandbox_policy( @@ -284,6 +291,7 @@ async fn effective_patch_permissions( ); let effective_additional_permissions = apply_granted_turn_permissions( session, + environment_id, cwd.as_path(), crate::sandboxing::SandboxPermissions::UseDefault, write_permissions_for_paths(&file_paths, &file_system_sandbox_policy, cwd), @@ -353,8 +361,14 @@ impl ToolExecutor for ApplyPatchHandler { { codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => { let (file_paths, effective_additional_permissions, file_system_sandbox_policy) = - effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes, &cwd) - .await; + effective_patch_permissions( + session.as_ref(), + turn.as_ref(), + &turn_environment.environment_id, + &changes, + &cwd, + ) + .await; match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes) .await { @@ -506,7 +520,14 @@ pub(crate) async fn intercept_apply_patch( { codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => { let (approval_keys, effective_additional_permissions, file_system_sandbox_policy) = - effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes, cwd).await; + effective_patch_permissions( + session.as_ref(), + turn.as_ref(), + &turn_environment.environment_id, + &changes, + cwd, + ) + .await; match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes) .await { diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index b2cbe62907..d6963f0761 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -250,7 +250,8 @@ pub(super) fn implicit_granted_permissions( pub(super) async fn apply_granted_turn_permissions( session: &Session, - cwd: &std::path::Path, + environment_id: &str, + cwd: &Path, sandbox_permissions: SandboxPermissions, additional_permissions: Option, ) -> EffectiveAdditionalPermissions { @@ -262,8 +263,8 @@ pub(super) async fn apply_granted_turn_permissions( }; } - let granted_session_permissions = session.granted_session_permissions().await; - let granted_turn_permissions = session.granted_turn_permissions().await; + let granted_session_permissions = session.granted_session_permissions(environment_id).await; + let granted_turn_permissions = session.granted_turn_permissions(environment_id).await; let granted_permissions = merge_permission_profiles( granted_session_permissions.as_ref(), granted_turn_permissions.as_ref(), diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 70a31fe0e0..f4a89fe0ca 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -48,9 +48,13 @@ impl ToolExecutor for RequestPermissionsHandler { } }; - #[allow(deprecated)] + let Some(turn_environment) = turn.environments.primary() else { + return Err(FunctionCallError::RespondToModel( + "request_permissions requires a primary environment".to_string(), + )); + }; let mut args: RequestPermissionsArgs = - parse_arguments_with_base_path(&arguments, &turn.cwd)?; + parse_arguments_with_base_path(&arguments, &turn_environment.cwd)?; args.permissions = normalize_additional_permissions(args.permissions.into()) .map(codex_protocol::request_permissions::RequestPermissionProfile::from) .map_err(FunctionCallError::RespondToModel)?; diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index c890e0d8da..504653cf76 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -84,10 +84,10 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result for ExecCommandHandler { let requested_additional_permissions = additional_permissions.clone(); let effective_additional_permissions = apply_granted_turn_permissions( context.session.as_ref(), + &turn_environment.environment_id, cwd.as_path(), sandbox_permissions, additional_permissions, From 8e4b92d294c087423215a83b0e9c8282eedc9d12 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 2 Jun 2026 11:40:35 -0700 Subject: [PATCH 27/59] [codex-analytics] Track CodexErr details in turn analytics (#25707) ## Summary - add analytics-only `CodexErr` telemetry to `codex_turn_event` while leaving existing `turn_error` unchanged - record terminal `CodexErr` facts from core immediately before the existing turn error event is sent - emit source-truth `codex_error_*` fields for downstream analytics, including the raw `CodexErr::InvalidRequest(String)` message as `codex_error_subreason` ## Validation - `just test -p codex-analytics` - attempted `just test -p codex-core`, but the local run timed out across unrelated integration suites in this environment and is not being used as validation --- .../analytics/src/analytics_client_tests.rs | 33 ++++ codex-rs/analytics/src/client.rs | 7 + codex-rs/analytics/src/events.rs | 4 + codex-rs/analytics/src/facts.rs | 146 ++++++++++++++++++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 39 +++++ codex-rs/core/src/compact.rs | 2 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/compact_remote_v2.rs | 1 + codex-rs/core/src/session/mod.rs | 12 ++ codex-rs/core/src/session/turn.rs | 4 +- 11 files changed, 249 insertions(+), 1 deletion(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index b3f6ba22ce..61ab5f6b23 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -62,6 +62,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnCodexErrorFact; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerRequestError; @@ -132,6 +133,7 @@ use codex_plugin::PluginTelemetryMetadata; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; +use codex_protocol::error::CodexErr; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -3256,6 +3258,9 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, + codex_error_kind: None, + codex_error_subreason: None, + codex_error_http_status_code: None, steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, @@ -3318,6 +3323,9 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, + "codex_error_kind": null, + "codex_error_subreason": null, + "codex_error_http_status_code": null, "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, @@ -3983,6 +3991,18 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_token_usage*/ false, ) .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnCodexError(Box::new( + TurnCodexErrorFact::from_codex_err( + "thread-2".to_string(), + "turn-2".to_string(), + &CodexErr::InvalidRequest("unknown turn environment id `env-2`".to_string()), + ), + ))), + &mut out, + ) + .await; reducer .ingest( AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( @@ -3999,6 +4019,18 @@ async fn turn_lifecycle_emits_failed_turn_event() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["status"], json!("failed")); assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); + assert_eq!( + payload["event_params"]["codex_error_kind"], + json!("invalid_request") + ); + assert_eq!( + payload["event_params"]["codex_error_subreason"], + json!("unknown turn environment id `env-2`") + ); + assert_eq!( + payload["event_params"]["codex_error_http_status_code"], + json!(null) + ); } #[tokio::test] @@ -4031,6 +4063,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["status"], json!("interrupted")); assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["codex_error_kind"], json!(null)); } #[tokio::test] diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index fbcfa32dc5..bd0726b28e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -18,6 +18,7 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnCodexErrorFact; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; @@ -256,6 +257,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_codex_error(&self, fact: TurnCodexErrorFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnCodexError( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index fd52fefc1e..d2e7d80545 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use std::time::Instant; use crate::facts::AcceptedLineFingerprint; use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexErrKind; use crate::facts::CompactionImplementation; use crate::facts::CompactionPhase; use crate::facts::CompactionReason; @@ -797,6 +798,9 @@ pub(crate) struct CodexTurnEventParams { pub(crate) is_first_turn: bool, pub(crate) status: Option, pub(crate) turn_error: Option, + pub(crate) codex_error_kind: Option, + pub(crate) codex_error_subreason: Option, + pub(crate) codex_error_http_status_code: Option, pub(crate) steer_count: Option, pub(crate) total_tool_call_count: Option, pub(crate) shell_command_count: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 9a85276712..4d298ffdbf 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -15,6 +15,7 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; +use codex_protocol::error::CodexErr; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; @@ -29,6 +30,9 @@ use codex_protocol::request_permissions::RequestPermissionsResponse; use serde::Serialize; use std::path::PathBuf; +const INVALID_REQUEST_SUBREASON_MAX_BYTES: usize = 512; +const INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX: &str = "..."; + #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct AcceptedLineFingerprint { pub path_hash: String, @@ -99,6 +103,147 @@ pub struct TurnTokenUsageFact { pub token_usage: TokenUsage, } +#[derive(Clone)] +pub struct TurnCodexErrorFact { + pub(crate) turn_id: String, + pub(crate) thread_id: String, + pub(crate) error: TurnCodexError, +} + +impl TurnCodexErrorFact { + pub fn from_codex_err(thread_id: String, turn_id: String, error: &CodexErr) -> Self { + Self { + turn_id, + thread_id, + error: TurnCodexError::from_codex_err(error), + } + } +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CodexErrKind { + TurnAborted, + Stream, + ContextWindowExceeded, + ThreadNotFound, + AgentLimitReached, + SessionConfiguredNotFirstEvent, + Timeout, + RequestTimeout, + Spawn, + Interrupted, + UnexpectedStatus, + InvalidRequest, + InvalidImageRequest, + UsageLimitReached, + ServerOverloaded, + CyberPolicy, + ResponseStreamFailed, + ConnectionFailed, + QuotaExceeded, + UsageNotIncluded, + InternalServerError, + RetryLimit, + InternalAgentDied, + Sandbox, + LandlockSandboxExecutableNotProvided, + UnsupportedOperation, + RefreshTokenFailed, + Fatal, + Io, + Json, + #[cfg(target_os = "linux")] + LandlockRuleset, + #[cfg(target_os = "linux")] + LandlockPathFd, + TokioJoin, + EnvVar, +} + +#[derive(Clone)] +pub(crate) struct TurnCodexError { + pub(crate) kind: CodexErrKind, + pub(crate) subreason: Option, + pub(crate) http_status_code: Option, +} + +impl TurnCodexError { + fn from_codex_err(error: &CodexErr) -> Self { + Self { + kind: error.into(), + subreason: match error { + CodexErr::InvalidRequest(message) => { + // InvalidRequest can contain raw provider response bodies, so bound the + // analytics copy without changing the source CodexErr. + let subreason = if message.len() <= INVALID_REQUEST_SUBREASON_MAX_BYTES { + message.clone() + } else { + let truncated_len = message.floor_char_boundary( + INVALID_REQUEST_SUBREASON_MAX_BYTES + .saturating_sub(INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX.len()), + ); + format!( + "{}{INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX}", + &message[..truncated_len] + ) + }; + Some(subreason) + } + _ => None, + }, + http_status_code: error.http_status_code_value(), + } + } +} + +impl From<&CodexErr> for CodexErrKind { + fn from(error: &CodexErr) -> Self { + match error { + CodexErr::TurnAborted => CodexErrKind::TurnAborted, + CodexErr::Stream(..) => CodexErrKind::Stream, + CodexErr::ContextWindowExceeded => CodexErrKind::ContextWindowExceeded, + CodexErr::ThreadNotFound(_) => CodexErrKind::ThreadNotFound, + CodexErr::AgentLimitReached { .. } => CodexErrKind::AgentLimitReached, + CodexErr::SessionConfiguredNotFirstEvent => { + CodexErrKind::SessionConfiguredNotFirstEvent + } + CodexErr::Timeout => CodexErrKind::Timeout, + CodexErr::RequestTimeout => CodexErrKind::RequestTimeout, + CodexErr::Spawn => CodexErrKind::Spawn, + CodexErr::Interrupted => CodexErrKind::Interrupted, + CodexErr::UnexpectedStatus(_) => CodexErrKind::UnexpectedStatus, + CodexErr::InvalidRequest(_) => CodexErrKind::InvalidRequest, + CodexErr::InvalidImageRequest() => CodexErrKind::InvalidImageRequest, + CodexErr::UsageLimitReached(_) => CodexErrKind::UsageLimitReached, + CodexErr::ServerOverloaded => CodexErrKind::ServerOverloaded, + CodexErr::CyberPolicy { .. } => CodexErrKind::CyberPolicy, + CodexErr::ResponseStreamFailed(_) => CodexErrKind::ResponseStreamFailed, + CodexErr::ConnectionFailed(_) => CodexErrKind::ConnectionFailed, + CodexErr::QuotaExceeded => CodexErrKind::QuotaExceeded, + CodexErr::UsageNotIncluded => CodexErrKind::UsageNotIncluded, + CodexErr::InternalServerError => CodexErrKind::InternalServerError, + CodexErr::RetryLimit(_) => CodexErrKind::RetryLimit, + CodexErr::InternalAgentDied => CodexErrKind::InternalAgentDied, + CodexErr::Sandbox(_) => CodexErrKind::Sandbox, + CodexErr::LandlockSandboxExecutableNotProvided => { + CodexErrKind::LandlockSandboxExecutableNotProvided + } + CodexErr::UnsupportedOperation(_) => CodexErrKind::UnsupportedOperation, + CodexErr::RefreshTokenFailed(_) => CodexErrKind::RefreshTokenFailed, + CodexErr::Fatal(_) => CodexErrKind::Fatal, + CodexErr::Io(_) => CodexErrKind::Io, + CodexErr::Json(_) => CodexErrKind::Json, + #[cfg(target_os = "linux")] + CodexErr::LandlockRuleset(_) => CodexErrKind::LandlockRuleset, + #[cfg(target_os = "linux")] + CodexErr::LandlockPathFd(_) => CodexErrKind::LandlockPathFd, + CodexErr::TokioJoin(_) => CodexErrKind::TokioJoin, + CodexErr::EnvVar(_) => CodexErrKind::EnvVar, + } + } +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -329,6 +474,7 @@ pub(crate) enum CustomAnalyticsFact { GuardianReview(Box), TurnResolvedConfig(Box), TurnTokenUsage(Box), + TurnCodexError(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index a33ca7b9e3..c227f4daf1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -38,6 +38,7 @@ pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; +pub use facts::TurnCodexErrorFact; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; pub use facts::TurnSteerRejectionReason; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index f206393476..46f69c59f1 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -70,6 +70,8 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TurnCodexError; +use crate::facts::TurnCodexErrorFact; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -322,6 +324,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + codex_error: Option, latest_diff: Option, steer_count: usize, tool_counts: TurnToolCounts, @@ -461,6 +464,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnTokenUsage(input) => { self.ingest_turn_token_usage(*input, out).await; } + CustomAnalyticsFact::TurnCodexError(input) => { + self.ingest_turn_codex_error(*input); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -606,6 +612,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + codex_error: None, latest_diff: None, steer_count: 0, tool_counts: TurnToolCounts::default(), @@ -630,6 +637,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + codex_error: None, latest_diff: None, steer_count: 0, tool_counts: TurnToolCounts::default(), @@ -639,6 +647,29 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out).await; } + fn ingest_turn_codex_error(&mut self, input: TurnCodexErrorFact) { + let TurnCodexErrorFact { + turn_id, + thread_id, + error, + } = input; + let turn_state = self.turns.entry(turn_id).or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + token_usage: None, + completed: None, + codex_error: None, + latest_diff: None, + steer_count: 0, + tool_counts: TurnToolCounts::default(), + }); + turn_state.thread_id.get_or_insert(thread_id); + turn_state.codex_error = Some(error); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -795,6 +826,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + codex_error: None, latest_diff: None, steer_count: 0, tool_counts: TurnToolCounts::default(), @@ -1154,6 +1186,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + codex_error: None, latest_diff: None, steer_count: 0, tool_counts: TurnToolCounts::default(), @@ -1175,6 +1208,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + codex_error: None, latest_diff: None, steer_count: 0, tool_counts: TurnToolCounts::default(), @@ -1194,6 +1228,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + codex_error: None, latest_diff: None, steer_count: 0, tool_counts: TurnToolCounts::default(), @@ -2451,6 +2486,7 @@ fn codex_turn_event_params( is_first_turn, } = resolved_config; let token_usage = turn_state.token_usage.clone(); + let codex_error = turn_state.codex_error.as_ref(); CodexTurnEventParams { thread_id, session_id: thread_metadata.session_id.clone(), @@ -2483,6 +2519,9 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, + codex_error_kind: codex_error.map(|error| error.kind), + codex_error_subreason: codex_error.and_then(|error| error.subreason.clone()), + codex_error_http_status_code: codex_error.and_then(|error| error.http_status_code), steer_count: Some(turn_state.steer_count), total_tool_call_count: Some(turn_state.tool_counts.total), shell_command_count: Some(turn_state.tool_counts.shell_command), diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 11386e6bcd..a002ce20d6 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -240,6 +240,7 @@ async fn run_compact_task_inner_impl( continue; } sess.set_total_tokens_full(turn_context.as_ref()).await; + sess.track_turn_codex_error(turn_context.as_ref(), &e); let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); sess.send_event(&turn_context, event).await; return Err(e); @@ -257,6 +258,7 @@ async fn run_compact_task_inner_impl( tokio::time::sleep(delay).await; continue; } else { + sess.track_turn_codex_error(turn_context.as_ref(), &e); let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); sess.send_event(&turn_context, event).await; return Err(e); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index b4456dc717..c2ef417b9b 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -138,6 +138,7 @@ async fn run_remote_compact_task_inner( } attempt.track(sess.as_ref(), status, error.clone()).await; if let Err(err) = result { + sess.track_turn_codex_error(turn_context, &err); let event = EventMsg::Error( err.to_error_event(Some("Error running remote compact task".to_string())), ); diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 0da3017f7d..eb10eb211d 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -155,6 +155,7 @@ async fn run_remote_compact_task_inner( } attempt.track(sess.as_ref(), status, error.clone()).await; if let Err(err) = result { + sess.track_turn_codex_error(turn_context, &err); let event = EventMsg::Error( err.to_error_event(Some("Error running remote compact task".to_string())), ); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index d8f660e2a1..7c49cd742e 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -45,6 +45,7 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnCodexErrorFact; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_config::types::OAuthCredentialsStoreMode; @@ -1634,6 +1635,17 @@ impl Session { ) } + /// Record a terminal CodexErr before the app-server completion notification is reduced. + pub(crate) fn track_turn_codex_error(&self, turn_context: &TurnContext, error: &CodexErr) { + self.services + .analytics_events_client + .track_turn_codex_error(TurnCodexErrorFact::from_codex_err( + self.conversation_id.to_string(), + turn_context.sub_id.clone(), + error, + )); + } + /// Persist the event to rollout and send it to clients. pub(crate) async fn send_event(&self, turn_context: &TurnContext, msg: EventMsg) { let legacy_source = msg.clone(); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index d1e9e59f70..62f8d9a497 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -369,7 +369,7 @@ pub(crate) async fn run_turn( // Aborted turn is reported via a different event. break; } - Err(CodexErr::InvalidImageRequest()) => { + Err(codex_error @ CodexErr::InvalidImageRequest()) => { { let mut state = sess.state.lock().await; error_or_panic( @@ -380,6 +380,7 @@ pub(crate) async fn run_turn( } } + sess.track_turn_codex_error(turn_context.as_ref(), &codex_error); let error = CodexErrorInfo::BadRequest; sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) .await; @@ -405,6 +406,7 @@ pub(crate) async fn run_turn( { warn!("failed to usage-limit active goal after usage-limit error: {err}"); } + sess.track_turn_codex_error(turn_context.as_ref(), &e); let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); sess.send_event(&turn_context, event).await; // let the user continue the conversation From e29071e4c9bf724f9679b7bcf2038d74cc44acd1 Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 2 Jun 2026 20:51:25 +0200 Subject: [PATCH 28/59] Add environmentId to request_permissions (#25858) ## Stack 1. #25850 - Key request-permission grants by environment: stores and applies sticky permission grants per environment id. 2. This PR (#25858) - Add `environmentId` to `request_permissions`: lets the model target a selected environment and resolves relative permission paths against it. 3. #25862 - Propagate permission approval environment id: carries the selected environment id through approval events, app-server requests, TUI prompts, and delegate forwarding. 4. #25867 - Add remote request permissions integration coverage: verifies the selected remote environment across request, approval, grant reuse, and exec. This PR is stacked on #25850; #25862 and #25867 are stacked on this PR. ## Why PR1 made request-permission grants internally environment-keyed, but the model-facing `request_permissions` tool could still only target the primary environment. For CCA and multi-environment turns, the tool needs an explicit way to bind a permission request to a selected attached environment before resolving relative paths. ## What Changed - Added optional `environmentId` to `RequestPermissionsArgs`, with `environment_id` accepted as an alias. - Exposed `environmentId` in the `request_permissions` tool schema and description. - Resolve the selected environment before parsing filesystem permission paths, so relative paths bind to the selected environment cwd. - Route validated tool calls through `request_permissions_for_environment` directly instead of duplicating environment lookup in `Session::request_permissions`. - Reject unknown environment ids with a model-facing error. - Updated focused request-permissions and Guardian call sites for the new optional field. ## Testing Not run locally per instruction. --- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/session/mod.rs | 24 ------- codex-rs/core/src/session/tests.rs | 63 +++++++++++++++++-- .../core/src/session/tests/guardian_tests.rs | 18 +++++- .../src/tools/handlers/request_permissions.rs | 22 ++++++- .../core/src/tools/handlers/shell_spec.rs | 9 ++- .../src/tools/handlers/shell_spec_tests.rs | 7 +++ codex-rs/protocol/src/request_permissions.rs | 8 +++ 8 files changed, 119 insertions(+), 33 deletions(-) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 3132420e4c..f5f04d2dbd 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -755,6 +755,7 @@ async fn handle_request_permissions( ) { let call_id = event.call_id; let args = RequestPermissionsArgs { + environment_id: None, reason: event.reason, permissions: event.permissions, }; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 7c49cd742e..0abd3c6ec7 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2112,30 +2112,6 @@ impl Session { rx_approve } - pub async fn request_permissions( - self: &Arc, - turn_context: &Arc, - call_id: String, - args: RequestPermissionsArgs, - cancellation_token: CancellationToken, - ) -> Option { - let Some(turn_environment) = turn_context.environments.primary() else { - return Some(RequestPermissionsResponse { - permissions: RequestPermissionProfile::default(), - scope: PermissionGrantScope::Turn, - strict_auto_review: false, - }); - }; - self.request_permissions_for_environment( - turn_context, - call_id, - args, - turn_environment.selection(), - cancellation_token, - ) - .await - } - #[expect( clippy::await_holding_invalid_type, reason = "active turn checks and turn state updates must remain atomic" diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 260e91c092..6f54e54b14 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5365,11 +5365,17 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() let turn_context = Arc::clone(&turn_context); let call_id = call_id.clone(); async move { + let environment = turn_context + .environments + .primary() + .expect("primary environment") + .selection(); session - .request_permissions( + .request_permissions_for_environment( &turn_context, call_id, codex_protocol::request_permissions::RequestPermissionsArgs { + environment_id: None, reason: Some("need network".to_string()), permissions: RequestPermissionProfile { network: Some(codex_protocol::models::NetworkPermissions { @@ -5378,6 +5384,7 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() ..RequestPermissionProfile::default() }, }, + environment, CancellationToken::new(), ) .await @@ -5409,7 +5416,7 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() } #[tokio::test] -async fn request_permissions_tool_resolves_relative_paths_against_primary_environment() { +async fn request_permissions_tool_resolves_relative_paths_against_selected_environment() { let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; *session.active_turn.lock().await = Some(ActiveTurn::default()); let environment_cwd = { @@ -5429,6 +5436,7 @@ async fn request_permissions_tool_resolves_relative_paths_against_primary_enviro mcp_elicitations: true, })) .expect("test setup should allow updating approval policy"); + turn_context_mut.environments.turn_environments[0].environment_id = "remote".to_string(); turn_context_mut.environments.turn_environments[0].cwd = environment_cwd.clone(); let call_id = "call-1".to_string(); @@ -5451,6 +5459,7 @@ async fn request_permissions_tool_resolves_relative_paths_against_primary_enviro source: ToolCallSource::Direct, payload: ToolPayload::Function { arguments: json!({ + "environment_id": "remote", "reason": "need write", "permissions": { "file_system": { @@ -5509,6 +5518,38 @@ async fn request_permissions_tool_resolves_relative_paths_against_primary_enviro .expect("request_permissions handler should succeed"); } +#[tokio::test] +async fn request_permissions_tool_rejects_unknown_environment_id() { + let (session, turn_context) = make_session_and_context().await; + let result = RequestPermissionsHandler + .handle(ToolInvocation { + session: Arc::new(session), + turn: Arc::new(turn_context), + cancellation_token: CancellationToken::new(), + tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), + call_id: "call-1".to_string(), + tool_name: codex_tools::ToolName::plain("request_permissions"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: json!({ + "environment_id": "missing", + "permissions": { + "network": { + "enabled": true, + }, + }, + }) + .to_string(), + }, + }) + .await; + + let Err(FunctionCallError::RespondToModel(output)) = result else { + panic!("expected unknown environment id to be rejected"); + }; + assert_eq!(output, "unknown turn environment id `missing`"); +} + #[tokio::test] async fn request_permissions_response_materializes_session_cwd_grants_before_recording() { let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; @@ -5547,14 +5588,21 @@ async fn request_permissions_response_materializes_session_cwd_grants_before_rec let call_id = call_id.clone(); let requested_permissions = requested_permissions.clone(); async move { + let environment = turn_context + .environments + .primary() + .expect("primary environment") + .selection(); session - .request_permissions( + .request_permissions_for_environment( &turn_context, call_id, codex_protocol::request_permissions::RequestPermissionsArgs { + environment_id: None, reason: Some("need cwd write".to_string()), permissions: requested_permissions, }, + environment, CancellationToken::new(), ) .await @@ -5627,11 +5675,17 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req let session = Arc::new(session); let turn_context = Arc::new(turn_context); let call_id = "call-1".to_string(); + let environment = turn_context + .environments + .primary() + .expect("primary environment") + .selection(); let response = session - .request_permissions( + .request_permissions_for_environment( &turn_context, call_id, codex_protocol::request_permissions::RequestPermissionsArgs { + environment_id: None, reason: Some("need network".to_string()), permissions: RequestPermissionProfile { network: Some(codex_protocol::models::NetworkPermissions { @@ -5640,6 +5694,7 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req ..RequestPermissionProfile::default() }, }, + environment, CancellationToken::new(), ) .await; diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index e8fccd7d72..d8b46c9999 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -124,15 +124,22 @@ async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() { }), ..RequestPermissionProfile::default() }; + let environment = turn_context + .environments + .primary() + .expect("primary environment") + .selection(); let response = tokio::time::timeout( Duration::from_secs(45), - session.request_permissions( + session.request_permissions_for_environment( &turn_context, "perm-call-1".to_string(), RequestPermissionsArgs { + environment_id: None, reason: Some("need network".to_string()), permissions: requested_permissions.clone(), }, + environment, CancellationToken::new(), ), ) @@ -213,14 +220,21 @@ async fn request_permissions_guardian_review_stops_when_cancelled() { let requested_permissions = requested_permissions.clone(); let cancellation_token = cancellation_token.clone(); async move { + let environment = turn_context + .environments + .primary() + .expect("primary environment") + .selection(); session - .request_permissions( + .request_permissions_for_environment( &turn_context, "perm-call-cancelled".to_string(), RequestPermissionsArgs { + environment_id: None, reason: Some("need network".to_string()), permissions: requested_permissions, }, + environment, cancellation_token, ) .await diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index f4a89fe0ca..a20ff2b665 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -6,16 +6,25 @@ 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::parse_arguments; use crate::tools::handlers::parse_arguments_with_base_path; +use crate::tools::handlers::resolve_tool_environment; use crate::tools::handlers::shell_spec::create_request_permissions_tool; use crate::tools::handlers::shell_spec::request_permissions_tool_description; use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; use codex_tools::ToolName; use codex_tools::ToolSpec; +use serde::Deserialize; pub struct RequestPermissionsHandler; +#[derive(Deserialize)] +struct RequestPermissionsEnvironmentArgs { + #[serde(default, rename = "environment_id", alias = "environmentId")] + environment_id: Option, +} + #[async_trait::async_trait] impl ToolExecutor for RequestPermissionsHandler { fn tool_name(&self) -> ToolName { @@ -48,7 +57,10 @@ impl ToolExecutor for RequestPermissionsHandler { } }; - let Some(turn_environment) = turn.environments.primary() else { + let environment_args: RequestPermissionsEnvironmentArgs = parse_arguments(&arguments)?; + let Some(turn_environment) = + resolve_tool_environment(turn.as_ref(), environment_args.environment_id.as_deref())? + else { return Err(FunctionCallError::RespondToModel( "request_permissions requires a primary environment".to_string(), )); @@ -65,7 +77,13 @@ impl ToolExecutor for RequestPermissionsHandler { } let response = session - .request_permissions(&turn, call_id, args, cancellation_token) + .request_permissions_for_environment( + &turn, + call_id, + args, + turn_environment.selection(), + cancellation_token, + ) .await .ok_or_else(|| { FunctionCallError::RespondToModel( diff --git a/codex-rs/core/src/tools/handlers/shell_spec.rs b/codex-rs/core/src/tools/handlers/shell_spec.rs index 13c531eaba..3fb563b8d9 100644 --- a/codex-rs/core/src/tools/handlers/shell_spec.rs +++ b/codex-rs/core/src/tools/handlers/shell_spec.rs @@ -229,6 +229,13 @@ pub fn create_request_permissions_tool(description: String) -> ToolSpec { "Optional short explanation for why additional permissions are needed.".to_string(), )), ), + ( + "environment_id".to_string(), + JsonSchema::string(Some( + "Environment id from . Omit to use the primary environment." + .to_string(), + )), + ), ("permissions".to_string(), permission_profile_schema()), ]); @@ -247,7 +254,7 @@ pub fn create_request_permissions_tool(description: String) -> ToolSpec { } pub fn request_permissions_tool_description() -> String { - "Request additional filesystem or network permissions from the user and wait for the client to grant a subset of the requested permission profile. Granted permissions apply automatically to later shell-like commands in the current turn, or for the rest of the session if the client approves them at session scope." + "Request additional filesystem or network permissions from the user and wait for the client to grant a subset of the requested permission profile. Use environment_id to target a specific attached environment; omit it to use the primary environment. Relative filesystem paths resolve against the selected environment cwd. Granted permissions apply automatically to later shell-like commands in the current turn, or for the rest of the session if the client approves them at session scope." .to_string() } diff --git a/codex-rs/core/src/tools/handlers/shell_spec_tests.rs b/codex-rs/core/src/tools/handlers/shell_spec_tests.rs index b7c0db7242..b7bbfa54a1 100644 --- a/codex-rs/core/src/tools/handlers/shell_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_spec_tests.rs @@ -172,6 +172,13 @@ fn request_permissions_tool_includes_full_permission_schema() { "Optional short explanation for why additional permissions are needed.".to_string(), )), ), + ( + "environment_id".to_string(), + JsonSchema::string(Some( + "Environment id from . Omit to use the primary environment." + .to_string(), + )), + ), ("permissions".to_string(), permission_profile_schema()), ]); diff --git a/codex-rs/protocol/src/request_permissions.rs b/codex-rs/protocol/src/request_permissions.rs index be6b88ef52..43e8b8ee9d 100644 --- a/codex-rs/protocol/src/request_permissions.rs +++ b/codex-rs/protocol/src/request_permissions.rs @@ -48,6 +48,14 @@ impl From for RequestPermissionProfile { #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct RequestPermissionsArgs { + #[serde( + default, + rename = "environment_id", + alias = "environmentId", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional)] + pub environment_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, pub permissions: RequestPermissionProfile, From 57f337a8e90360649112f8fc01d9ee2f9f2afa24 Mon Sep 17 00:00:00 2001 From: Won Park Date: Tue, 2 Jun 2026 12:00:04 -0700 Subject: [PATCH 29/59] Route standalone image generation through host finalization md (#25176) ## Why Standalone image-generation extensions emitted turn items through the low-level event path, bypassing host-owned finalization such as image persistence and contributor processing. At the same time, the generated-image save-path hint must remain visible to the model through the extension tool's `FunctionCallOutput`, rather than the legacy built-in developer-message path. ## What changed - Extended `ExtensionTurnItem` to support image-generation items while keeping the extension-facing emitter API limited to `emit_started` and `emit_completed`. - Routed extension completion through core `finalize_turn_item`, so standalone image-generation items receive host-owned processing and persisted `saved_path` values before publication. - Kept legacy built-in image generation on its existing developer-message hint path, while standalone image generation returns its deterministic saved-path hint in `FunctionCallOutput`. - Shared the image artifact path and output-hint formatting used by core and the image-generation extension. - Passed thread identity through extension tool calls so standalone image generation can construct the same intended artifact path as core. - Added an app-server integration test covering real standalone image generation, saved artifact publication, model-visible output hint wiring, and absence of the legacy developer-message hint. ## Validation - `just fmt` - `just test -p codex-image-generation-extension` - `just test -p codex-web-search-extension` - `just test -p codex-goal-extension` - `just test -p codex-memories-extension` - Targeted `codex-core` tests for image save history, extension completion finalization, and contributor execution - `just test -p codex-app-server standalone_image_generation_returns_saved_path_hint_to_model` - `just fix -p codex-core` - `just fix -p codex-image-generation-extension` - `just bazel-lock-update` - `just bazel-lock-check` --- .../tests/suite/v2/image_generation.rs | 214 ++++++++++++++++++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + codex-rs/core/src/stream_events_utils.rs | 72 +++--- .../src/tools/handlers/extension_tools.rs | 170 +++++++------- codex-rs/ext/extension-api/src/lib.rs | 1 - codex-rs/ext/image-generation/src/tests.rs | 17 +- codex-rs/ext/image-generation/src/tool.rs | 37 +-- codex-rs/tools/src/lib.rs | 1 - codex-rs/tools/src/tool_call.rs | 28 +-- 9 files changed, 385 insertions(+), 156 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/image_generation.rs diff --git a/codex-rs/app-server/tests/suite/v2/image_generation.rs b/codex-rs/app-server/tests/suite/v2/image_generation.rs new file mode 100644 index 0000000000..4752f1497e --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/image_generation.rs @@ -0,0 +1,214 @@ +use std::path::Path; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadItem; +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_config::types::AuthCredentialsStoreMode; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use serde_json::json; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const RESULT: &str = "cG5n"; + +// macOS and Windows Bazel CI can spend tens of seconds starting app-server +// subprocesses or processing test RPCs under load. +#[cfg(any(target_os = "macos", windows))] +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); +#[cfg(not(any(target_os = "macos", windows)))] +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> Result<()> { + let call_id = "image-run-1"; + let server = responses::start_mock_server().await; + mount_image_response(&server).await; + + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call_with_namespace( + call_id, + "image_gen", + "imagegen", + &json!({ + "action": "generate", + "prompt": "paint a blue whale", + }) + .to_string(), + ), + responses::ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-2"), + ]), + ], + ) + .await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "Generate an image".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _turn: TurnStartResponse = to_response::(turn_resp)?; + + let completed = timeout( + DEFAULT_READ_TIMEOUT, + wait_for_image_generation_completed(&mut mcp), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let ThreadItem::ImageGeneration { + status, + revised_prompt, + result, + saved_path: Some(saved_path), + .. + } = completed.item + else { + panic!("expected completed image generation item with saved path"); + }; + assert_eq!(status, "completed"); + assert_eq!(revised_prompt.as_deref(), Some("paint a blue whale")); + assert_eq!(result, RESULT); + assert_eq!(std::fs::read(&saved_path)?, b"png"); + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + let output = requests[1].function_call_output(call_id); + assert_eq!( + output["output"][0], + json!({ + "type": "input_image", + "image_url": format!("data:image/png;base64,{RESULT}"), + "detail": "high", + }) + ); + assert_eq!(output["output"].as_array().map(Vec::len), Some(1)); + assert!( + !requests[1] + .message_input_texts("developer") + .iter() + .any(|text| text.contains("Generated images are saved to")), + "standalone image generation should not emit the legacy developer-message hint" + ); + + Ok(()) +} + +async fn wait_for_image_generation_completed( + mcp: &mut McpProcess, +) -> Result { + loop { + let notification = mcp + .read_stream_until_notification_message("item/completed") + .await?; + let completed: ItemCompletedNotification = serde_json::from_value( + notification + .params + .context("item/completed notification should include params")?, + )?; + if matches!(&completed.item, ThreadItem::ImageGeneration { .. }) { + return Ok(completed); + } + } +} + +async fn mount_image_response(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/api/codex/images/generations")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "created": 1, + "data": [{"b64_json": RESULT}], + }))) + .expect(1) + .mount(server) + .await; +} + +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +model_provider = "openai-custom" +chatgpt_base_url = "{server_uri}" + +[features] +imagegenext = true + +[model_providers.openai-custom] +name = "OpenAI" +base_url = "{server_uri}/api/codex" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +supports_websockets = false +requires_openai_auth = true +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 1605983705..950cccebb5 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -17,6 +17,7 @@ mod experimental_feature_list; mod external_agent_config; mod fs; mod hooks_list; +mod image_generation; mod initialize; mod marketplace_add; mod marketplace_remove; diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index bb1f5fba65..7914c8d482 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -131,6 +131,7 @@ pub(crate) async fn persist_image_generation_item( turn_context: &TurnContext, image_item: &mut ImageGenerationItem, ) -> Option { + image_item.saved_path = None; let session_id = sess.conversation_id.to_string(); match save_image_generation_result( &turn_context.config.codex_home, @@ -163,15 +164,12 @@ pub(crate) async fn persist_image_generation_item( } } -pub(crate) async fn finalize_image_generation_item( +async fn record_image_generation_instructions( sess: &Session, turn_context: &TurnContext, - image_item: &mut ImageGenerationItem, + image_item: &ImageGenerationItem, ) { - if persist_image_generation_item(sess, turn_context, image_item) - .await - .is_none() - { + if image_item.saved_path.is_none() { return; } let session_id = sess.conversation_id.to_string(); @@ -530,27 +528,16 @@ pub(crate) async fn handle_non_tool_response_item( | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } => { let mut turn_item = parse_turn_item(item)?; - if let TurnItemContributorPolicy::Run(turn_store) = contributor_policy { - apply_turn_item_contributors(sess, turn_store, &mut turn_item).await; - } - if let TurnItem::AgentMessage(agent_message) = &mut turn_item { - let combined = agent_message - .content - .iter() - .map(|entry| match entry { - codex_protocol::items::AgentMessageContent::Text { text } => text.as_str(), - }) - .collect::(); - let (stripped, memory_citation) = - strip_hidden_assistant_markup_and_parse_memory_citation(&combined, plan_mode); - agent_message.content = - vec![codex_protocol::items::AgentMessageContent::Text { text: stripped }]; - if agent_message.memory_citation.is_none() { - agent_message.memory_citation = memory_citation; - } - } - if let TurnItem::ImageGeneration(image_item) = &mut turn_item { - finalize_image_generation_item(sess, turn_context, image_item).await; + finalize_turn_item( + sess, + turn_context, + contributor_policy, + &mut turn_item, + plan_mode, + ) + .await; + if let TurnItem::ImageGeneration(image_item) = &turn_item { + record_image_generation_instructions(sess, turn_context, image_item).await; } Some(turn_item) } @@ -564,6 +551,37 @@ pub(crate) async fn handle_non_tool_response_item( } } +pub(crate) async fn finalize_turn_item( + sess: &Session, + turn_context: &TurnContext, + contributor_policy: TurnItemContributorPolicy<'_>, + turn_item: &mut TurnItem, + plan_mode: bool, +) { + if let TurnItemContributorPolicy::Run(turn_store) = contributor_policy { + apply_turn_item_contributors(sess, turn_store, turn_item).await; + } + if let TurnItem::AgentMessage(agent_message) = &mut *turn_item { + let combined = agent_message + .content + .iter() + .map(|entry| match entry { + codex_protocol::items::AgentMessageContent::Text { text } => text.as_str(), + }) + .collect::(); + let (stripped, memory_citation) = + strip_hidden_assistant_markup_and_parse_memory_citation(&combined, plan_mode); + agent_message.content = + vec![codex_protocol::items::AgentMessageContent::Text { text: stripped }]; + if agent_message.memory_citation.is_none() { + agent_message.memory_citation = memory_citation; + } + } + if let TurnItem::ImageGeneration(image_item) = &mut *turn_item { + persist_image_generation_item(sess, turn_context, image_item).await; + } +} + pub(crate) fn last_assistant_message_from_item( item: &ResponseItem, plan_mode: bool, diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 4ad88ac5bb..0a1e2e58c8 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -4,7 +4,6 @@ use std::sync::Weak; use codex_protocol::items::TurnItem; use codex_tools::ConversationHistory; use codex_tools::ExtensionTurnItem; -use codex_tools::ImageGenerationCompletionFuture; use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolName; use codex_tools::ToolSearchInfo; @@ -12,12 +11,11 @@ use codex_tools::ToolSpec; use codex_tools::TurnItemEmissionFuture; use codex_tools::TurnItemEmitter; -use crate::context::ContextualUserFragment; -use crate::context::ImageGenerationInstructions; use crate::function_tool::FunctionCallError; use crate::session::session::Session; use crate::session::turn_context::TurnContext; -use crate::stream_events_utils::persist_image_generation_item; +use crate::stream_events_utils::TurnItemContributorPolicy; +use crate::stream_events_utils::finalize_turn_item; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -76,6 +74,10 @@ struct CoreTurnItemEmitter { fn extension_turn_item(item: ExtensionTurnItem) -> TurnItem { match item { ExtensionTurnItem::WebSearch(item) => TurnItem::WebSearch(item), + ExtensionTurnItem::ImageGeneration(mut item) => { + item.saved_path = None; + TurnItem::ImageGeneration(item) + } } } @@ -85,8 +87,9 @@ impl TurnItemEmitter for CoreTurnItemEmitter { let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else { return; }; - let item = extension_turn_item(item); - session.emit_turn_item_started(turn.as_ref(), &item).await; + session + .emit_turn_item_started(turn.as_ref(), &extension_turn_item(item)) + .await; }) } @@ -95,54 +98,18 @@ impl TurnItemEmitter for CoreTurnItemEmitter { let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else { return; }; - let item = extension_turn_item(item); + let mut item = extension_turn_item(item); + finalize_turn_item( + session.as_ref(), + turn.as_ref(), + TurnItemContributorPolicy::Run(turn.extension_data.as_ref()), + &mut item, + turn.collaboration_mode.mode == codex_protocol::config_types::ModeKind::Plan, + ) + .await; session.emit_turn_item_completed(turn.as_ref(), item).await; }) } - - fn image_generation_completed<'a>( - &'a self, - call_id: String, - prompt: String, - result: String, - ) -> ImageGenerationCompletionFuture<'a> { - Box::pin(async move { - let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else { - return None; - }; - let mut item = codex_protocol::items::ImageGenerationItem { - id: call_id, - status: "completed".to_string(), - revised_prompt: Some(prompt), - result, - saved_path: None, - }; - let output_hint = - persist_image_generation_item(session.as_ref(), turn.as_ref(), &mut item) - .await - .map(|saved_path| { - let output_dir = saved_path - .parent() - .unwrap_or_else(|| turn.config.codex_home.clone()); - ImageGenerationInstructions::new(output_dir.display(), saved_path.display()) - .body() - }); - let started_item = codex_protocol::items::ImageGenerationItem { - id: item.id.clone(), - status: "in_progress".to_string(), - revised_prompt: None, - result: String::new(), - saved_path: None, - }; - session - .emit_turn_item_started(turn.as_ref(), &TurnItem::ImageGeneration(started_item)) - .await; - session - .emit_turn_item_completed(turn.as_ref(), TurnItem::ImageGeneration(item)) - .await; - output_hint - }) - } } async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { @@ -167,6 +134,8 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { mod tests { use std::sync::Arc; + use codex_extension_api::ExtensionData; + use codex_extension_api::TurnItemContributor; use codex_protocol::items::TurnItem; use codex_protocol::items::WebSearchItem; use codex_protocol::models::ContentItem; @@ -174,10 +143,13 @@ mod tests { use codex_protocol::models::WebSearchAction; use codex_protocol::protocol::EventMsg; use codex_tools::ExtensionTurnItem; + use codex_utils_absolute_path::test_support::PathExt; + use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; use serde_json::json; use tokio::sync::Mutex; + use super::CoreTurnItemEmitter; use super::ExtensionToolAdapter; use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; @@ -409,8 +381,54 @@ mod tests { assert_eq!(end.action, expected.action); } - struct ImageGenerationExtensionExecutor { - output_hint: Arc>>, + struct ImageGenerationExtensionExecutor; + + #[derive(Debug)] + struct ExtensionTurnItemContributorRan; + + struct RecordExtensionTurnItemContributor; + + #[async_trait::async_trait] + impl TurnItemContributor for RecordExtensionTurnItemContributor { + async fn contribute( + &self, + _thread_store: &ExtensionData, + turn_store: &ExtensionData, + _item: &mut TurnItem, + ) -> Result<(), String> { + turn_store.insert(ExtensionTurnItemContributorRan); + Ok(()) + } + } + + #[tokio::test] + async fn extension_completion_runs_turn_item_contributors() { + let (mut session, turn) = crate::session::tests::make_session_and_context().await; + let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); + builder.turn_item_contributor(Arc::new(RecordExtensionTurnItemContributor)); + session.services.extensions = Arc::new(builder.build()); + let session = Arc::new(session); + let turn = Arc::new(turn); + let emitter = CoreTurnItemEmitter { + session: Arc::downgrade(&session), + turn: Arc::downgrade(&turn), + }; + + codex_tools::TurnItemEmitter::emit_completed( + &emitter, + ExtensionTurnItem::WebSearch(WebSearchItem { + id: "search-1".to_string(), + query: "contributors".to_string(), + action: WebSearchAction::Other, + }), + ) + .await; + + assert!( + turn.extension_data + .get::() + .is_some() + ); } #[async_trait::async_trait] @@ -434,15 +452,28 @@ mod tests { &self, call: codex_tools::ToolCall, ) -> Result, codex_tools::FunctionCallError> { - let output_hint = call - .turn_item_emitter - .image_generation_completed( - call.call_id, - "A tiny blue square".to_string(), - "cG5n".to_string(), - ) + call.turn_item_emitter + .emit_started(ExtensionTurnItem::ImageGeneration( + codex_protocol::items::ImageGenerationItem { + id: call.call_id.clone(), + status: "in_progress".to_string(), + revised_prompt: None, + result: String::new(), + saved_path: None, + }, + )) + .await; + call.turn_item_emitter + .emit_completed(ExtensionTurnItem::ImageGeneration( + codex_protocol::items::ImageGenerationItem { + id: call.call_id, + status: "completed".to_string(), + revised_prompt: Some("A tiny blue square".to_string()), + result: "cG5n".to_string(), + saved_path: Some(test_path_buf("/tmp/extension-claimed.png").abs()), + }, + )) .await; - *self.output_hint.lock().await = output_hint; Ok(Box::new(codex_tools::JsonToolOutput::new( json!({ "ok": true }), ))) @@ -451,10 +482,7 @@ mod tests { #[tokio::test] async fn image_generation_publication_is_finalized_by_core() { - let output_hint = Arc::new(Mutex::new(None)); - let handler = ExtensionToolAdapter::new(Arc::new(ImageGenerationExtensionExecutor { - output_hint: Arc::clone(&output_hint), - })); + let handler = ExtensionToolAdapter::new(Arc::new(ImageGenerationExtensionExecutor)); let (session, turn, rx) = crate::session::tests::make_session_and_context_with_rx().await; let expected_path = crate::stream_events_utils::image_generation_artifact_path( &turn.config.codex_home, @@ -521,17 +549,5 @@ mod tests { std::fs::read(&expected_path).expect("generated artifact should be saved"), b"png" ); - assert_eq!( - *output_hint.lock().await, - Some(format!( - "Generated images are saved to {} as {} by default.\n\ - If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.", - expected_path - .parent() - .expect("generated image path should have a parent") - .display(), - expected_path.display(), - )) - ); } } diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index 2f00a8dd98..4705581c8a 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -13,7 +13,6 @@ pub use capabilities::ResponseItemInjector; pub use codex_tools::ConversationHistory; pub use codex_tools::ExtensionTurnItem; pub use codex_tools::FunctionCallError; -pub use codex_tools::ImageGenerationCompletionFuture; pub use codex_tools::JsonToolOutput; pub use codex_tools::NoopTurnItemEmitter; pub use codex_tools::ResponsesApiTool; diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs index f866a9018b..44f2d80ebe 100644 --- a/codex-rs/ext/image-generation/src/tests.rs +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -26,7 +26,6 @@ use crate::IMAGE_GEN_NAMESPACE; use crate::IMAGEGEN_TOOL_NAME; const RESULT: &str = "cG5n"; -const OUTPUT_HINT: &str = "Generated images are saved to /tmp as /tmp/call-1.png by default."; #[test] fn uses_reserved_image_gen_namespace() { @@ -55,10 +54,9 @@ fn generate_uses_fixed_request_defaults() { } #[test] -fn generated_output_returns_image_input_and_output_hint() { +fn generated_output_returns_image_input() { let output = GeneratedImageOutput { result: RESULT.to_string(), - output_hint: Some(OUTPUT_HINT.to_string()), }; let ResponseInputItem::FunctionCallOutput { @@ -73,15 +71,10 @@ fn generated_output_returns_image_input_and_output_hint() { }; assert_eq!( content_items, - vec![ - FunctionCallOutputContentItem::InputImage { - image_url: format!("data:image/png;base64,{RESULT}"), - detail: Some(DEFAULT_IMAGE_DETAIL), - }, - FunctionCallOutputContentItem::InputText { - text: OUTPUT_HINT.to_string(), - }, - ] + vec![FunctionCallOutputContentItem::InputImage { + image_url: format!("data:image/png;base64,{RESULT}"), + detail: Some(DEFAULT_IMAGE_DETAIL), + }] ); } diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index 75a79db2bf..41d407d912 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -3,6 +3,7 @@ use codex_api::ImageEditRequest; use codex_api::ImageGenerationRequest; use codex_api::ImageQuality; use codex_api::ImageUrl; +use codex_extension_api::ExtensionTurnItem; use codex_extension_api::FunctionCallError; use codex_extension_api::ToolCall; use codex_extension_api::ToolExecutor; @@ -11,6 +12,7 @@ use codex_extension_api::ToolOutput; use codex_extension_api::ToolPayload; use codex_extension_api::ToolSpec; use codex_extension_api::parse_tool_input_schema; +use codex_protocol::items::ImageGenerationItem; use codex_protocol::models::ContentItem; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::FunctionCallOutputBody; @@ -84,6 +86,15 @@ impl ToolExecutor for ImageGenerationTool { async fn handle(&self, call: ToolCall) -> Result, FunctionCallError> { let args = parse_args(&call)?; let request = request_for_action(&args, call.conversation_history.items())?; + call.turn_item_emitter + .emit_started(ExtensionTurnItem::ImageGeneration(ImageGenerationItem { + id: call.call_id.clone(), + status: "in_progress".to_string(), + revised_prompt: None, + result: String::new(), + saved_path: None, + })) + .await; let response = match request { ImageRequest::Generate(request) => self.backend.generate(request).await, ImageRequest::Edit(request) => self.backend.edit(request).await, @@ -96,14 +107,16 @@ impl ToolExecutor for ImageGenerationTool { "image generation returned no image data".to_string(), )); }; - let output_hint = call - .turn_item_emitter - .image_generation_completed(call.call_id.clone(), args.prompt, result.clone()) + call.turn_item_emitter + .emit_completed(ExtensionTurnItem::ImageGeneration(ImageGenerationItem { + id: call.call_id.clone(), + status: "completed".to_string(), + revised_prompt: Some(args.prompt), + result: result.clone(), + saved_path: None, + })) .await; - Ok(Box::new(GeneratedImageOutput { - result, - output_hint, - })) + Ok(Box::new(GeneratedImageOutput { result })) } } @@ -284,7 +297,6 @@ fn imagegen_tool_spec() -> ToolSpec { struct GeneratedImageOutput { result: String, - output_hint: Option, } impl ToolOutput for GeneratedImageOutput { @@ -298,17 +310,12 @@ impl ToolOutput for GeneratedImageOutput { true } - /// Returns generated bytes and persisted-artifact context for model follow-up. + /// Returns generated bytes for model follow-up. fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { - let mut content = vec![FunctionCallOutputContentItem::InputImage { + let content = vec![FunctionCallOutputContentItem::InputImage { image_url: format!("data:image/png;base64,{}", self.result), detail: Some(DEFAULT_IMAGE_DETAIL), }]; - if let Some(output_hint) = &self.output_hint { - content.push(FunctionCallOutputContentItem::InputText { - text: output_hint.clone(), - }); - } ResponseInputItem::FunctionCallOutput { call_id: call_id.to_string(), output: FunctionCallOutputPayload { diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 28c4f93bba..1cee3f7d2d 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -64,7 +64,6 @@ pub use responses_api::mcp_tool_to_responses_api_tool; pub use responses_api::tool_definition_to_responses_api_tool; pub use tool_call::ConversationHistory; pub use tool_call::ExtensionTurnItem; -pub use tool_call::ImageGenerationCompletionFuture; pub use tool_call::NoopTurnItemEmitter; pub use tool_call::ToolCall; pub use tool_call::TurnItemEmissionFuture; diff --git a/codex-rs/tools/src/tool_call.rs b/codex-rs/tools/src/tool_call.rs index 31143f9f1f..e3fc21e864 100644 --- a/codex-rs/tools/src/tool_call.rs +++ b/codex-rs/tools/src/tool_call.rs @@ -1,6 +1,7 @@ use crate::FunctionCallError; use crate::ToolName; use crate::ToolPayload; +use codex_protocol::items::ImageGenerationItem; use codex_protocol::items::WebSearchItem; use codex_protocol::models::ResponseItem; use codex_utils_output_truncation::TruncationPolicy; @@ -29,20 +30,14 @@ impl ConversationHistory { /// Future returned when an extension tool emits a visible turn-item lifecycle event. pub type TurnItemEmissionFuture<'a> = Pin + Send + 'a>>; -/// Future returned when an image-generation extension publishes completed image bytes. -pub type ImageGenerationCompletionFuture<'a> = - Pin> + Send + 'a>>; - -/// Visible turn items that an extension fully owns and may emit as-is. -/// -/// Add only item kinds that require no additional host finalization before -/// persistence or client delivery. Richer items need a host-owned publish path. +/// Visible turn items that an extension may publish into the host lifecycle. #[derive(Clone, Debug, PartialEq)] pub enum ExtensionTurnItem { WebSearch(WebSearchItem), + ImageGeneration(ImageGenerationItem), } -/// Host-provided capability for extension tools to emit finalized visible turn items. +/// Host-provided capability for extension tools to emit visible turn items. /// /// Implementations route lifecycle events through the host's normal item event /// pipeline, including any persistence and client delivery owned by the host. @@ -50,21 +45,8 @@ pub trait TurnItemEmitter: Send + Sync { /// Emits the beginning of one visible turn item. fn emit_started<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a>; - /// Emits the completion of one visible turn item. + /// Emits one visible turn item after host-owned finalization. fn emit_completed<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a>; - - /// Publishes image bytes for host persistence and visible completion. - /// - /// Returns persisted-artifact context for the extension's model-facing - /// function output when the host saves the generated image successfully. - fn image_generation_completed<'a>( - &'a self, - _call_id: String, - _prompt: String, - _result: String, - ) -> ImageGenerationCompletionFuture<'a> { - Box::pin(std::future::ready(None)) - } } /// Turn-item emitter used when a caller does not expose visible item emission. From de124c32be47b01e2204269f26bd1f00ab15bb56 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 2 Jun 2026 12:05:52 -0700 Subject: [PATCH 30/59] Fix Windows release PDB staging (#25916) ## Summary - Teach the Windows release prebuild staging step to locate Rust/MSVC PDBs emitted with crate-style underscore names. - Stage PDBs under the shipped hyphenated binary names so the downstream symbol archive step keeps the same artifact contract. - Keep a fallback for already-hyphenated PDB names and fail with a clear diagnostic if neither form exists. ## Root cause The recent symbol publishing change in #25649 started copying `${binary}.pdb` from `target//release` during Windows prebuild staging. Cargo still emits the `.exe` with the hyphenated binary name, but MSVC PDBs for hyphenated Rust crates are emitted with underscores, for example `codex_app_server.pdb` for `codex-app-server.exe`. The release workflow was still building into the expected directory; the new PDB copy step was looking for the wrong filename. ## Impact This unblocks the `rust-release` Windows prebuilt-binary jobs for hyphenated binaries while preserving the hyphenated PDB names consumed by the final Windows release packaging and symbol archive steps. ## Validation - `just fmt` from `codex-rs` - `git diff --check -- .github/workflows/rust-release-windows.yml` - Parsed `.github/workflows/rust-release-windows.yml` as YAML locally - Local bash staging sanity test for both underscore-emitted and hyphenated PDB filenames --- .github/workflows/rust-release-windows.yml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index f0fd6b90fc..fc14088ef1 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -134,11 +134,22 @@ jobs: - name: Stage Windows binaries shell: bash run: | - output_dir="target/${{ matrix.target }}/release/staged-${{ matrix.bundle }}" + release_dir="target/${{ matrix.target }}/release" + output_dir="$release_dir/staged-${{ matrix.bundle }}" mkdir -p "$output_dir" for binary in ${{ matrix.binaries }}; do - cp "target/${{ matrix.target }}/release/${binary}.exe" "$output_dir/${binary}.exe" - cp "target/${{ matrix.target }}/release/${binary}.pdb" "$output_dir/${binary}.pdb" + pdb_name="${binary//-/_}" + pdb_path="$release_dir/${pdb_name}.pdb" + if [[ ! -f "$pdb_path" ]]; then + pdb_path="$release_dir/${binary}.pdb" + fi + if [[ ! -f "$pdb_path" ]]; then + echo "PDB for $binary not found at $release_dir/${pdb_name}.pdb or $release_dir/${binary}.pdb" >&2 + exit 1 + fi + + cp "$release_dir/${binary}.exe" "$output_dir/${binary}.exe" + cp "$pdb_path" "$output_dir/${binary}.pdb" done - name: Upload Windows binaries From 9de568372da86141ced85e4153494b0b159f2171 Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 2 Jun 2026 21:09:34 +0200 Subject: [PATCH 31/59] Propagate permission approval environment id (#25862) ## Stack 1. #25850 - Key request-permission grants by environment: stores and applies sticky permission grants per environment id. 2. #25858 - Add `environmentId` to `request_permissions`: lets the model target a selected environment and resolves relative permission paths against it. 3. This PR (#25862) - Propagate permission approval environment id: carries the selected environment id through approval events, app-server requests, TUI prompts, and delegate forwarding. 4. #25867 - Add remote request permissions integration coverage: verifies the selected remote environment across request, approval, grant reuse, and exec. This PR is stacked on #25858, and #25867 is stacked on this PR. ## Why PR2 lets the model bind a `request_permissions` call to a selected environment, but the approval event and client-facing request still needed to carry that binding. For CCA, the user-facing prompt and delegated approval path should know which environment the grant applies to instead of relying on cwd alone. ## What Changed - Added optional `environmentId` to `RequestPermissionsEvent`. - Emit the selected environment id from core permission approval events. - Preserve the environment id through delegate forwarding, including cwd-based delegated requests. - Added `environmentId` to app-server permission approval params, generated schema/TypeScript artifacts, and README examples. - Preserve and display the environment id in TUI permission approval prompts. - Updated focused core, app-server protocol, and TUI conversion coverage. ## Testing Not run locally per instruction. Performed read-only `git diff --check`. --- codex-rs/analytics/src/analytics_client_tests.rs | 1 + .../json/PermissionsRequestApprovalParams.json | 9 ++++++++- .../schema/json/ServerRequest.json | 9 ++++++++- .../json/codex_app_server_protocol.schemas.json | 9 ++++++++- .../v2/PermissionsRequestApprovalParams.ts | 2 +- .../src/protocol/v2/permissions.rs | 2 ++ .../app-server-protocol/src/protocol/v2/tests.rs | 2 ++ codex-rs/app-server/README.md | 3 ++- codex-rs/app-server/src/bespoke_event_handling.rs | 1 + codex-rs/core/src/codex_delegate.rs | 2 +- codex-rs/core/src/codex_delegate_tests.rs | 6 +++++- codex-rs/core/src/session/mod.rs | 13 +++++++++++-- codex-rs/core/src/session/tests.rs | 9 +++++++++ codex-rs/protocol/src/request_permissions.rs | 9 +++++++++ codex-rs/tui/src/app/app_server_requests.rs | 1 + codex-rs/tui/src/app/event_dispatch.rs | 8 ++++++++ codex-rs/tui/src/app/tests.rs | 6 +++++- codex-rs/tui/src/app/thread_routing.rs | 1 + codex-rs/tui/src/bottom_pane/approval_overlay.rs | 10 ++++++++++ codex-rs/tui/src/chatwidget.rs | 1 + .../tui/src/chatwidget/tests/approval_requests.rs | 2 ++ codex-rs/tui/src/chatwidget/tool_requests.rs | 1 + 22 files changed, 97 insertions(+), 10 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 61ab5f6b23..0a11412efd 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -835,6 +835,7 @@ fn sample_permissions_approval_request(request_id: i64) -> ServerRequest { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "permissions-1".to_string(), + environment_id: None, started_at_ms: 1_000, cwd: test_path_buf("/tmp").abs(), reason: Some("need network".to_string()), diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json index 337a469890..961e24c2c2 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json @@ -285,6 +285,13 @@ "cwd": { "$ref": "#/definitions/AbsolutePathBuf" }, + "environmentId": { + "default": null, + "type": [ + "null", + "string" + ] + }, "itemId": { "type": "string" }, @@ -319,4 +326,4 @@ ], "title": "PermissionsRequestApprovalParams", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 411095f2c2..13bac2e118 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -1590,6 +1590,13 @@ "cwd": { "$ref": "#/definitions/AbsolutePathBuf" }, + "environmentId": { + "default": null, + "type": [ + "null", + "string" + ] + }, "itemId": { "type": "string" }, @@ -1998,4 +2005,4 @@ } ], "title": "ServerRequest" -} \ No newline at end of file +} 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 cdb8f95538..6aa4c5f35e 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 @@ -3783,6 +3783,13 @@ "cwd": { "$ref": "#/definitions/v2/AbsolutePathBuf" }, + "environmentId": { + "default": null, + "type": [ + "null", + "string" + ] + }, "itemId": { "type": "string" }, @@ -19232,4 +19239,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts index 509f60923b..d0677f5f0c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts @@ -4,7 +4,7 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { RequestPermissionProfile } from "./RequestPermissionProfile"; -export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, +export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, environmentId: string | null, /** * Unix timestamp (in milliseconds) when this approval request started. */ 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 dfc1942276..45fc5c7e14 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -663,6 +663,8 @@ pub struct PermissionsRequestApprovalParams { pub thread_id: String, pub turn_id: String, pub item_id: String, + #[serde(default)] + pub environment_id: Option, /// Unix timestamp (in milliseconds) when this approval request started. #[ts(type = "number")] pub started_at_ms: i64, 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 1e7558363d..1c1e92b36f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -400,6 +400,7 @@ fn permissions_request_approval_uses_request_permission_profile() { "threadId": "thr_123", "turnId": "turn_123", "itemId": "call_123", + "environmentId": "remote", "startedAtMs": 1, "cwd": absolute_path_string("repo"), "reason": "Select a workspace root", @@ -416,6 +417,7 @@ fn permissions_request_approval_uses_request_permission_profile() { .expect("permissions request should deserialize"); assert_eq!(params.cwd, absolute_path("repo")); + assert_eq!(params.environment_id.as_deref(), Some("remote")); assert_eq!( params.permissions, RequestPermissionProfile { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index f3b55337ce..fdc34e524c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1379,7 +1379,7 @@ the client can offer session-scoped and/or persistent approval choices. ### Permission requests -The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the command-execution `additionalPermissions` shape: it can request network access and additional filesystem access. The `cwd` field identifies the directory used to resolve project-root permissions and relative deny globs. +The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the command-execution `additionalPermissions` shape: it can request network access and additional filesystem access. The `environmentId` and `cwd` fields identify the environment and directory used to resolve project-root permissions and relative deny globs. ```json { @@ -1389,6 +1389,7 @@ The built-in `request_permissions` tool sends an `item/permissions/requestApprov "threadId": "thr_123", "turnId": "turn_123", "itemId": "call_123", + "environmentId": "local", "cwd": "/Users/me/project", "reason": "Select a workspace root", "permissions": { diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 43e3d305bb..f4a5f3b45f 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -761,6 +761,7 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn_id: request.turn_id.clone(), item_id: request.call_id.clone(), + environment_id: request.environment_id.clone(), started_at_ms: request.started_at_ms, cwd: request_cwd.clone(), reason: request.reason, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index f5f04d2dbd..678a6e5d10 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -755,7 +755,7 @@ async fn handle_request_permissions( ) { let call_id = event.call_id; let args = RequestPermissionsArgs { - environment_id: None, + environment_id: event.environment_id, reason: event.reason, permissions: event.permissions, }; diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 64e0c16416..7dabb09d33 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -183,9 +183,11 @@ async fn run_codex_thread_interactive_respects_pre_cancelled_spawn() { #[tokio::test] async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { - let (parent_session, parent_ctx, rx_events) = + let (parent_session, mut parent_ctx, rx_events) = crate::session::tests::make_session_and_context_with_rx().await; *parent_session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + let parent_ctx_mut = Arc::get_mut(&mut parent_ctx).expect("single turn context ref"); + parent_ctx_mut.environments.turn_environments[0].environment_id = "remote".to_string(); let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY); let (_tx_events, rx_events_child) = bounded(SUBMISSION_CHANNEL_CAPACITY); @@ -228,6 +230,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { RequestPermissionsEvent { call_id: request_call_id, turn_id: "child-turn-1".to_string(), + environment_id: Some("remote".to_string()), started_at_ms: 0, reason: Some("need access".to_string()), permissions: RequestPermissionProfile { @@ -252,6 +255,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { panic!("expected RequestPermissions event"); }; assert_eq!(request.call_id, call_id.clone()); + assert_eq!(request.environment_id.as_deref(), Some("remote")); assert_eq!(request.cwd, Some(delegated_cwd)); parent_session diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 0abd3c6ec7..5a92c9c825 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2251,6 +2251,7 @@ impl Session { let event = EventMsg::RequestPermissions(RequestPermissionsEvent { call_id: call_id.clone(), turn_id: turn_context.sub_id.clone(), + environment_id: Some(environment.environment_id.clone()), started_at_ms: now_unix_timestamp_ms(), reason: args.reason, permissions: requested_permissions, @@ -2279,14 +2280,22 @@ impl Session { cwd: AbsolutePathBuf, cancellation_token: CancellationToken, ) -> Option { - let Some(primary_environment) = turn_context.environments.primary() else { + let turn_environment = match args.environment_id.as_deref() { + Some(environment_id) => turn_context + .environments + .turn_environments + .iter() + .find(|environment| environment.environment_id == environment_id), + None => turn_context.environments.primary(), + }; + let Some(turn_environment) = turn_environment else { return Some(RequestPermissionsResponse { permissions: RequestPermissionProfile::default(), scope: PermissionGrantScope::Turn, strict_auto_review: false, }); }; - let mut environment = primary_environment.selection(); + let mut environment = turn_environment.selection(); environment.cwd = cwd; self.request_permissions_for_environment( turn_context, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 6f54e54b14..30043937c7 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5399,6 +5399,10 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() panic!("expected request_permissions event"); }; assert_eq!(request.call_id, call_id); + assert_eq!( + request.environment_id.as_deref(), + Some(codex_exec_server::LOCAL_ENVIRONMENT_ID) + ); #[allow(deprecated)] let turn_cwd = turn_context.cwd.clone(); assert_eq!(request.cwd, Some(turn_cwd)); @@ -5499,6 +5503,7 @@ async fn request_permissions_tool_resolves_relative_paths_against_selected_envir }), ..Default::default() }; + assert_eq!(request.environment_id.as_deref(), Some("remote")); assert_eq!(request.permissions, expected_permissions); session @@ -5616,6 +5621,10 @@ async fn request_permissions_response_materializes_session_cwd_grants_before_rec let EventMsg::RequestPermissions(request) = request_event.msg else { panic!("expected request_permissions event"); }; + assert_eq!( + request.environment_id.as_deref(), + Some(codex_exec_server::LOCAL_ENVIRONMENT_ID) + ); let request_cwd = request.cwd.clone().expect("request cwd"); session diff --git a/codex-rs/protocol/src/request_permissions.rs b/codex-rs/protocol/src/request_permissions.rs index 43e8b8ee9d..1752363ec5 100644 --- a/codex-rs/protocol/src/request_permissions.rs +++ b/codex-rs/protocol/src/request_permissions.rs @@ -79,6 +79,15 @@ pub struct RequestPermissionsEvent { /// Uses `#[serde(default)]` for backwards compatibility. #[serde(default)] pub turn_id: String, + #[serde( + default, + rename = "environmentId", + alias = "environment_id", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional)] + #[ts(rename = "environmentId")] + pub environment_id: Option, #[ts(type = "number")] pub started_at_ms: i64, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index 1843333023..c2580bd075 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -489,6 +489,7 @@ mod tests { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "perm-1".to_string(), + environment_id: None, started_at_ms: 0, cwd: absolute_path(if cfg!(windows) { r"C:\tmp" } else { "/tmp" }), reason: None, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 43e1179841..3570491d1a 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1856,12 +1856,20 @@ impl App { )); } ApprovalRequest::Permissions { + environment_id, permissions, reason, .. } => { let _ = tui.enter_alt_screen(); let mut lines = Vec::new(); + if let Some(environment_id) = environment_id { + lines.push(Line::from(vec![ + "Environment: ".into(), + environment_id.bold(), + ])); + lines.push(Line::from("")); + } if let Some(reason) = reason { lines.push(Line::from(vec!["Reason: ".into(), reason.italic()])); lines.push(Line::from("")); diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index f33f689acd..eb5e10c50f 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2539,6 +2539,7 @@ async fn inactive_thread_permissions_approval_preserves_file_system_permissions( thread_id: thread_id.to_string(), turn_id: "turn-approval".to_string(), item_id: "call-approval".to_string(), + environment_id: Some("remote".to_string()), started_at_ms: 0, cwd: test_absolute_path("/tmp"), reason: Some("Need access to .git".to_string()), @@ -2557,7 +2558,9 @@ async fn inactive_thread_permissions_approval_preserves_file_system_permissions( }; let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Permissions { - permissions, .. + environment_id, + permissions, + .. })) = app .interactive_request_for_thread_request(thread_id, &request) .await @@ -2565,6 +2568,7 @@ async fn inactive_thread_permissions_approval_preserves_file_system_permissions( panic!("expected permissions approval request"); }; + assert_eq!(environment_id.as_deref(), Some("remote")); assert_eq!( permissions, RequestPermissionProfile { diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index f2c5969f2f..1bd28d5f65 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -310,6 +310,7 @@ impl App { thread_id, thread_label, call_id: params.item_id.clone(), + environment_id: params.environment_id.clone(), reason: params.reason.clone(), permissions: params.permissions.clone().into(), }), diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 17b17f3598..f318dd2103 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -84,6 +84,7 @@ pub(crate) enum ApprovalRequest { thread_id: ThreadId, thread_label: Option, call_id: String, + environment_id: Option, reason: Option, permissions: RequestPermissionProfile, }, @@ -713,6 +714,7 @@ fn build_header(request: &ApprovalRequest) -> Box { } ApprovalRequest::Permissions { thread_label, + environment_id, reason, permissions, .. @@ -725,6 +727,13 @@ fn build_header(request: &ApprovalRequest) -> Box { ])); header.push(Line::from("")); } + if let Some(environment_id) = environment_id { + header.push(Line::from(vec![ + "Environment: ".into(), + environment_id.clone().bold(), + ])); + header.push(Line::from("")); + } if let Some(reason) = reason { header.push(Line::from(vec!["Reason: ".into(), reason.clone().italic()])); header.push(Line::from("")); @@ -1227,6 +1236,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, call_id: "test".to_string(), + environment_id: None, reason: Some("need workspace access".to_string()), permissions: RequestPermissionProfile { network: Some(NetworkPermissions { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 246e16d69c..c9827a4e71 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -864,6 +864,7 @@ fn request_permissions_from_params( RequestPermissionsEvent { turn_id: params.turn_id, call_id: params.item_id, + environment_id: params.environment_id, started_at_ms: params.started_at_ms, reason: params.reason, permissions: params.permissions.into(), diff --git a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs index d732177ce4..18fc155649 100644 --- a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs +++ b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs @@ -281,6 +281,7 @@ fn app_server_request_permissions_preserves_file_system_permissions() { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "item-1".to_string(), + environment_id: Some("remote".to_string()), started_at_ms: 0, cwd: cwd.clone(), reason: Some("Select a workspace root".to_string()), @@ -310,6 +311,7 @@ fn app_server_request_permissions_preserves_file_system_permissions() { } ); assert_eq!(request.cwd, Some(cwd)); + assert_eq!(request.environment_id.as_deref(), Some("remote")); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tool_requests.rs b/codex-rs/tui/src/chatwidget/tool_requests.rs index 58bbd7cef0..3001563451 100644 --- a/codex-rs/tui/src/chatwidget/tool_requests.rs +++ b/codex-rs/tui/src/chatwidget/tool_requests.rs @@ -441,6 +441,7 @@ impl ChatWidget { thread_id: self.thread_id.unwrap_or_default(), thread_label: None, call_id: ev.call_id, + environment_id: ev.environment_id, reason: ev.reason, permissions: ev.permissions, }; From bc49677ec8aeed439c45832802918b36b1c04b5f Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 2 Jun 2026 12:27:01 -0700 Subject: [PATCH 32/59] [codex] Pin Python SDK to glibc-compatible runtime (#25907) ## Summary - pin the Python SDK runtime package to `openai-codex-cli-bin==0.136.0` so Ubuntu/glibc installs resolve a compatible wheel - refresh generated SDK artifacts and lock data for the runtime update - keep newly generated client-message-id wire models internal to the generated protocol layer ## Dependency - merge #25906 first so the Python SDK release publishes both manylinux and musllinux runtime wheels before publishing the package with this pin ## Validation - ran `just fmt` - regenerated the Python public API helpers - validated the edited workflow YAML - CI passed 29/29 checks --- .github/workflows/python-sdk-release.yml | 6 +- .github/workflows/sdk.yml | 6 +- sdk/python/pyproject.toml | 6 +- sdk/python/scripts/update_sdk_artifacts.py | 6 +- .../generated/notification_registry.py | 2 + .../src/openai_codex/generated/v2_all.py | 446 +++++++++++++++--- .../test_artifact_workflow_and_binaries.py | 25 +- sdk/python/tests/test_contract_generation.py | 2 +- sdk/python/uv.lock | 18 +- 9 files changed, 407 insertions(+), 110 deletions(-) diff --git a/.github/workflows/python-sdk-release.yml b/.github/workflows/python-sdk-release.yml index 654dd8d822..96870db171 100644 --- a/.github/workflows/python-sdk-release.yml +++ b/.github/workflows/python-sdk-release.yml @@ -34,8 +34,8 @@ jobs: exit 1 fi - # The pinned runtime currently publishes a musllinux Linux wheel. - # Build in Alpine so release type generation installs that wheel. + # Build in a glibc Linux image so release type generation installs + # the pinned manylinux runtime wheel. docker run --rm \ --user "$(id -u):$(id -g)" \ -e HOME=/tmp/codex-python-sdk-home \ @@ -46,7 +46,7 @@ jobs: -v "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \ -v "${RUNNER_TEMP}:${RUNNER_TEMP}" \ -w "${GITHUB_WORKSPACE}/sdk/python" \ - python:3.12-alpine \ + python:3.12-slim \ sh -euxc ' python -m venv /tmp/release-tools /tmp/release-tools/bin/python -m pip install build twine uv==0.11.3 diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 54ed8dc558..4103a948cd 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -23,15 +23,15 @@ jobs: run: | set -euo pipefail - # Run inside Alpine so dependency resolution exercises the pinned - # runtime wheel on the same Linux wheel family that CI installs. + # Run inside a glibc Linux image so dependency resolution exercises + # the pinned manylinux runtime wheel that users install. docker run --rm \ --user "$(id -u):$(id -g)" \ -e HOME=/tmp/codex-python-sdk-home \ -e UV_LINK_MODE=copy \ -v "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \ -w "${GITHUB_WORKSPACE}/sdk/python" \ - python:3.12-alpine \ + python:3.12-slim \ sh -euxc ' python -m venv /tmp/uv /tmp/uv/bin/python -m pip install uv==0.11.3 diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 9c93be69a5..6b7bbddd87 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -16,7 +16,7 @@ classifiers = [ "Intended Audience :: Developers", "Topic :: Software Development :: Libraries :: Python Modules", ] -dependencies = ["pydantic>=2.12", "openai-codex-cli-bin==0.132.0"] +dependencies = ["pydantic>=2.12", "openai-codex-cli-bin==0.136.0"] [project.urls] Homepage = "https://github.com/openai/codex" @@ -78,10 +78,10 @@ combine-as-imports = true [tool.uv] exclude-newer = "7 days" -exclude-newer-package = { openai-codex-cli-bin = "2026-05-20T21:00:00Z" } +exclude-newer-package = { openai-codex-cli-bin = "2026-06-01T20:00:00Z" } index-strategy = "first-index" [tool.uv.pip] exclude-newer = "7 days" -exclude-newer-package = { openai-codex-cli-bin = "2026-05-20T21:00:00Z" } +exclude-newer-package = { openai-codex-cli-bin = "2026-06-01T20:00:00Z" } index-strategy = "first-index" diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index 1742ced547..a4af5d605e 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -1163,7 +1163,9 @@ def generate_public_api_flat_methods() -> None: turn_start_fields = _load_public_fields( "openai_codex.generated.v2_all", "TurnStartParams", - exclude={"thread_id", "input", *approval_fields}, + # Keep the wire model current without exposing this app-server field + # through the ergonomic Python API yet. + exclude={"thread_id", "input", "client_user_message_id", *approval_fields}, ) turn_start_fields = _replace_public_sandbox_field(turn_start_fields, wire_name="sandbox_policy") @@ -1267,7 +1269,7 @@ def build_parser() -> argparse.ArgumentParser: "--platform-tag", help=( "Optional wheel platform tag override, for example " - "macosx_11_0_arm64 or musllinux_1_1_x86_64." + "macosx_11_0_arm64 or manylinux_2_17_x86_64." ), ) return parser diff --git a/sdk/python/src/openai_codex/generated/notification_registry.py b/sdk/python/src/openai_codex/generated/notification_registry.py index c55eb5b9b7..d5e620a7a3 100644 --- a/sdk/python/src/openai_codex/generated/notification_registry.py +++ b/sdk/python/src/openai_codex/generated/notification_registry.py @@ -57,6 +57,7 @@ from .v2_all import ThreadRealtimeSdpNotification from .v2_all import ThreadRealtimeStartedNotification from .v2_all import ThreadRealtimeTranscriptDeltaNotification from .v2_all import ThreadRealtimeTranscriptDoneNotification +from .v2_all import ThreadSettingsUpdatedNotification from .v2_all import ThreadStartedNotification from .v2_all import ThreadStatusChangedNotification from .v2_all import ThreadTokenUsageUpdatedNotification @@ -122,6 +123,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = { "thread/realtime/started": ThreadRealtimeStartedNotification, "thread/realtime/transcript/delta": ThreadRealtimeTranscriptDeltaNotification, "thread/realtime/transcript/done": ThreadRealtimeTranscriptDoneNotification, + "thread/settings/updated": ThreadSettingsUpdatedNotification, "thread/started": ThreadStartedNotification, "thread/status/changed": ThreadStatusChangedNotification, "thread/tokenUsage/updated": ThreadTokenUsageUpdatedNotification, diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 85120b8293..c27a8f75ca 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -56,7 +56,7 @@ class ActivePermissionProfile(BaseModel): extends: Annotated[ str | None, Field( - 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." ), ] = None id: Annotated[ @@ -77,6 +77,11 @@ class AddCreditsNudgeEmailStatus(Enum): cooldown_active = "cooldown_active" +class AdditionalContextKind(Enum): + untrusted = "untrusted" + application = "application" + + class AdditionalNetworkPermissions(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -251,6 +256,11 @@ class AuthMode(Enum): agent_identity = "agentIdentity" +class AutoCompactTokenLimitScope(Enum): + total = "total" + body_after_prefix = "body_after_prefix" + + class AutoReviewDecisionSource(RootModel[Literal["agent"]]): model_config = ConfigDict( populate_by_name=True, @@ -563,6 +573,13 @@ class CommandMigration(BaseModel): name: str +class ComputerUseRequirements(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + allow_locked_computer_use: Annotated[bool | None, Field(alias="allowLockedComputerUse")] = None + + class MdmConfigLayerSource(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -675,7 +692,7 @@ class ConfigReadParams(BaseModel): description="Optional working directory to resolve project config layers. If specified, return the effective config as seen from that directory (i.e., including any project layers between `cwd` and the project/repo root)." ), ] = None - include_layers: Annotated[bool | None, Field(alias="includeLayers")] = False + include_layers: Annotated[bool | None, Field(alias="includeLayers")] = None class CommandConfiguredHookHandler(BaseModel): @@ -913,7 +930,7 @@ class FeedbackUploadParams(BaseModel): ) classification: str extra_log_files: Annotated[list[str] | None, Field(alias="extraLogFiles")] = None - include_logs: Annotated[bool, Field(alias="includeLogs")] + include_logs: Annotated[bool | None, Field(alias="includeLogs")] = None reason: str | None = None tags: dict[str, Any] | None = None thread_id: Annotated[str | None, Field(alias="threadId")] = None @@ -939,7 +956,7 @@ class FileChangeOutputDeltaNotification(BaseModel): class FileSystemAccessMode(Enum): read = "read" write = "write" - none = "none" + deny = "deny" class PathFileSystemPath(BaseModel): @@ -1276,6 +1293,17 @@ class InputTextFunctionCallOutputContentItem(BaseModel): ] +class EncryptedContentFunctionCallOutputContentItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + encrypted_content: str + type: Annotated[ + Literal["encrypted_content"], + Field(title="EncryptedContentFunctionCallOutputContentItemType"), + ] + + class FuzzyFileSearchMatchType(Enum): file = "file" directory = "directory" @@ -1335,7 +1363,7 @@ class GetAccountParams(BaseModel): alias="refreshToken", description="When `true`, requests a proactive token refresh before returning.\n\nIn managed auth mode this triggers the normal refresh-token flow. In external auth mode this flag is ignored. Clients should refresh tokens themselves and call `account/login/start` with `chatgptAuthTokens`.", ), - ] = False + ] = None class GitInfo(BaseModel): @@ -1425,6 +1453,8 @@ class HookEventName(Enum): post_compact = "postCompact" session_start = "sessionStart" user_prompt_submit = "userPromptSubmit" + subagent_start = "subagentStart" + subagent_stop = "subagentStop" stop = "stop" @@ -1506,6 +1536,8 @@ class HooksListParams(BaseModel): class ImageDetail(Enum): + auto = "auto" + low = "low" high = "high" original = "original" @@ -1742,6 +1774,8 @@ class ManagedHooksRequirements(BaseModel): pre_tool_use: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="PreToolUse")] session_start: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="SessionStart")] stop: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="Stop")] + subagent_start: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="SubagentStart")] + subagent_stop: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="SubagentStop")] user_prompt_submit: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="UserPromptSubmit")] managed_dir: Annotated[str | None, Field(alias="managedDir")] = None windows_managed_dir: Annotated[str | None, Field(alias="windowsManagedDir")] = None @@ -1835,6 +1869,18 @@ class McpResourceReadParams(BaseModel): uri: str +class McpServerInfo(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + description: str | None = None + icons: list | None = None + name: str + title: str | None = None + version: str + website_url: Annotated[str | None, Field(alias="websiteUrl")] = None + + class McpServerMigration(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -2142,7 +2188,7 @@ class NetworkRequirements(BaseModel): class NetworkUnixSocketPermission(Enum): allow = "allow" - none = "none" + deny = "deny" class NonSteerableTurnKind(Enum): @@ -2188,6 +2234,32 @@ class PatchChangeKind( root: AddPatchChangeKind | DeletePatchChangeKind | UpdatePatchChangeKind +class PermissionProfileListParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cursor: Annotated[ + str | None, Field(description="Opaque pagination cursor returned by a previous call.") + ] = None + cwd: Annotated[ + str | None, + Field(description="Optional working directory to resolve project config layers."), + ] = None + limit: Annotated[ + int | None, Field(description="Optional page size; defaults to the full result set.", ge=0) + ] = None + + +class PermissionProfileSummary(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + description: Annotated[ + str | None, Field(description="Optional user-facing description for display in clients.") + ] = None + id: Annotated[str, Field(description="Available permission profile identifier.")] + + class Personality(Enum): none = "none" friendly = "friendly" @@ -2332,6 +2404,7 @@ class PluginInterface(BaseModel): class PluginListMarketplaceKind(Enum): local = "local" + vertical = "vertical" workspace_directory = "workspace-directory" shared_with_me = "shared-with-me" @@ -3469,6 +3542,20 @@ class SkillsConfigWriteResponse(BaseModel): effective_enabled: Annotated[bool, Field(alias="effectiveEnabled")] +class SkillsExtraRootsSetParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + extra_roots: Annotated[list[AbsolutePathBuf], Field(alias="extraRoots")] + + +class SkillsExtraRootsSetResponse(BaseModel): + pass + model_config = ConfigDict( + populate_by_name=True, + ) + + class SkillsListParams(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -3624,6 +3711,20 @@ class ThreadCompactStartResponse(BaseModel): ) +class ThreadGoalClearParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadGoalClearResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cleared: bool + + class ThreadGoalClearedNotification(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -3631,6 +3732,13 @@ class ThreadGoalClearedNotification(BaseModel): thread_id: Annotated[str, Field(alias="threadId")] +class ThreadGoalGetParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + + class ThreadGoalStatus(Enum): active = "active" paused = "paused" @@ -3749,6 +3857,7 @@ class McpToolCallThreadItem(BaseModel): error: McpToolCallError | None = None id: str mcp_app_resource_uri: Annotated[str | None, Field(alias="mcpAppResourceUri")] = None + plugin_id: Annotated[str | None, Field(alias="pluginId")] = None result: McpToolCallResult | None = None server: str status: McpToolCallStatus @@ -3922,7 +4031,7 @@ class ThreadReadParams(BaseModel): alias="includeTurns", description="When true, include turns and their items from rollout history.", ), - ] = False + ] = None thread_id: Annotated[str, Field(alias="threadId")] @@ -4536,6 +4645,14 @@ class AccountUpdatedNotification(BaseModel): plan_type: Annotated[PlanType | None, Field(alias="planType")] = None +class AdditionalContextEntry(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + kind: AdditionalContextKind + value: str + + class AppConfig(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -4629,6 +4746,24 @@ class ThreadNameSetRequest(BaseModel): params: ThreadSetNameParams +class ThreadGoalGetRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["thread/goal/get"], Field(title="Thread/goal/getRequestMethod")] + params: ThreadGoalGetParams + + +class ThreadGoalClearRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["thread/goal/clear"], Field(title="Thread/goal/clearRequestMethod")] + params: ThreadGoalClearParams + + class ThreadMetadataUpdateRequest(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -4730,6 +4865,17 @@ class SkillsListRequest(BaseModel): params: SkillsListParams +class SkillsExtraRootsSetRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["skills/extraRoots/set"], Field(title="Skills/extraRoots/setRequestMethod") + ] + params: SkillsExtraRootsSetParams + + class HooksListRequest(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -4995,6 +5141,17 @@ class ExperimentalFeatureListRequest(BaseModel): params: ExperimentalFeatureListParams +class PermissionProfileListRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["permissionProfile/list"], Field(title="PermissionProfile/listRequestMethod") + ] + params: PermissionProfileListParams + + class ExperimentalFeatureEnablementSetRequest(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -5431,16 +5588,22 @@ class ConfigRequirements(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + allow_appshots: Annotated[bool | None, Field(alias="allowAppshots")] = None allow_managed_hooks_only: Annotated[bool | None, Field(alias="allowManagedHooksOnly")] = None allowed_approval_policies: Annotated[ list[AskForApproval] | None, Field(alias="allowedApprovalPolicies") ] = None + allowed_permissions: Annotated[list[str] | None, Field(alias="allowedPermissions")] = None allowed_sandbox_modes: Annotated[ list[SandboxMode] | None, Field(alias="allowedSandboxModes") ] = None allowed_web_search_modes: Annotated[ list[WebSearchMode] | None, Field(alias="allowedWebSearchModes") ] = None + allowed_windows_sandbox_implementations: Annotated[ + list[WindowsSandboxSetupMode] | None, Field(alias="allowedWindowsSandboxImplementations") + ] = None + computer_use: Annotated[ComputerUseRequirements | None, Field(alias="computerUse")] = None enforce_residency: Annotated[ResidencyRequirement | None, Field(alias="enforceResidency")] = ( None ) @@ -5608,13 +5771,19 @@ class InputImageFunctionCallOutputContentItem(BaseModel): class FunctionCallOutputContentItem( - RootModel[InputTextFunctionCallOutputContentItem | InputImageFunctionCallOutputContentItem] + RootModel[ + InputTextFunctionCallOutputContentItem + | InputImageFunctionCallOutputContentItem + | EncryptedContentFunctionCallOutputContentItem + ] ): model_config = ConfigDict( populate_by_name=True, ) root: Annotated[ - InputTextFunctionCallOutputContentItem | InputImageFunctionCallOutputContentItem, + InputTextFunctionCallOutputContentItem + | InputImageFunctionCallOutputContentItem + | EncryptedContentFunctionCallOutputContentItem, Field( description="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." ), @@ -5767,6 +5936,7 @@ class ListMcpServerStatusParams(BaseModel): int | None, Field(description="Optional page size; defaults to a server-defined value.", ge=0), ] = None + thread_id: Annotated[str | None, Field(alias="threadId")] = None class McpResourceReadResponse(BaseModel): @@ -5784,6 +5954,7 @@ class McpServerStatus(BaseModel): name: str resource_templates: Annotated[list[ResourceTemplate], Field(alias="resourceTemplates")] resources: list[Resource] + server_info: Annotated[McpServerInfo | None, Field(alias="serverInfo")] = None tools: dict[str, Tool] @@ -5817,6 +5988,13 @@ class Model(BaseModel): ] = [] availability_nux: Annotated[ModelAvailabilityNux | None, Field(alias="availabilityNux")] = None default_reasoning_effort: Annotated[ReasoningEffort, Field(alias="defaultReasoningEffort")] + default_service_tier: Annotated[ + str | None, + Field( + alias="defaultServiceTier", + description="Catalog default service tier id for this model, when one is configured.", + ), + ] = None description: str display_name: Annotated[str, Field(alias="displayName")] hidden: bool @@ -5859,6 +6037,20 @@ class OverriddenMetadata(BaseModel): overriding_layer: Annotated[ConfigLayerMetadata, Field(alias="overridingLayer")] +class PermissionProfileListResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + data: list[PermissionProfileSummary] + next_cursor: Annotated[ + str | None, + Field( + alias="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.", + ), + ] = None + + class PluginSharePrincipal(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -6332,6 +6524,30 @@ class ThreadGoal(BaseModel): updated_at: Annotated[int, Field(alias="updatedAt")] +class ThreadGoalGetResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + goal: ThreadGoal | None = None + + +class ThreadGoalSetParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + objective: str | None = None + status: ThreadGoalStatus | None = None + thread_id: Annotated[str, Field(alias="threadId")] + token_budget: Annotated[int | None, Field(alias="tokenBudget")] = None + + +class ThreadGoalSetResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + goal: ThreadGoal + + class ThreadGoalUpdatedNotification(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -6345,6 +6561,7 @@ class UserMessageThreadItem(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + client_id: Annotated[str | None, Field(alias="clientId")] = None content: list[UserInput] id: str type: Annotated[Literal["userMessage"], Field(title="UserMessageThreadItemType")] @@ -6536,6 +6753,55 @@ class ThreadListParams(BaseModel): ] = None +class ThreadResumeInitialTurnsPageParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + items_view: Annotated[ + TurnItemsView | None, + Field( + alias="itemsView", + description="How much item detail to include for each returned turn; defaults to summary.", + ), + ] = None + limit: Annotated[int | None, Field(description="Optional turn page size.", ge=0)] = None + sort_direction: Annotated[ + SortDirection | None, + Field( + alias="sortDirection", + description="Optional turn pagination direction; defaults to descending.", + ), + ] = None + + +class ThreadSettings(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + active_permission_profile: Annotated[ + ActivePermissionProfile | None, Field(alias="activePermissionProfile") + ] = None + approval_policy: Annotated[AskForApproval, Field(alias="approvalPolicy")] + approvals_reviewer: Annotated[ApprovalsReviewer, Field(alias="approvalsReviewer")] + collaboration_mode: Annotated[CollaborationMode, Field(alias="collaborationMode")] + cwd: AbsolutePathBuf + effort: ReasoningEffort | None = None + model: str + model_provider: Annotated[str, Field(alias="modelProvider")] + personality: Personality | None = None + sandbox_policy: Annotated[SandboxPolicy, Field(alias="sandboxPolicy")] + service_tier: Annotated[str | None, Field(alias="serviceTier")] = None + summary: ReasoningSummary | None = None + + +class ThreadSettingsUpdatedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + thread_settings: Annotated[ThreadSettings, Field(alias="threadSettings")] + + class ThreadStartParams(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -6648,6 +6914,7 @@ class TurnStartParams(BaseModel): description="Override where approval requests are routed for review on this turn and subsequent turns.", ), ] = None + client_user_message_id: Annotated[str | None, Field(alias="clientUserMessageId")] = None cwd: Annotated[ str | None, Field(description="Override the working directory for this turn and subsequent turns."), @@ -6696,6 +6963,7 @@ class TurnSteerParams(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + client_user_message_id: Annotated[str | None, Field(alias="clientUserMessageId")] = None expected_turn_id: Annotated[ str, Field( @@ -6803,6 +7071,15 @@ class ThreadForkRequest(BaseModel): params: ThreadForkParams +class ThreadGoalSetRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["thread/goal/set"], Field(title="Thread/goal/setRequestMethod")] + params: ThreadGoalSetParams + + class ThreadListRequest(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -6891,6 +7168,41 @@ class ConfigValueWriteRequest(BaseModel): params: ConfigValueWriteParams +class Config(BaseModel): + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + analytics: AnalyticsConfig | None = None + approval_policy: AskForApproval | None = None + approvals_reviewer: Annotated[ + ApprovalsReviewer | None, + Field( + description="[UNSTABLE] Optional default for where approval requests are routed for review." + ), + ] = None + compact_prompt: str | None = None + desktop: dict[str, Any] | None = None + developer_instructions: str | None = None + forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | None = None + forced_login_method: ForcedLoginMethod | None = None + instructions: str | None = None + model: str | None = None + model_auto_compact_token_limit: int | None = None + model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope | None = None + model_context_window: int | None = None + model_provider: str | None = None + model_reasoning_effort: ReasoningEffort | None = None + model_reasoning_summary: ReasoningSummary | None = None + model_verbosity: Verbosity | None = None + review_model: str | None = None + sandbox_mode: SandboxMode | None = None + sandbox_workspace_write: SandboxWorkspaceWrite | None = None + service_tier: str | None = None + tools: ToolsV2 | None = None + web_search: WebSearchMode | None = None + + class ConfigBatchWriteParams(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -6913,6 +7225,15 @@ class ConfigBatchWriteParams(BaseModel): ] = None +class ConfigReadResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + config: Config + layers: list[ConfigLayer] | None = None + origins: dict[str, ConfigLayerMetadata] + + class ConfigWriteResponse(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -7115,29 +7436,6 @@ class PluginSummary(BaseModel): source: PluginSource -class ProfileV2(BaseModel): - model_config = ConfigDict( - extra="allow", - populate_by_name=True, - ) - approval_policy: AskForApproval | None = None - approvals_reviewer: Annotated[ - ApprovalsReviewer | None, - Field( - description="[UNSTABLE] Optional profile-level override for where approval requests are routed for review. If omitted, the enclosing config default is used." - ), - ] = None - chatgpt_base_url: str | None = None - model: str | None = None - model_provider: str | None = None - model_reasoning_effort: ReasoningEffort | None = None - model_reasoning_summary: ReasoningSummary | None = None - model_verbosity: Verbosity | None = None - service_tier: str | None = None - tools: ToolsV2 | None = None - web_search: WebSearchMode | None = None - - class RequestPermissionProfile(BaseModel): model_config = ConfigDict( extra="forbid", @@ -7229,6 +7527,16 @@ class ThreadGoalUpdatedServerNotification(BaseModel): params: ThreadGoalUpdatedNotification +class ThreadSettingsUpdatedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["thread/settings/updated"], Field(title="Thread/settings/updatedNotificationMethod") + ] + params: ThreadSettingsUpdatedNotification + + class ThreadTokenUsageUpdatedServerNotification(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -7393,6 +7701,15 @@ class TurnStartedNotification(BaseModel): turn: Turn +class TurnsPage(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + backwards_cursor: Annotated[str | None, Field(alias="backwardsCursor")] = None + data: list[Turn] + next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None + + class PluginShareSaveRequest(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -7411,51 +7728,6 @@ class ConfigBatchWriteRequest(BaseModel): params: ConfigBatchWriteParams -class Config(BaseModel): - model_config = ConfigDict( - extra="allow", - populate_by_name=True, - ) - analytics: AnalyticsConfig | None = None - approval_policy: AskForApproval | None = None - approvals_reviewer: Annotated[ - ApprovalsReviewer | None, - Field( - description="[UNSTABLE] Optional default for where approval requests are routed for review." - ), - ] = None - compact_prompt: str | None = None - desktop: dict[str, Any] | None = None - developer_instructions: str | None = None - forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | None = None - forced_login_method: ForcedLoginMethod | None = None - instructions: str | None = None - model: str | None = None - model_auto_compact_token_limit: int | None = None - model_context_window: int | None = None - model_provider: str | None = None - model_reasoning_effort: ReasoningEffort | None = None - model_reasoning_summary: ReasoningSummary | None = None - model_verbosity: Verbosity | None = None - profile: str | None = None - profiles: dict[str, ProfileV2] | None = {} - review_model: str | None = None - sandbox_mode: SandboxMode | None = None - sandbox_workspace_write: SandboxWorkspaceWrite | None = None - service_tier: str | None = None - tools: ToolsV2 | None = None - web_search: WebSearchMode | None = None - - -class ConfigReadResponse(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - config: Config - layers: list[ConfigLayer] | None = None - origins: dict[str, ConfigLayerMetadata] - - class ExternalAgentConfigDetectResponse(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -7892,6 +8164,14 @@ class ThreadRollbackResponse(BaseModel): ] +class ThreadSearchResult(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + snippet: str + thread: Thread + + class ThreadStartResponse(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -7960,6 +8240,9 @@ class ClientRequest( | ThreadArchiveRequest | ThreadUnsubscribeRequest | ThreadNameSetRequest + | ThreadGoalSetRequest + | ThreadGoalGetRequest + | ThreadGoalClearRequest | ThreadMetadataUpdateRequest | ThreadUnarchiveRequest | ThreadCompactStartRequest @@ -7971,6 +8254,7 @@ class ClientRequest( | ThreadReadRequest | ThreadInjectItemsRequest | SkillsListRequest + | SkillsExtraRootsSetRequest | HooksListRequest | MarketplaceAddRequest | MarketplaceRemoveRequest @@ -8004,6 +8288,7 @@ class ClientRequest( | ModelListRequest | ModelProviderCapabilitiesReadRequest | ExperimentalFeatureListRequest + | PermissionProfileListRequest | ExperimentalFeatureEnablementSetRequest | McpServerOauthLoginRequest | ConfigMcpServerReloadRequest @@ -8043,6 +8328,9 @@ class ClientRequest( | ThreadArchiveRequest | ThreadUnsubscribeRequest | ThreadNameSetRequest + | ThreadGoalSetRequest + | ThreadGoalGetRequest + | ThreadGoalClearRequest | ThreadMetadataUpdateRequest | ThreadUnarchiveRequest | ThreadCompactStartRequest @@ -8054,6 +8342,7 @@ class ClientRequest( | ThreadReadRequest | ThreadInjectItemsRequest | SkillsListRequest + | SkillsExtraRootsSetRequest | HooksListRequest | MarketplaceAddRequest | MarketplaceRemoveRequest @@ -8087,6 +8376,7 @@ class ClientRequest( | ModelListRequest | ModelProviderCapabilitiesReadRequest | ExperimentalFeatureListRequest + | PermissionProfileListRequest | ExperimentalFeatureEnablementSetRequest | McpServerOauthLoginRequest | ConfigMcpServerReloadRequest @@ -8158,6 +8448,7 @@ class ServerNotification( | ThreadNameUpdatedServerNotification | ThreadGoalUpdatedServerNotification | ThreadGoalClearedServerNotification + | ThreadSettingsUpdatedServerNotification | ThreadTokenUsageUpdatedServerNotification | TurnStartedServerNotification | HookStartedServerNotification @@ -8227,6 +8518,7 @@ class ServerNotification( | ThreadNameUpdatedServerNotification | ThreadGoalUpdatedServerNotification | ThreadGoalClearedServerNotification + | ThreadSettingsUpdatedServerNotification | ThreadTokenUsageUpdatedServerNotification | TurnStartedServerNotification | HookStartedServerNotification diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index a8dda48854..5a8f1275d6 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -265,8 +265,9 @@ def test_schema_normalization_only_flattens_string_literal_oneofs( "AuthMode", "InputModality", "ExperimentalFeatureStage", - "ProcessOutputStream", + "AutoCompactTokenLimitScope", "CommandExecOutputStream", + "ProcessOutputStream", ] @@ -357,10 +358,10 @@ def test_source_sdk_template_pins_published_runtime() -> None: "dependencies": pyproject["project"]["dependencies"], } == { "sdk_template_version": "0.0.0-dev", - "runtime_pin": "0.132.0", + "runtime_pin": "0.136.0", "dependencies": [ "pydantic>=2.12", - "openai-codex-cli-bin==0.132.0", + "openai-codex-cli-bin==0.136.0", ], } @@ -435,7 +436,7 @@ def test_runtime_setup_reads_independent_runtime_pin_and_release_tags() -> None: } == { "package_name": "openai-codex-cli-bin", "sdk_template_version": "0.0.0-dev", - "runtime_pin": "0.132.0", + "runtime_pin": "0.136.0", "normalized_release_version": "0.116.0a1", "release_tag": "rust-v0.116.0-alpha.1", } @@ -594,11 +595,11 @@ def test_stage_runtime_release_can_pin_wheel_platform_tag(tmp_path: Path) -> Non tmp_path / "runtime-stage", "0.116.0a1", package_archive, - platform_tag="musllinux_1_1_x86_64", + platform_tag="manylinux_2_17_x86_64", ) pyproject = (staged / "pyproject.toml").read_text() - assert 'platform-tag = "musllinux_1_1_x86_64"' in pyproject + assert 'platform-tag = "manylinux_2_17_x86_64"' in pyproject def test_stage_runtime_release_rejects_incomplete_package_layout(tmp_path: Path) -> None: @@ -650,7 +651,7 @@ def test_stage_sdk_release_preserves_reviewed_runtime_pin(tmp_path: Path) -> Non "version": "0.1.0b1", "dependencies": [ "pydantic>=2.12", - "openai-codex-cli-bin==0.132.0", + "openai-codex-cli-bin==0.136.0", ], } assert ( @@ -687,7 +688,7 @@ def test_sdk_beta_release_can_pin_stable_runtime(tmp_path: Path) -> None: ) runtime_stage = script.stage_python_runtime_package( tmp_path / "runtime-stage", - "0.132.0", + "0.136.0", package_archive, ) @@ -700,10 +701,10 @@ def test_sdk_beta_release_can_pin_stable_runtime(tmp_path: Path) -> None: "sdk_dependencies": sdk_pyproject["project"]["dependencies"], } == { "sdk_version": "0.1.0b1", - "runtime_version": "0.132.0", + "runtime_version": "0.136.0", "sdk_dependencies": [ "pydantic>=2.12", - "openai-codex-cli-bin==0.132.0", + "openai-codex-cli-bin==0.136.0", ], } @@ -762,7 +763,7 @@ def test_stage_runtime_stages_package_without_type_generation(tmp_path: Path) -> "--codex-version", "rust-v0.116.0-alpha.1", "--platform-tag", - "musllinux_1_1_x86_64", + "manylinux_2_17_x86_64", ] ) @@ -793,7 +794,7 @@ def test_stage_runtime_stages_package_without_type_generation(tmp_path: Path) -> script.run_command(args, ops) - assert calls == ["stage_runtime:0.116.0a1:musllinux_1_1_x86_64:codex-package.tar.gz"] + assert calls == ["stage_runtime:0.116.0a1:manylinux_2_17_x86_64:codex-package.tar.gz"] def test_default_runtime_is_resolved_from_installed_runtime_package( diff --git a/sdk/python/tests/test_contract_generation.py b/sdk/python/tests/test_contract_generation.py index 2c95d06a3e..a6d3d2fdc4 100644 --- a/sdk/python/tests/test_contract_generation.py +++ b/sdk/python/tests/test_contract_generation.py @@ -40,7 +40,7 @@ def test_generated_files_are_up_to_date(): # Regenerate contract artifacts via the pinned runtime package, not a local # app-server binary from the checkout or CI environment. - assert importlib.metadata.version("openai-codex-cli-bin") == "0.132.0" + assert importlib.metadata.version("openai-codex-cli-bin") == "0.136.0" env = os.environ.copy() env.pop("CODEX_EXEC_PATH", None) python_bin = str(Path(sys.executable).parent) diff --git a/sdk/python/uv.lock b/sdk/python/uv.lock index 6d9e40e37a..9dabd34d71 100644 --- a/sdk/python/uv.lock +++ b/sdk/python/uv.lock @@ -7,7 +7,7 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P7D" [options.exclude-newer-package] -openai-codex-cli-bin = "2026-05-20T21:00:00Z" +openai-codex-cli-bin = "2026-06-01T20:00:00Z" [[package]] name = "annotated-types" @@ -299,7 +299,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "datamodel-code-generator", marker = "extra == 'dev'", specifier = "==0.31.2" }, - { name = "openai-codex-cli-bin", specifier = "==0.132.0" }, + { name = "openai-codex-cli-bin", specifier = "==0.136.0" }, { name = "pydantic", specifier = ">=2.12" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.8" }, @@ -308,15 +308,15 @@ provides-extras = ["dev"] [[package]] name = "openai-codex-cli-bin" -version = "0.132.0" +version = "0.136.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/a1/b92b7a1b73a83785d2e1dcd0faecd1b7f886a38cf02a30abe1c35f42f0f7/openai_codex_cli_bin-0.132.0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:1c22b51dbd679413f84f00b9d8fd4e5cf8a1c0d1c7cc8c42bcb3f9f1b33e2334", size = 89403211, upload-time = "2026-05-20T02:37:22.311Z" }, - { url = "https://files.pythonhosted.org/packages/5f/68/163272e582de55a7f460e2329281267908d75d0fbcbbbb2c6749a6329e6b/openai_codex_cli_bin-0.132.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:56217495e6635c8a5d96df820cc0da5f46cd9b6ec6f3a5f67f1607d69ef74256", size = 79058685, upload-time = "2026-05-20T02:37:27.165Z" }, - { url = "https://files.pythonhosted.org/packages/0b/18/a60c6b137e7cd3959cae16ba757f57ca5702979b0ea107a21f516ba15d98/openai_codex_cli_bin-0.132.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:09642e7578a3078bfccc82af4077b085d42b0022b529e4b5c645e0a0af3397a4", size = 78689038, upload-time = "2026-05-20T02:37:31.548Z" }, - { url = "https://files.pythonhosted.org/packages/f8/eb/1b184307a67c1006d59b61636bcfcea73a89aa95271f6516ed28dce554ca/openai_codex_cli_bin-0.132.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:85aec095f9d144d7a2d1aff39fce77b7240f42014580c35801ba74b9317aa5f7", size = 85528820, upload-time = "2026-05-20T02:37:36.559Z" }, - { url = "https://files.pythonhosted.org/packages/0e/e8/1b823a8bf7b96d1513905ad79b16a146d797f81a19a6bc350a2f95a16661/openai_codex_cli_bin-0.132.0-py3-none-win_amd64.whl", hash = "sha256:3cb5c90c55baa39bd5ddc890d2068d3e1322a57a54d1d0e623819009a205c7f5", size = 86916218, upload-time = "2026-05-20T02:37:41.886Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e6/bb8634bd4f3adaea299c95d7b03105ac417e32dd6d8bc2af5dda141d6f28/openai_codex_cli_bin-0.132.0-py3-none-win_arm64.whl", hash = "sha256:74ef93d3deef7cb83c71d19fc667defe749cdab337ec331f59a23511561b6f6a", size = 79892931, upload-time = "2026-05-20T02:37:46.828Z" }, + { url = "https://files.pythonhosted.org/packages/45/2e/09764338fcb104ebcb1ebf2490d71ab4b8339b5d6f8bdce19480adad5a99/openai_codex_cli_bin-0.136.0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:9ded24a9977221f67173f49b8c3ba3a944787b7323dbd664dd8e8ae1a5efc0aa", size = 94036262, upload-time = "2026-06-01T19:28:13.291Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/10dea458f8ead97b6449a1c05b65883ffe28724204c05e6169439576ee48/openai_codex_cli_bin-0.136.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8d194688c1afe87bf85705528cbcbdb762cb378d8c58c64162402babbd795436", size = 83713829, upload-time = "2026-06-01T19:28:17.39Z" }, + { url = "https://files.pythonhosted.org/packages/7d/83/21402a805f3312196a4f4bef0b6a45d3dee301e3db3ddeee117b4b1e612b/openai_codex_cli_bin-0.136.0-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:bdbdfeeac5bb2e744bd810cb60c55313c280d0f6ff9e93699431bcfc2040c9c2", size = 82981863, upload-time = "2026-06-01T19:28:21.246Z" }, + { url = "https://files.pythonhosted.org/packages/33/79/1617e008381345391def39254404c0248a425739bd13c95d9fbe3e63d16f/openai_codex_cli_bin-0.136.0-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:37b353435f354c6d1bcc32e3b83e40ef9d1ef7294c8adedd229fbaedc82a6f74", size = 90187224, upload-time = "2026-06-01T19:28:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bc/a591d4464f999e0fab6abb5ba369463eee99a82e7c298e2cff257d80b849/openai_codex_cli_bin-0.136.0-py3-none-win_amd64.whl", hash = "sha256:c91ffbb1d903970e74b65958f7d1fa548fac8abdfe9b3a098f3da7f47dbb2575", size = 91137642, upload-time = "2026-06-01T19:28:28.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/f3/e8ccd644e7a87a25229022088abf4d724d3d608fcda7123f9a11515409ca/openai_codex_cli_bin-0.136.0-py3-none-win_arm64.whl", hash = "sha256:9073f7283c47ad3e585e23598ebbef554e85d280cabb637b6cb9a078c5e7086a", size = 83623904, upload-time = "2026-06-01T19:28:32.734Z" }, ] [[package]] From af18e92140f875f042325114108b8720d6836e96 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 2 Jun 2026 12:41:13 -0700 Subject: [PATCH 33/59] Use environment secrets for Azure signing (#24859) ## Summary - Move Azure Trusted Signing values out of reusable workflow-call secrets and into the `azure-artifact-signing` environment scope - Attach the Windows signing job to the `azure-artifact-signing` environment so it can resolve the signing secrets directly - Stop inheriting caller secrets for the Windows release reusable workflow ## Validation - `git diff --check -- .github/workflows/rust-release.yml .github/workflows/rust-release-windows.yml` - `ruby -e 'require "yaml"; ARGV.each { |path| YAML.load_file(path); puts "ok #{path}" }' .github/workflows/rust-release.yml .github/workflows/rust-release-windows.yml` --- .github/workflows/rust-release-windows.yml | 16 +++------------- .github/workflows/rust-release.yml | 1 - 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index fc14088ef1..0d8cc29697 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -6,19 +6,6 @@ on: release-lto: required: true type: string - secrets: - AZURE_TRUSTED_SIGNING_CLIENT_ID: - required: true - AZURE_TRUSTED_SIGNING_TENANT_ID: - required: true - AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID: - required: true - AZURE_TRUSTED_SIGNING_ENDPOINT: - required: true - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: - required: true - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: - required: true # Cargo's libgit2 transport has been flaky when fetching git dependencies with # nested submodules. Prefer the system git CLI across every Cargo invocation. @@ -164,6 +151,9 @@ jobs: - build-windows-binaries name: Build - ${{ matrix.runner }} - ${{ matrix.target }} runs-on: ${{ matrix.runs_on }} + environment: + name: azure-artifact-signing + deployment: false timeout-minutes: 90 permissions: contents: read diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 364fe78902..6b0814dae5 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -865,7 +865,6 @@ jobs: uses: ./.github/workflows/rust-release-windows.yml with: release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} - secrets: inherit argument-comment-lint-release-assets: if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} From ad355d4c9641953bb85c242c92f72f924edd4687 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 2 Jun 2026 12:42:42 -0700 Subject: [PATCH 34/59] Fix Windows running thread resume path normalization (#25509) ## Why Fixes #24944. On Windows, app-server resume could reject an active running thread when the requested session path used normal `C:\...` form and the already-running path used verbatim `\\?\C:\...` form. The paths point at the same JSONL file, but the resume stale-path guard compared raw `PathBuf`s, so desktop resume and heartbeat flows could fail with a mismatched-path error. ## What Changed - Compare requested and active rollout paths with `path_utils::paths_match_after_normalization`. - Extend the existing running-thread mismatched-path test with a Windows-only same-file resume case before the stale-path rejection. ## Verification - `just test -p codex-app-server thread_resume_rejects_mismatched_path_for_running_thread_id` --- .../request_processors/thread_processor.rs | 2 +- .../tests/suite/v2/thread_resume.rs | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) 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 32dfb1eeb9..b26c2e4df3 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -2766,7 +2766,7 @@ impl ThreadRequestProcessor { .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 + && !path_utils::paths_match_after_normalization(requested_path, active_path) { return Err(invalid_request(format!( "cannot resume running thread {existing_thread_id} with stale path: requested `{}`, active `{}`", 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 c016b38827..3073b382a5 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -2410,6 +2410,36 @@ async fn thread_resume_rejects_mismatched_path_for_running_thread_id() -> Result ) .await??; + #[cfg(windows)] + { + let active_path = thread.path.as_ref().expect("thread should have path"); + let active_path_display = active_path.as_os_str().to_string_lossy(); + let equivalent_path = if let Some(path) = active_path_display.strip_prefix(r"\\?\UNC\") { + PathBuf::from(format!(r"\\{path}")) + } else if let Some(path) = active_path_display.strip_prefix(r"\\?\") { + PathBuf::from(path) + } else if let Some(path) = active_path_display.strip_prefix(r"\\") { + PathBuf::from(format!(r"\\?\UNC\{path}")) + } else { + PathBuf::from(format!(r"\\?\{active_path_display}")) + }; + let normalized_resume_id = primary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread_id.clone(), + path: Some(equivalent_path), + ..Default::default() + }) + .await?; + let normalized_resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(normalized_resume_id)), + ) + .await??; + let ThreadResumeResponse { thread, .. } = + to_response::(normalized_resume_resp)?; + assert_eq!(thread.id, thread_id); + } + let stale_thread_id = Uuid::new_v4().to_string(); let stale_path = rollout_path(codex_home.path(), "2025-01-01T00-00-00", &stale_thread_id); std::fs::create_dir_all(stale_path.parent().expect("stale path parent"))?; From b794182ea7e57c9bde214bb9fd32040445a8ae38 Mon Sep 17 00:00:00 2001 From: knittel-openai <153027209+knittel-openai@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:46:14 -0700 Subject: [PATCH 35/59] Populate workspace kind on Codex turn events (#25135) ## Summary - carry `workspace_kind` from Responses API client metadata into the turn resolved analytics fact - serialize the optional value on `codex_turn_event` - cover both the turn metadata source and turn event serialization The `workspace_kind` tells us whether a thread had a project attached vs projectless. this is an indicator for who is adopting Codex for knowledge work outside of coding ## Testing - `env UV_CACHE_DIR=/private/tmp/uv-cache /private/tmp/cargo-tools/bin/just fmt` - `env PATH=/private/tmp/cargo-tools/bin:$PATH CARGO_HOME=/private/tmp/cargo-home UV_CACHE_DIR=/private/tmp/uv-cache /private/tmp/cargo-tools/bin/just test -p codex-analytics` - `env PATH=/private/tmp/cargo-tools/bin:$PATH CARGO_HOME=/private/tmp/cargo-home UV_CACHE_DIR=/private/tmp/uv-cache /private/tmp/cargo-tools/bin/just test -p codex-core turn_metadata` Paired with openai/openai#970661, which keeps forwarding the same metadata key through Responses API headers. --- codex-rs/analytics/src/analytics_client_tests.rs | 4 ++++ codex-rs/analytics/src/events.rs | 1 + codex-rs/analytics/src/facts.rs | 1 + codex-rs/analytics/src/reducer.rs | 2 ++ codex-rs/app-server/tests/suite/v2/turn_start.rs | 5 +++++ codex-rs/core/src/session/turn.rs | 1 + codex-rs/core/src/turn_metadata.rs | 9 +++++++++ codex-rs/core/src/turn_metadata_tests.rs | 3 +++ 8 files changed, 26 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0a11412efd..7ceda36a6a 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -391,6 +391,7 @@ fn sample_turn_resolved_config(thread_id: &str, turn_id: &str) -> TurnResolvedCo sandbox_network_access: true, collaboration_mode: ModeKind::Plan, personality: None, + workspace_kind: None, is_first_turn: true, } } @@ -3255,6 +3256,7 @@ fn turn_event_serializes_expected_shape() { sandbox_network_access: true, collaboration_mode: Some("plan"), personality: Some("pragmatic".to_string()), + workspace_kind: Some("projectless".to_string()), num_input_images: 2, is_first_turn: true, status: Some(TurnStatus::Completed), @@ -3320,6 +3322,7 @@ fn turn_event_serializes_expected_shape() { "sandbox_network_access": true, "collaboration_mode": "plan", "personality": "pragmatic", + "workspace_kind": "projectless", "num_input_images": 2, "is_first_turn": true, "status": "completed", @@ -3640,6 +3643,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert!(payload["event_params"].get("product_client_id").is_none()); assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["workspace_kind"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d2e7d80545..61fe75ac27 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -794,6 +794,7 @@ pub(crate) struct CodexTurnEventParams { pub(crate) sandbox_network_access: bool, pub(crate) collaboration_mode: Option<&'static str>, pub(crate) personality: Option, + pub(crate) workspace_kind: Option, pub(crate) num_input_images: usize, pub(crate) is_first_turn: bool, pub(crate) status: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 4d298ffdbf..b056108507 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -85,6 +85,7 @@ pub struct TurnResolvedConfigFact { pub sandbox_network_access: bool, pub collaboration_mode: ModeKind, pub personality: Option, + pub workspace_kind: Option, pub is_first_turn: bool, } diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 46f69c59f1..5b41178530 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -2483,6 +2483,7 @@ fn codex_turn_event_params( sandbox_network_access, collaboration_mode, personality, + workspace_kind, is_first_turn, } = resolved_config; let token_usage = turn_state.token_usage.clone(); @@ -2515,6 +2516,7 @@ fn codex_turn_event_params( sandbox_network_access, collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), personality: personality_mode(personality), + workspace_kind, num_input_images, is_first_turn, status: completed.status, diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 0c2c7b71cd..9f6a027a75 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -855,6 +855,10 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { url: "https://example.com/a.png".to_string(), detail: None, }], + responsesapi_client_metadata: Some(HashMap::from([( + "workspace_kind".to_string(), + "projectless".to_string(), + )])), ..Default::default() }) .await?; @@ -882,6 +886,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["workspace_kind"], "projectless"); assert_eq!(event["event_params"]["ephemeral"], false); assert_eq!(event["event_params"]["thread_source"], "user"); assert_eq!(event["event_params"]["initialization_mode"], "new"); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 62f8d9a497..498e65c7b9 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -640,6 +640,7 @@ async fn track_turn_resolved_config_analytics( sandbox_network_access: turn_context.network_sandbox_policy().is_enabled(), collaboration_mode: turn_context.collaboration_mode.mode, personality: turn_context.personality, + workspace_kind: turn_context.turn_metadata_state.workspace_kind(), is_first_turn, }); } diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index 7296d6d3f0..e66a1fbb50 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -33,6 +33,7 @@ const MODEL_KEY: &str = "model"; const REASONING_EFFORT_KEY: &str = "reasoning_effort"; const TURN_STARTED_AT_UNIX_MS_KEY: &str = "turn_started_at_unix_ms"; const USER_INPUT_REQUESTED_DURING_TURN_KEY: &str = "user_input_requested_during_turn"; +const WORKSPACE_KIND_KEY: &str = "workspace_kind"; const REQUEST_KIND_KEY: &str = "request_kind"; const COMPACTION_KEY: &str = "compaction"; const WINDOW_ID_KEY: &str = "window_id"; @@ -443,6 +444,14 @@ impl TurnMetadataState { Some(responsesapi_client_metadata); } + pub(crate) fn workspace_kind(&self) -> Option { + self.responsesapi_client_metadata + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .and_then(|metadata| metadata.get(WORKSPACE_KIND_KEY).cloned()) + } + pub(crate) fn set_turn_started_at_unix_ms(&self, turn_started_at_unix_ms: i64) { *self .turn_started_at_unix_ms diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index ffbeb83d26..32a559848a 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -546,6 +546,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( state.set_responsesapi_client_metadata(HashMap::from([ ("fiber_run_id".to_string(), "fiber-123".to_string()), ("origin".to_string(), "東京".to_string()), + ("workspace_kind".to_string(), "projectless".to_string()), ("model".to_string(), "client-supplied".to_string()), ( "reasoning_effort".to_string(), @@ -580,6 +581,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( assert_eq!(json["fiber_run_id"].as_str(), Some("fiber-123")); assert_eq!(json["origin"].as_str(), Some("東京")); + assert_eq!(json["workspace_kind"].as_str(), Some("projectless")); assert_eq!(json["model"].as_str(), Some("client-supplied")); assert_eq!(json["reasoning_effort"].as_str(), Some("client-supplied")); assert_eq!(json["session_id"].as_str(), Some("session-a")); @@ -619,6 +621,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( assert_eq!(meta["model"].as_str(), Some("gpt-5.4")); assert_eq!(meta["reasoning_effort"].as_str(), Some("high")); assert!(meta.get(WINDOW_ID_KEY).is_none()); + assert_eq!(state.workspace_kind().as_deref(), Some("projectless")); } #[test] From d45cd2624848b185d10ab2f29b40fd581f407ac8 Mon Sep 17 00:00:00 2001 From: joeflorencio-openai Date: Tue, 2 Jun 2026 13:18:59 -0700 Subject: [PATCH 36/59] Switch runtime to cloud config bundle (#24622) ## Summary - Adapts the moved `codex-cloud-config` crate from the legacy cloud requirements endpoint to the new config bundle endpoint. - Switches runtime consumers from `CloudRequirementsLoader` to `CloudConfigBundleLoader` so one shared bundle supplies cloud-delivered config and requirements. - Removes the legacy cloud requirements domain loader path. ## Details This intentionally keeps `codex-cloud-config` monolithic for review lineage: the previous PR establishes the crate move, and this PR shows the behavior change against that moved implementation. A follow-up PR splits the module back into focused files. The new bundle path preserves the important cloud requirements loader semantics where intended: account-scoped signed cache, 30 minute TTL, 5 minute refresh cadence, retry/backoff, auth recovery, and fail-closed startup loading. The cached payload changes from a single requirements TOML string to the backend-delivered bundle, and validation rejects malformed config or requirements fragments before cache write/use. --- codex-rs/Cargo.lock | 2 - codex-rs/app-server-client/src/lib.rs | 14 +- codex-rs/app-server/src/config_manager.rs | 32 +- .../src/config_manager_service_tests.rs | 35 +- codex-rs/app-server/src/in_process.rs | 10 +- codex-rs/app-server/src/lib.rs | 9 +- codex-rs/app-server/src/mcp_refresh.rs | 4 +- .../src/message_processor_tracing_tests.rs | 4 +- codex-rs/app-server/src/request_processors.rs | 4 +- .../request_processors/account_processor.rs | 4 +- .../src/request_processors/config_errors.rs | 10 +- .../thread_processor_tests.rs | 46 +- .../tests/suite/conversation_summary.rs | 4 +- .../app-server/tests/suite/v2/mcp_resource.rs | 4 +- .../tests/suite/v2/remote_thread_store.rs | 4 +- .../app-server/tests/suite/v2/thread_fork.rs | 6 +- .../app-server/tests/suite/v2/thread_read.rs | 8 +- .../tests/suite/v2/thread_resume.rs | 6 +- .../app-server/tests/suite/v2/thread_start.rs | 6 +- .../tests/suite/v2/thread_unarchive.rs | 4 +- codex-rs/backend-client/src/client.rs | 18 - codex-rs/backend-client/src/lib.rs | 1 - codex-rs/backend-client/src/types.rs | 1 - codex-rs/cloud-config/Cargo.toml | 4 +- codex-rs/cloud-config/src/lib.rs | 2160 ++++++++--------- codex-rs/config/src/cloud_config_bundle.rs | 214 ++ .../config/src/cloud_config_bundle_tests.rs | 125 + codex-rs/config/src/cloud_config_layers.rs | 45 +- .../config/src/cloud_config_layers_tests.rs | 21 + codex-rs/config/src/cloud_requirements.rs | 105 - codex-rs/config/src/config_requirements.rs | 45 +- codex-rs/config/src/lib.rs | 14 +- codex-rs/config/src/loader/README.md | 26 +- codex-rs/config/src/loader/macos.rs | 47 +- codex-rs/config/src/loader/mod.rs | 298 +-- codex-rs/config/src/loader/tests.rs | 3 - codex-rs/config/src/state.rs | 7 +- codex-rs/config/src/strict_config.rs | 1 - codex-rs/config/src/test_support.rs | 80 + codex-rs/config/src/test_support_tests.rs | 25 + codex-rs/core-plugins/src/test_support.rs | 2 - .../core/src/config/config_loader_tests.rs | 496 ++-- codex-rs/core/src/config/config_tests.rs | 418 ++-- codex-rs/core/src/config/mod.rs | 14 +- codex-rs/core/src/connectors_tests.rs | 88 +- codex-rs/core/src/network_proxy_loader.rs | 2 - codex-rs/core/src/session/tests.rs | 4 +- codex-rs/core/tests/common/lib.rs | 36 +- codex-rs/core/tests/common/test_codex.rs | 19 +- codex-rs/core/tests/suite/approvals.rs | 6 +- codex-rs/core/tests/suite/hooks.rs | 2 +- codex-rs/core/tests/suite/unified_exec.rs | 2 +- codex-rs/exec/src/lib.rs | 108 +- codex-rs/exec/src/lib_tests.rs | 1 + codex-rs/hooks/src/engine/discovery.rs | 4 - codex-rs/hooks/src/engine/mod_tests.rs | 29 +- codex-rs/tui/src/debug_config.rs | 82 +- codex-rs/tui/src/lib.rs | 172 +- codex-rs/tui/src/onboarding/auth.rs | 4 +- codex-rs/tui/src/session_archive_commands.rs | 10 +- 60 files changed, 2615 insertions(+), 2340 deletions(-) create mode 100644 codex-rs/config/src/cloud_config_bundle.rs create mode 100644 codex-rs/config/src/cloud_config_bundle_tests.rs delete mode 100644 codex-rs/config/src/cloud_requirements.rs create mode 100644 codex-rs/config/src/test_support.rs create mode 100644 codex-rs/config/src/test_support_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 27b66293e1..e7bb18b5f6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2356,7 +2356,6 @@ dependencies = [ "codex-login", "codex-otel", "codex-protocol", - "codex-utils-absolute-path", "hmac 0.12.1", "pretty_assertions", "serde", @@ -2365,7 +2364,6 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "toml 0.9.11+spec-1.1.0", "tracing", ] diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 746f0d8448..c75915629c 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -43,7 +43,7 @@ use codex_app_server_protocol::Result as JsonRpcResult; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::NoopThreadConfigLoader; use codex_config::RemoteThreadConfigLoader; @@ -339,8 +339,8 @@ pub struct InProcessClientStartArgs { pub loader_overrides: LoaderOverrides, /// Whether config API paths should reject unknown config fields. pub strict_config: bool, - /// Preloaded cloud requirements provider. - pub cloud_requirements: CloudRequirementsLoader, + /// Preloaded cloud config bundle provider. + pub cloud_config_bundle: CloudConfigBundleLoader, /// Feedback sink used by app-server/core telemetry and logs. pub feedback: CodexFeedback, /// SQLite tracing layer used to flush recently emitted logs before feedback upload. @@ -406,7 +406,7 @@ impl InProcessClientStartArgs { cli_overrides: self.cli_overrides, loader_overrides: self.loader_overrides, strict_config: self.strict_config, - cloud_requirements: self.cloud_requirements, + cloud_config_bundle: self.cloud_config_bundle, thread_config_loader, feedback: self.feedback, log_db: self.log_db, @@ -1035,7 +1035,7 @@ mod tests { cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), feedback: CodexFeedback::new(), log_db: None, state_db: Some(state_db), @@ -2199,7 +2199,7 @@ mod tests { cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), feedback: CodexFeedback::new(), log_db: None, state_db: None, @@ -2240,7 +2240,7 @@ mod tests { cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), feedback: CodexFeedback::new(), log_db: None, state_db: None, diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index 11633342b3..fae113154b 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -1,6 +1,6 @@ use codex_arg0::Arg0DispatchPaths; -use codex_cloud_config::cloud_requirements_loader; -use codex_config::CloudRequirementsLoader; +use codex_cloud_config::cloud_config_bundle_loader; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLayerStack; use codex_config::LoaderOverrides; use codex_config::ThreadConfigLoader; @@ -31,7 +31,7 @@ pub(crate) struct ConfigManager { runtime_feature_enablement: Arc>>, loader_overrides: LoaderOverrides, strict_config: bool, - cloud_requirements: Arc>, + cloud_config_bundle: Arc>, arg0_paths: Arg0DispatchPaths, thread_config_loader: Arc>>, } @@ -42,7 +42,7 @@ impl ConfigManager { cli_overrides: Vec<(String, TomlValue)>, loader_overrides: LoaderOverrides, strict_config: bool, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, arg0_paths: Arg0DispatchPaths, thread_config_loader: Arc, ) -> Self { @@ -52,7 +52,7 @@ impl ConfigManager { runtime_feature_enablement: Arc::new(RwLock::new(BTreeMap::new())), loader_overrides, strict_config, - cloud_requirements: Arc::new(RwLock::new(cloud_requirements)), + cloud_config_bundle: Arc::new(RwLock::new(cloud_config_bundle)), arg0_paths, thread_config_loader: Arc::new(RwLock::new(thread_config_loader)), } @@ -73,8 +73,8 @@ impl ConfigManager { .unwrap_or_default() } - pub(crate) fn current_cloud_requirements(&self) -> CloudRequirementsLoader { - self.cloud_requirements + pub(crate) fn current_cloud_config_bundle(&self) -> CloudConfigBundleLoader { + self.cloud_config_bundle .read() .map(|guard| guard.clone()) .unwrap_or_default() @@ -90,17 +90,17 @@ impl ConfigManager { Ok(()) } - pub(crate) fn replace_cloud_requirements_loader( + pub(crate) fn replace_cloud_config_bundle_loader( &self, auth_manager: Arc, chatgpt_base_url: String, ) { let loader = - cloud_requirements_loader(auth_manager, chatgpt_base_url, self.codex_home.clone()); - if let Ok(mut guard) = self.cloud_requirements.write() { + cloud_config_bundle_loader(auth_manager, chatgpt_base_url, self.codex_home.clone()); + if let Ok(mut guard) = self.cloud_config_bundle.write() { *guard = loader; } else { - warn!("failed to update cloud requirements loader"); + warn!("failed to update cloud config bundle loader"); } } @@ -245,7 +245,7 @@ impl ConfigManager { .strict_config(self.strict_config) .harness_overrides(typesafe_overrides) .fallback_cwd(fallback_cwd) - .cloud_requirements(self.current_cloud_requirements()) + .cloud_config_bundle(self.current_cloud_config_bundle()) .thread_config_loader(self.current_thread_config_loader()) .build() .await?; @@ -274,8 +274,8 @@ impl ConfigManager { codex_config::ConfigLoadOptions { loader_overrides: self.loader_overrides.clone(), strict_config: self.strict_config, + cloud_config_bundle: self.current_cloud_config_bundle(), }, - self.current_cloud_requirements(), thread_config_loader.as_ref(), ) .await @@ -303,14 +303,14 @@ impl ConfigManager { codex_home: PathBuf, cli_overrides: Vec<(String, TomlValue)>, loader_overrides: LoaderOverrides, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, ) -> Self { Self::new( codex_home, cli_overrides, loader_overrides, /*strict_config*/ false, - cloud_requirements, + cloud_config_bundle, Arg0DispatchPaths::default(), Arc::new(codex_config::NoopThreadConfigLoader), ) @@ -322,7 +322,7 @@ impl ConfigManager { codex_home, Vec::new(), LoaderOverrides::without_managed_config_for_tests(), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ) } } diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index da2437ca17..8deae8b065 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -4,12 +4,11 @@ use codex_app_server_protocol::AppConfig; use codex_app_server_protocol::AppToolApproval; use codex_app_server_protocol::AppsConfig; use codex_app_server_protocol::AskForApproval; -use codex_config::CloudRequirementsLoader; -use codex_config::FeatureRequirementsToml; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; +use codex_config::test_support::CloudConfigBundleFixture; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; -use std::collections::BTreeMap; use tempfile::tempdir; #[test] @@ -356,7 +355,7 @@ async fn read_includes_origins_and_layers() { tmp.path().to_path_buf(), vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let response = service @@ -435,7 +434,7 @@ writable_roots = ["~/code"] tmp.path().to_path_buf(), vec![], loader_overrides, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let response = service @@ -475,7 +474,7 @@ async fn write_value_reports_override() { tmp.path().to_path_buf(), vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let result = service @@ -578,7 +577,7 @@ async fn write_value_defaults_to_selected_user_config_path() { tmp.path().to_path_buf(), vec![], loader_overrides, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); service .write_value(ConfigValueWriteParams { @@ -618,7 +617,7 @@ async fn load_default_config_preserves_selected_user_config_path_after_load_erro tmp.path().to_path_buf(), vec![], loader_overrides, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); service @@ -648,7 +647,7 @@ async fn invalid_user_value_rejected_even_if_overridden_by_managed() { tmp.path().to_path_buf(), vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let error = service @@ -708,14 +707,12 @@ async fn write_value_rejects_feature_requirement_conflict() { tmp.path().to_path_buf(), vec![], LoaderOverrides::without_managed_config_for_tests(), - CloudRequirementsLoader::new(async { - Ok(Some(ConfigRequirementsToml { - feature_requirements: Some(FeatureRequirementsToml { - entries: BTreeMap::from([("personality".to_string(), true)]), - }), - ..Default::default() - })) - }), + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +personality = true +"#, + ), ); let error = service @@ -765,7 +762,7 @@ async fn read_reports_managed_overrides_user_and_session_flags() { tmp.path().to_path_buf(), cli_overrides, LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let response = service @@ -821,7 +818,7 @@ async fn write_value_reports_managed_override() { tmp.path().to_path_buf(), vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let result = service diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index f7072d0fa1..448a389851 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -77,7 +77,7 @@ use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::ThreadConfigLoader; use codex_core::config::Config; @@ -124,8 +124,8 @@ pub struct InProcessStartArgs { pub loader_overrides: LoaderOverrides, /// Whether config API paths should reject unknown config fields. pub strict_config: bool, - /// Preloaded cloud requirements provider. - pub cloud_requirements: CloudRequirementsLoader, + /// Preloaded cloud config bundle provider. + pub cloud_config_bundle: CloudConfigBundleLoader, /// Loader used to fetch typed thread config sources before a thread starts. pub thread_config_loader: Arc, /// Feedback sink used by app-server/core telemetry and logs. @@ -415,7 +415,7 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult { - warn!(error = %err, "Failed to preload config for cloud requirements"); - // TODO(gt): Make cloud requirements preload failures blocking once we can fail-closed. + warn!(error = %err, "Failed to preload config for cloud config bundle"); + // TODO: Decide whether bootstrap config preload failures should block startup. + // If this fails, we cannot install cloud/thread config loaders, so non-strict + // startup may continue without managed cloud config. } }; let mut config_warnings = Vec::new(); diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index 4ddfa34735..0996c409bc 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -103,7 +103,7 @@ mod tests { use crate::extensions::thread_extensions; use async_trait::async_trait; use codex_arg0::Arg0DispatchPaths; - use codex_config::CloudRequirementsLoader; + use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::ThreadConfigContext; use codex_config::ThreadConfigLoadError; @@ -213,7 +213,7 @@ mod tests { Vec::new(), LoaderOverrides::without_managed_config_for_tests(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), Arg0DispatchPaths::default(), loader.clone(), ); diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 94b5a1af27..771b1fd128 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -23,7 +23,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_core::config::Config; use codex_core::config::ConfigBuilder; @@ -240,7 +240,7 @@ async fn build_test_processor( Vec::new(), LoaderOverrides::default(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), Arg0DispatchPaths::default(), Arc::new(codex_config::NoopThreadConfigLoader), ); diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 5d8cb4d794..36008ce0b9 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -266,8 +266,8 @@ use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCre use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; use codex_chatgpt::workspace_settings; -use codex_config::CloudRequirementsLoadError; -use codex_config::CloudRequirementsLoadErrorCode; +use codex_config::CloudConfigBundleLoadError; +use codex_config::CloudConfigBundleLoadErrorCode; use codex_config::ConfigLayerStack; use codex_config::loader::project_trust_key; use codex_config::types::McpServerTransportConfig; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 17d78fb20b..8ebfc037e1 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -586,7 +586,7 @@ impl AccountRequestProcessor { ) .map_err(|err| internal_error(format!("failed to set external auth: {err}")))?; self.auth_manager.reload().await; - self.config_manager.replace_cloud_requirements_loader( + self.config_manager.replace_cloud_config_bundle_loader( self.auth_manager.clone(), self.config.chatgpt_base_url.clone(), ); @@ -645,7 +645,7 @@ impl AccountRequestProcessor { let auth_manager = thread_manager.auth_manager(); auth_manager.reload().await; config_manager - .replace_cloud_requirements_loader(auth_manager.clone(), chatgpt_base_url); + .replace_cloud_config_bundle_loader(auth_manager.clone(), chatgpt_base_url); config_manager .sync_default_client_residency_requirement() .await; diff --git a/codex-rs/app-server/src/request_processors/config_errors.rs b/codex-rs/app-server/src/request_processors/config_errors.rs index 63fe2b3d2c..7e93b63ede 100644 --- a/codex-rs/app-server/src/request_processors/config_errors.rs +++ b/codex-rs/app-server/src/request_processors/config_errors.rs @@ -1,11 +1,11 @@ use super::*; -fn cloud_requirements_load_error(err: &std::io::Error) -> Option<&CloudRequirementsLoadError> { +fn cloud_config_bundle_load_error(err: &std::io::Error) -> Option<&CloudConfigBundleLoadError> { let mut current: Option<&(dyn std::error::Error + 'static)> = err .get_ref() .map(|source| source as &(dyn std::error::Error + 'static)); while let Some(source) = current { - if let Some(cloud_error) = source.downcast_ref::() { + if let Some(cloud_error) = source.downcast_ref::() { return Some(cloud_error); } current = source.source(); @@ -14,16 +14,16 @@ fn cloud_requirements_load_error(err: &std::io::Error) -> Option<&CloudRequireme } pub(super) fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError { - let data = cloud_requirements_load_error(err).map(|cloud_error| { + let data = cloud_config_bundle_load_error(err).map(|cloud_error| { let mut data = serde_json::json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": format!("{:?}", cloud_error.code()), "detail": cloud_error.to_string(), }); if let Some(status_code) = cloud_error.status_code() { data["statusCode"] = serde_json::json!(status_code); } - if cloud_error.code() == CloudRequirementsLoadErrorCode::Auth { + if cloud_error.code() == CloudConfigBundleLoadErrorCode::Auth { data["action"] = serde_json::json!("relogin"); } data diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 8689193ced..6be022aa6f 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -54,7 +54,7 @@ mod thread_processor_behavior_tests { use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ToolRequestUserInputParams; - use codex_config::CloudRequirementsLoader; + use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::SessionThreadConfig; use codex_config::StaticThreadConfigLoader; @@ -508,9 +508,9 @@ mod thread_processor_behavior_tests { } #[test] - fn config_load_error_marks_cloud_requirements_failures_for_relogin() { - let err = std::io::Error::other(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Auth, + fn config_load_error_marks_cloud_config_bundle_failures_for_relogin() { + let err = std::io::Error::other(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, Some(401), "Your authentication session could not be refreshed automatically. Please log out and sign in again.", )); @@ -520,7 +520,7 @@ mod thread_processor_behavior_tests { assert_eq!( error.data, Some(json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": "Auth", "action": "relogin", "statusCode": 401, @@ -535,7 +535,7 @@ mod thread_processor_behavior_tests { } #[test] - fn config_load_error_leaves_non_cloud_requirements_failures_unmarked() { + fn config_load_error_leaves_non_cloud_config_bundle_failures_unmarked() { let err = std::io::Error::other("required MCP servers failed to initialize"); let error = config_load_error(&err); @@ -549,11 +549,11 @@ mod thread_processor_behavior_tests { } #[test] - fn config_load_error_marks_non_auth_cloud_requirements_failures_without_relogin() { - let err = std::io::Error::other(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::RequestFailed, + fn config_load_error_marks_non_auth_cloud_config_bundle_failures_without_relogin() { + let err = std::io::Error::other(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::RequestFailed, /*status_code*/ None, - "Failed to load cloud requirements (workspace-managed policies).", + "Failed to load cloud config bundle (workspace-managed policies).", )); let error = config_load_error(&err); @@ -561,9 +561,29 @@ mod thread_processor_behavior_tests { assert_eq!( error.data, Some(json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": "RequestFailed", - "detail": "Failed to load cloud requirements (workspace-managed policies).", + "detail": "Failed to load cloud config bundle (workspace-managed policies).", + })) + ); + } + + #[test] + fn config_load_error_marks_invalid_cloud_config_bundle_failures_without_relogin() { + let err = std::io::Error::other(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::InvalidBundle, + /*status_code*/ None, + "invalid cloud config bundle: invalid cloud config fragment Base policy (cfg_123)", + )); + + let error = config_load_error(&err); + + assert_eq!( + error.data, + Some(json!({ + "reason": "cloudConfigBundle", + "errorCode": "InvalidBundle", + "detail": "invalid cloud config bundle: invalid cloud config fragment Base policy (cfg_123)", })) ); } @@ -595,7 +615,7 @@ mod thread_processor_behavior_tests { Vec::new(), LoaderOverrides::default(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), Arg0DispatchPaths::default(), Arc::new(StaticThreadConfigLoader::new(vec![ ThreadConfigSource::Session(SessionThreadConfig { diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index d54f5edc51..05e3ad1c77 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -15,7 +15,7 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_core::config::ConfigBuilder; use codex_exec_server::EnvironmentManager; @@ -150,7 +150,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index 37b7a10d9b..d8ecb88996 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -20,7 +20,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::ConfigBuilder; @@ -201,7 +201,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index 158ebb25d1..016e4c3df8 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -34,7 +34,7 @@ use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::UserInput as V2UserInput; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::NoopThreadConfigLoader; use codex_core::config::ConfigBuilder; @@ -76,7 +76,7 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, 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 afbe62b321..afb1ca0ed1 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -510,10 +510,10 @@ async fn thread_fork_with_empty_path_uses_thread_id() -> Result<()> { } #[tokio::test] -async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> { +async fn thread_fork_surfaces_cloud_config_bundle_load_errors() -> Result<()> { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/backend-api/wham/config/requirements")) + .and(path("/backend-api/wham/config/bundle")) .respond_with( ResponseTemplate::new(401) .insert_header("content-type", "text/html") @@ -594,7 +594,7 @@ async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> { assert_eq!( fork_err.error.data, Some(json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": "Auth", "action": "relogin", "statusCode": 401, diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 17c55a74a3..0a99d30ada 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -41,7 +41,7 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_core::ARCHIVED_SESSIONS_SUBDIR; use codex_core::config::ConfigBuilder; @@ -376,7 +376,7 @@ async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result< cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, @@ -442,7 +442,7 @@ async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_pa cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, @@ -528,7 +528,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, 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 3073b382a5..d18a405164 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -3121,10 +3121,10 @@ async fn thread_resume_fails_when_required_mcp_server_fails_to_initialize() -> R } #[tokio::test] -async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> { +async fn thread_resume_surfaces_cloud_config_bundle_load_errors() -> Result<()> { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/backend-api/wham/config/requirements")) + .and(path("/backend-api/wham/config/bundle")) .respond_with( ResponseTemplate::new(401) .insert_header("content-type", "text/html") @@ -3201,7 +3201,7 @@ async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> { assert_eq!( err.error.data, Some(json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": "Auth", "action": "relogin", "statusCode": 401, diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 3c4a0dba5e..7a7ed5522a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -718,10 +718,10 @@ async fn thread_start_emits_mcp_server_status_updated_notifications() -> Result< } #[tokio::test] -async fn thread_start_surfaces_cloud_requirements_load_errors() -> Result<()> { +async fn thread_start_surfaces_cloud_config_bundle_load_errors() -> Result<()> { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/backend-api/wham/config/requirements")) + .and(path("/backend-api/wham/config/bundle")) .respond_with( ResponseTemplate::new(401) .insert_header("content-type", "text/html") @@ -788,7 +788,7 @@ async fn thread_start_surfaces_cloud_requirements_load_errors() -> Result<()> { assert_eq!( err.error.data, Some(json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": "Auth", "action": "relogin", "statusCode": 401, diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 09c17bb79f..84fffd19ba 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -22,7 +22,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_core::config::ConfigBuilder; use codex_core::find_archived_thread_path_by_id_str; @@ -247,7 +247,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 159dba8b84..d9bdc9654e 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -1,6 +1,5 @@ use crate::types::CodeTaskDetailsResponse; use crate::types::ConfigBundleResponse; -use crate::types::ConfigFileResponse; use crate::types::PaginatedListTaskListItem; use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind; use crate::types::RateLimitStatusPayload; @@ -393,23 +392,6 @@ impl Client { self.decode_json::(&url, &ct, &body) } - /// Fetch the managed requirements file from codex-backend. - /// - /// `GET /api/codex/config/requirements` (Codex API style) or - /// `GET /wham/config/requirements` (ChatGPT backend-api style). - pub async fn get_config_requirements_file( - &self, - ) -> std::result::Result { - let url = match self.path_style { - PathStyle::CodexApi => format!("{}/api/codex/config/requirements", self.base_url), - PathStyle::ChatGptApi => format!("{}/wham/config/requirements", self.base_url), - }; - let req = self.http.get(&url).headers(self.headers()); - let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?; - self.decode_json::(&url, &ct, &body) - .map_err(RequestError::from) - } - /// Fetch the selected cloud-managed config bundle from codex-backend. /// /// `GET /api/codex/config/bundle` (Codex API style) or diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 00b5bd3697..250aac3d73 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -7,7 +7,6 @@ pub use client::RequestError; pub use types::CodeTaskDetailsResponse; pub use types::CodeTaskDetailsResponseExt; pub use types::ConfigBundleResponse; -pub use types::ConfigFileResponse; pub use types::DeliveredConfigToml; pub use types::DeliveredRequirementsToml; pub use types::DeliveredTomlFragment; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 38d9c6838f..40daba2ce5 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -1,5 +1,4 @@ pub use codex_backend_openapi_models::models::ConfigBundleResponse; -pub use codex_backend_openapi_models::models::ConfigFileResponse; pub use codex_backend_openapi_models::models::CreditStatusDetails; pub use codex_backend_openapi_models::models::DeliveredConfigToml; pub use codex_backend_openapi_models::models::DeliveredRequirementsToml; diff --git a/codex-rs/cloud-config/Cargo.toml b/codex-rs/cloud-config/Cargo.toml index aeba70a489..b9afaee105 100644 --- a/codex-rs/cloud-config/Cargo.toml +++ b/codex-rs/cloud-config/Cargo.toml @@ -17,14 +17,12 @@ 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 } sha2 = { workspace = true } thiserror = { workspace = true } -tokio = { workspace = true, features = ["fs", "sync", "time"] } -toml = { workspace = true } +tokio = { workspace = true, features = ["fs", "rt", "sync", "time"] } tracing = { workspace = true } [dev-dependencies] diff --git a/codex-rs/cloud-config/src/lib.rs b/codex-rs/cloud-config/src/lib.rs index 0beb91780c..72d2b4d1cf 100644 --- a/codex-rs/cloud-config/src/lib.rs +++ b/codex-rs/cloud-config/src/lib.rs @@ -1,12 +1,7 @@ -//! Cloud-hosted config requirements for Codex. +//! Cloud-hosted configuration data for Codex. //! -//! This crate fetches `requirements.toml` data from the backend as an alternative to loading it -//! from the local filesystem. It only applies to Business (aka Enterprise CBP) or Enterprise ChatGPT -//! customers. -//! -//! Fetching fails closed for eligible ChatGPT Business and Enterprise accounts. When cloud -//! requirements cannot be loaded for those accounts, Codex fails configuration loading rather than -//! continuing without them. +//! This crate owns transport, caching, and refresh behavior for cloud-delivered +//! config data. Parsing and composition remain in `codex-config`. use async_trait::async_trait; use base64::Engine; @@ -15,23 +10,30 @@ use chrono::DateTime; use chrono::Duration as ChronoDuration; use chrono::Utc; use codex_backend_client::Client as BackendClient; -use codex_config::CloudRequirementsLoadError; -use codex_config::CloudRequirementsLoadErrorCode; -use codex_config::CloudRequirementsLoader; -use codex_config::ConfigRequirementsToml; +use codex_backend_client::ConfigBundleResponse; +use codex_backend_client::DeliveredTomlFragment; +use codex_config::AbsolutePathBuf; +use codex_config::CloudConfigBundle; +use codex_config::CloudConfigBundleLayers; +use codex_config::CloudConfigBundleLoadError; +use codex_config::CloudConfigBundleLoadErrorCode; +use codex_config::CloudConfigBundleLoader; +use codex_config::CloudConfigFragment; +use codex_config::CloudConfigTomlBundle; +use codex_config::CloudRequirementsFragment; +use codex_config::CloudRequirementsTomlBundle; +use codex_config::compose_requirements; use codex_config::types::AuthCredentialsStoreMode; use codex_core::util::backoff; 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; @@ -46,24 +48,21 @@ use tokio::time::timeout; const CLOUD_REQUIREMENTS_TIMEOUT: Duration = Duration::from_secs(15); const CLOUD_REQUIREMENTS_MAX_ATTEMPTS: usize = 5; -const CLOUD_REQUIREMENTS_CACHE_FILENAME: &str = "cloud-requirements-cache.json"; +const CLOUD_CONFIG_BUNDLE_CACHE_VERSION: u32 = 1; +const CLOUD_REQUIREMENTS_CACHE_FILENAME: &str = "cloud-config-bundle-cache.json"; const CLOUD_REQUIREMENTS_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); const CLOUD_REQUIREMENTS_CACHE_TTL: Duration = Duration::from_secs(30 * 60); -const CLOUD_REQUIREMENTS_FETCH_ATTEMPT_METRIC: &str = "codex.cloud_requirements.fetch_attempt"; -const CLOUD_REQUIREMENTS_FETCH_FINAL_METRIC: &str = "codex.cloud_requirements.fetch_final"; -const CLOUD_REQUIREMENTS_LOAD_METRIC: &str = "codex.cloud_requirements.load"; +const CLOUD_REQUIREMENTS_FETCH_ATTEMPT_METRIC: &str = "codex.cloud_config_bundle.fetch_attempt"; +const CLOUD_REQUIREMENTS_FETCH_FINAL_METRIC: &str = "codex.cloud_config_bundle.fetch_final"; +const CLOUD_REQUIREMENTS_LOAD_METRIC: &str = "codex.cloud_config_bundle.load"; const CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE: &str = - "Failed to load cloud requirements (workspace-managed policies)."; -const CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE: &str = concat!( - "Cloud requirements (workspace-managed policies) are invalid and could not be parsed. ", - "Please contact your workspace admin." -); + "Failed to load cloud config bundle (workspace-managed policies)."; const CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE: &str = concat!( "Your authentication session could not be refreshed automatically. ", "Please log out and sign in again." ); const CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY: &[u8] = - b"codex-cloud-requirements-cache-v3-064f8542-75b4-494c-a294-97d3ce597271"; + b"codex-cloud-config-bundle-cache-v1-6160ae70-bcfd-4ca8-a99b-40f73b3b072e"; const CLOUD_REQUIREMENTS_CACHE_READ_HMAC_KEYS: &[&[u8]] = &[CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY]; @@ -100,54 +99,50 @@ enum FetchAttemptError { #[derive(Clone, Debug, Eq, Error, PartialEq)] enum CacheLoadStatus { - #[error("Skipping cloud requirements cache read because auth identity is incomplete.")] + #[error("Skipping cloud config bundle cache read because auth identity is incomplete.")] AuthIdentityIncomplete, - #[error("Cloud requirements cache file not found.")] + #[error("Cloud config bundle cache file not found.")] CacheFileNotFound, - #[error("Failed to read cloud requirements cache: {0}.")] + #[error("Failed to read cloud config bundle cache: {0}.")] CacheReadFailed(String), - #[error("Failed to parse cloud requirements cache: {0}.")] + #[error("Failed to parse cloud config bundle cache: {0}.")] CacheParseFailed(String), - #[error("Cloud requirements cache failed signature verification.")] + #[error("Cloud config bundle cache failed signature verification.")] CacheSignatureInvalid, - #[error("Ignoring cloud requirements cache because cached identity is incomplete.")] + #[error("Ignoring cloud config bundle cache because cached identity is incomplete.")] CacheIdentityIncomplete, - #[error("Ignoring cloud requirements cache for different auth identity.")] + #[error("Ignoring cloud config bundle cache for different auth identity.")] CacheIdentityMismatch, - #[error("Cloud requirements cache expired.")] + #[error("Ignoring cloud config bundle cache with unsupported version {0}.")] + CacheVersionUnsupported(u32), + #[error("Cloud config bundle cache expired.")] CacheExpired, + #[error("Ignoring cloud config bundle cache because the cached bundle is invalid.")] + CacheInvalidBundle, } #[derive(Debug, Error)] enum CloudRequirementsError { - #[error("failed to write cloud requirements cache")] + #[error("failed to write cloud config bundle cache")] CacheWrite, } -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] struct CloudRequirementsCacheFile { signed_payload: CloudRequirementsCacheSignedPayload, signature: String, } -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] struct CloudRequirementsCacheSignedPayload { + version: u32, cached_at: DateTime, expires_at: DateTime, chatgpt_user_id: Option, account_id: Option, - contents: Option, + bundle: CloudConfigBundle, } -impl CloudRequirementsCacheSignedPayload { - 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 { let mut mac = HmacSha256::new_from_slice(CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY).ok()?; mac.update(payload_bytes); @@ -191,19 +186,28 @@ fn cloud_requirements_eligible_auth(auth: &CodexAuth) -> bool { && (plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise)) } +fn optional_bundle(bundle: CloudConfigBundle) -> Option { + if bundle.is_empty() { + None + } else { + Some(bundle) + } +} + fn cache_payload_bytes(payload: &CloudRequirementsCacheSignedPayload) -> Option> { serde_json::to_vec(&payload).ok() } +/// Retrieves one cloud config bundle from the backend. +/// +/// Implementations should return the backend-selected bundle exactly as delivered and leave +/// validation, caching, and config/requirements parsing decisions to the service layer. #[async_trait] trait RequirementsFetcher: Send + Sync { - /// Returns `Ok(None)` when there are no cloud requirements for the account. - /// - /// Returning `Err` indicates cloud requirements could not be fetched. async fn fetch_requirements( &self, auth: &CodexAuth, - ) -> Result, FetchAttemptError>; + ) -> Result; } struct BackendRequirementsFetcher { @@ -221,20 +225,22 @@ impl RequirementsFetcher for BackendRequirementsFetcher { async fn fetch_requirements( &self, auth: &CodexAuth, - ) -> Result, FetchAttemptError> { + ) -> Result { let client = BackendClient::from_auth(self.base_url.clone(), auth) .inspect_err(|err| { tracing::warn!( error = %err, - "Failed to construct backend client for cloud requirements" + "Failed to construct backend client for cloud config bundle" ); }) .map_err(|_| FetchAttemptError::Retryable(RetryableFailureKind::BackendClientInit))?; let response = client - .get_config_requirements_file() + .get_config_bundle() .await - .inspect_err(|err| tracing::warn!(error = %err, "Failed to fetch cloud requirements")) + .inspect_err(|err| { + tracing::warn!(error = %err, "Failed to fetch cloud config bundle"); + }) .map_err(|err| { let status_code = err.status().map(|status| status.as_u16()); if err.is_unauthorized() { @@ -247,14 +253,55 @@ impl RequirementsFetcher for BackendRequirementsFetcher { } })?; - let Some(contents) = response.contents else { - tracing::info!( - "Cloud requirements response missing contents; treating as no requirements" - ); - return Ok(None); - }; + Ok(bundle_from_response(response)) + } +} - Ok(Some(contents)) +fn bundle_from_response(response: ConfigBundleResponse) -> CloudConfigBundle { + let config_toml = response + .config_toml + .flatten() + .map(|config_toml| *config_toml) + .and_then(|config_toml| config_toml.enterprise_managed.flatten()) + .unwrap_or_default() + .into_iter() + .map(config_fragment_from_delivered) + .collect(); + let requirements_toml = response + .requirements_toml + .flatten() + .map(|requirements_toml| *requirements_toml) + .and_then(|requirements_toml| requirements_toml.enterprise_managed.flatten()) + .unwrap_or_default() + .into_iter() + .map(requirements_fragment_from_delivered) + .collect(); + + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: config_toml, + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: requirements_toml, + }, + } +} + +fn config_fragment_from_delivered(fragment: DeliveredTomlFragment) -> CloudConfigFragment { + CloudConfigFragment { + id: fragment.id, + name: fragment.name, + contents: fragment.contents, + } +} + +fn requirements_fragment_from_delivered( + fragment: DeliveredTomlFragment, +) -> CloudRequirementsFragment { + CloudRequirementsFragment { + id: fragment.id, + name: fragment.name, + contents: fragment.contents, } } @@ -262,8 +309,8 @@ impl RequirementsFetcher for BackendRequirementsFetcher { struct CloudRequirementsService { auth_manager: Arc, fetcher: Arc, - requirements_base_dir: PathBuf, - cache_path: PathBuf, + cache_path: AbsolutePathBuf, + codex_home: AbsolutePathBuf, timeout: Duration, } @@ -274,90 +321,102 @@ impl CloudRequirementsService { codex_home: PathBuf, timeout: Duration, ) -> Self { + let codex_home = AbsolutePathBuf::resolve_path_against_base(codex_home, "/"); + let cache_path = codex_home.join(CLOUD_REQUIREMENTS_CACHE_FILENAME); Self { auth_manager, fetcher, - requirements_base_dir: codex_home.clone(), - cache_path: codex_home.join(CLOUD_REQUIREMENTS_CACHE_FILENAME), + cache_path, + codex_home, timeout, } } async fn fetch_with_timeout( &self, - ) -> Result, CloudRequirementsLoadError> { + ) -> Result, CloudConfigBundleLoadError> { let _timer = - codex_otel::start_global_timer("codex.cloud_requirements.fetch.duration_ms", &[]); + codex_otel::start_global_timer("codex.cloud_config_bundle.fetch.duration_ms", &[]); let started_at = Instant::now(); - let fetch_result = timeout(self.timeout, self.fetch()) + let load_result = timeout(self.timeout, self.fetch()) .await .inspect_err(|_| { let message = format!( - "Timed out waiting for cloud requirements after {}s", + "Timed out waiting for cloud config bundle after {}s", self.timeout.as_secs() ); tracing::error!("{message}"); - emit_load_metric("startup", "error"); + emit_load_metric("startup", "error", /*bundle*/ None); }) .map_err(|_| { - CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Timeout, + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Timeout, /*status_code*/ None, format!( - "timed out waiting for cloud requirements after {}s", + "timed out waiting for cloud config bundle after {}s", self.timeout.as_secs() ), ) })?; - let result = match fetch_result { + let result = match load_result { Ok(result) => result, Err(err) => { - emit_load_metric("startup", "error"); + emit_load_metric("startup", "error", /*bundle*/ None); return Err(err); } }; match result.as_ref() { - Some(requirements) => { + Some(bundle) => { tracing::info!( elapsed_ms = started_at.elapsed().as_millis(), - requirements = ?requirements, - "Cloud requirements load completed" + config_fragments = bundle.config_toml.enterprise_managed.len(), + requirements_fragments = bundle.requirements_toml.enterprise_managed.len(), + "Cloud config bundle load completed" ); - emit_load_metric("startup", "success"); + emit_load_metric("startup", "success", Some(bundle)); } None => { tracing::info!( elapsed_ms = started_at.elapsed().as_millis(), - "Cloud requirements load completed (none)" + "Cloud config bundle load completed (none)" ); - emit_load_metric("startup", "success"); + emit_load_metric("startup", "success", /*bundle*/ None); } } Ok(result) } - async fn fetch(&self) -> Result, CloudRequirementsLoadError> { + async fn fetch(&self) -> Result, CloudConfigBundleLoadError> { let Some(auth) = self.auth_manager.auth().await else { return Ok(None); }; if !cloud_requirements_eligible_auth(&auth) { return Ok(None); } - let (chatgpt_user_id, account_id) = auth_identity(&auth); + let (chatgpt_user_id, account_id) = auth_identity(&auth); match self .load_cache(chatgpt_user_id.as_deref(), account_id.as_deref()) .await { Ok(signed_payload) => { - tracing::info!( - path = %self.cache_path.display(), - "Using cached cloud requirements" - ); - return Ok(signed_payload.requirements(&self.requirements_base_dir)); + if let Err(err) = validate_bundle(&signed_payload.bundle, &self.codex_home) { + tracing::warn!( + path = %self.cache_path.display(), + error = %err, + "Ignoring invalid cached cloud config bundle" + ); + self.log_cache_load_status(&CacheLoadStatus::CacheInvalidBundle); + } else { + tracing::info!( + path = %self.cache_path.display(), + "Using cached cloud config bundle" + ); + return Ok(optional_bundle(signed_payload.bundle)); + } } Err(cache_load_status) => { self.log_cache_load_status(&cache_load_status); @@ -371,18 +430,18 @@ impl CloudRequirementsService { &self, mut auth: CodexAuth, trigger: &'static str, - ) -> Result, CloudRequirementsLoadError> { + ) -> Result, CloudConfigBundleLoadError> { let mut attempt = 1; let mut last_status_code: Option = None; let mut auth_recovery = self.auth_manager.unauthorized_recovery(); while attempt <= CLOUD_REQUIREMENTS_MAX_ATTEMPTS { - let contents = match self.fetcher.fetch_requirements(&auth).await { - Ok(contents) => { + let bundle = match self.fetcher.fetch_requirements(&auth).await { + Ok(bundle) => { emit_fetch_attempt_metric( trigger, attempt, "success", /*status_code*/ None, ); - contents + bundle } Err(FetchAttemptError::Retryable(status)) => { let status_code = status.status_code(); @@ -393,7 +452,7 @@ impl CloudRequirementsService { status = ?status, attempt, max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - "Failed to fetch cloud requirements; retrying" + "Failed to fetch cloud config bundle; retrying" ); sleep(backoff(attempt as u64)).await; } @@ -410,13 +469,13 @@ impl CloudRequirementsService { tracing::warn!( attempt, max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - "Cloud requirements request was unauthorized; attempting auth recovery" + "Cloud config bundle request was unauthorized; attempting auth recovery" ); match auth_recovery.next().await { Ok(_) => { let Some(refreshed_auth) = self.auth_manager.auth().await else { tracing::error!( - "Auth recovery succeeded but no auth is available for cloud requirements" + "Auth recovery succeeded but no auth is available for cloud config bundle" ); emit_fetch_final_metric( trigger, @@ -424,9 +483,10 @@ impl CloudRequirementsService { "auth_recovery_missing_auth", attempt, status_code, + /*bundle*/ None, ); - return Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Auth, + return Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, status_code, CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, )); @@ -437,7 +497,7 @@ impl CloudRequirementsService { Err(RefreshTokenError::Permanent(failed)) => { tracing::warn!( error = %failed, - "Failed to recover from unauthorized cloud requirements request" + "Failed to recover from unauthorized cloud config bundle request" ); emit_fetch_final_metric( trigger, @@ -445,9 +505,10 @@ impl CloudRequirementsService { "auth_recovery_unrecoverable", attempt, status_code, + /*bundle*/ None, ); - return Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Auth, + return Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, status_code, failed.message, )); @@ -458,7 +519,7 @@ impl CloudRequirementsService { error = %recovery_err, attempt, max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - "Failed to recover from unauthorized cloud requirements request; retrying" + "Failed to recover from unauthorized cloud config bundle request; retrying" ); sleep(backoff(attempt as u64)).await; } @@ -470,7 +531,7 @@ impl CloudRequirementsService { tracing::warn!( error = %message, - "Cloud requirements request was unauthorized and no auth recovery is available" + "Cloud config bundle request was unauthorized and no auth recovery is available" ); emit_fetch_final_metric( trigger, @@ -478,48 +539,48 @@ impl CloudRequirementsService { "auth_recovery_unavailable", attempt, status_code, + /*bundle*/ None, ); - return Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Auth, + return Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, status_code, CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, )); } }; - let requirements = match contents.as_deref() { - 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, - }; + if let Err(err) = validate_bundle(&bundle, &self.codex_home) { + emit_fetch_final_metric( + trigger, + "error", + "invalid_bundle", + attempt, + /*status_code*/ None, + /*bundle*/ None, + ); + return Err(err); + } let (chatgpt_user_id, account_id) = auth_identity(&auth); - if let Err(err) = self.save_cache(chatgpt_user_id, account_id, contents).await { - tracing::warn!(error = %err, "Failed to write cloud requirements cache"); + if let Err(err) = self + .save_cache(chatgpt_user_id, account_id, bundle.clone()) + .await + { + tracing::warn!( + error = %err, + "Failed to write cloud config bundle cache" + ); } emit_fetch_final_metric( - trigger, "success", "none", attempt, /*status_code*/ None, + trigger, + "success", + "none", + attempt, + /*status_code*/ None, + Some(&bundle), ); - return Ok(requirements); + return Ok(optional_bundle(bundle)); } emit_fetch_final_metric( @@ -528,13 +589,14 @@ impl CloudRequirementsService { "request_retry_exhausted", CLOUD_REQUIREMENTS_MAX_ATTEMPTS, last_status_code, + /*bundle*/ None, ); tracing::error!( path = %self.cache_path.display(), "{CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE}" ); - Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::RequestFailed, + Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::RequestFailed, last_status_code, CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE, )) @@ -548,9 +610,9 @@ impl CloudRequirementsService { Ok(false) => break, Err(_) => { tracing::error!( - "Timed out refreshing cloud requirements cache from remote; keeping existing cache" + "Timed out refreshing cloud config bundle cache from remote; keeping existing cache" ); - emit_load_metric("refresh", "error"); + emit_load_metric("refresh", "error", /*bundle*/ None); } } } @@ -565,14 +627,14 @@ impl CloudRequirementsService { } match self.fetch_with_retries(auth, "refresh").await { - Ok(_) => emit_load_metric("refresh", "success"), + Ok(bundle) => emit_load_metric("refresh", "success", bundle.as_ref()), Err(err) => { tracing::error!( path = %self.cache_path.display(), error = %err, - "Failed to refresh cloud requirements cache from remote" + "Failed to refresh cloud config bundle cache from remote" ); - emit_load_metric("refresh", "error"); + emit_load_metric("refresh", "error", /*bundle*/ None); } } true @@ -614,6 +676,11 @@ impl CloudRequirementsService { if !verify_cache_signature(&payload_bytes, &cache_file.signature) { return Err(CacheLoadStatus::CacheSignatureInvalid); } + if cache_file.signed_payload.version != CLOUD_CONFIG_BUNDLE_CACHE_VERSION { + return Err(CacheLoadStatus::CacheVersionUnsupported( + cache_file.signed_payload.version, + )); + } let (Some(cached_chatgpt_user_id), Some(cached_account_id)) = ( cache_file.signed_payload.chatgpt_user_id.as_deref(), @@ -656,7 +723,7 @@ impl CloudRequirementsService { &self, chatgpt_user_id: Option, account_id: Option, - contents: Option, + bundle: CloudConfigBundle, ) -> Result<(), CloudRequirementsError> { let now = Utc::now(); let expires_at = now @@ -666,11 +733,12 @@ impl CloudRequirementsService { ) .ok_or(CloudRequirementsError::CacheWrite)?; let signed_payload = CloudRequirementsCacheSignedPayload { + version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, cached_at: now, expires_at, chatgpt_user_id, account_id, - contents, + bundle, }; let payload_bytes = cache_payload_bytes(&signed_payload).ok_or(CloudRequirementsError::CacheWrite)?; @@ -694,11 +762,11 @@ impl CloudRequirementsService { } } -pub fn cloud_requirements_loader( +pub fn cloud_config_bundle_loader( auth_manager: Arc, chatgpt_base_url: String, codex_home: PathBuf, -) -> CloudRequirementsLoader { +) -> CloudConfigBundleLoader { let service = CloudRequirementsService::new( auth_manager, Arc::new(BackendRequirementsFetcher::new(chatgpt_base_url)), @@ -710,30 +778,30 @@ pub fn cloud_requirements_loader( let refresh_task = tokio::spawn(async move { refresh_service.refresh_cache_in_background().await }); let mut refresher_guard = refresher_task_slot().lock().unwrap_or_else(|err| { - tracing::warn!("cloud requirements refresher task slot was poisoned"); + tracing::warn!("cloud config bundle refresher task slot was poisoned"); err.into_inner() }); if let Some(existing_task) = refresher_guard.replace(refresh_task) { existing_task.abort(); } - CloudRequirementsLoader::new(async move { + CloudConfigBundleLoader::new(async move { task.await.map_err(|err| { - tracing::error!(error = %err, "Cloud requirements task failed"); - CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Internal, + tracing::error!(error = %err, "Cloud config bundle task failed"); + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Internal, /*status_code*/ None, - format!("cloud requirements load failed: {err}"), + format!("cloud config bundle load failed: {err}"), ) })? }) } -pub async fn cloud_requirements_loader_for_storage( +pub async fn cloud_config_bundle_loader_for_storage( codex_home: PathBuf, enable_codex_api_key_env: bool, credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: String, -) -> CloudRequirementsLoader { +) -> CloudConfigBundleLoader { let auth_manager = AuthManager::shared( codex_home.clone(), enable_codex_api_key_env, @@ -741,31 +809,35 @@ pub async fn cloud_requirements_loader_for_storage( Some(chatgpt_base_url.clone()), ) .await; - cloud_requirements_loader(auth_manager, chatgpt_base_url, codex_home) + cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home) } -fn parse_cloud_requirements( - contents: &str, - requirements_base_dir: &Path, -) -> Result, toml::de::Error> { - if contents.trim().is_empty() { - return Ok(None); - } +fn validate_bundle( + bundle: &CloudConfigBundle, + base_dir: &AbsolutePathBuf, +) -> Result<(), CloudConfigBundleLoadError> { + let bundle_layers = + CloudConfigBundleLayers::from_bundle(bundle.clone(), base_dir).map_err(|err| { + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::InvalidBundle, + /*status_code*/ None, + format!("invalid cloud config bundle: {err}"), + ) + })?; + let CloudConfigBundleLayers { + enterprise_managed_config: _, + enterprise_managed_requirements, + } = bundle_layers; - let _guard = AbsolutePathBufGuard::new(requirements_base_dir); - let requirements: ConfigRequirementsToml = toml::from_str(contents)?; - if requirements.is_empty() { - Ok(None) - } else { - Ok(Some(requirements)) - } -} + compose_requirements(enterprise_managed_requirements).map_err(|err| { + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::InvalidBundle, + /*status_code*/ None, + format!("invalid cloud config bundle: {err}"), + ) + })?; -fn format_cloud_requirements_parse_failed_message( - _contents: &str, - err: &toml::de::Error, -) -> String { - format!("{CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE}\n\nDetails:\n{err}") + Ok(()) } fn emit_fetch_attempt_metric( @@ -793,6 +865,7 @@ fn emit_fetch_final_metric( reason: &str, attempt_count: usize, status_code: Option, + bundle: Option<&CloudConfigBundle>, ) { let attempt_count_tag = attempt_count.to_string(); let status_code_tag = status_code_tag(status_code); @@ -804,20 +877,43 @@ fn emit_fetch_final_metric( ("reason", reason.to_string()), ("attempt_count", attempt_count_tag), ("status_code", status_code_tag), + ("bundle_shape", bundle_shape_tag(bundle)), ], ); } -fn emit_load_metric(trigger: &str, outcome: &str) { +fn emit_load_metric(trigger: &str, outcome: &str, bundle: Option<&CloudConfigBundle>) { emit_metric( CLOUD_REQUIREMENTS_LOAD_METRIC, vec![ ("trigger", trigger.to_string()), ("outcome", outcome.to_string()), + ("bundle_shape", bundle_shape_tag(bundle)), ], ); } +fn bundle_shape_tag(bundle: Option<&CloudConfigBundle>) -> String { + let Some(bundle) = bundle else { + return "none".to_string(); + }; + + let mut sources = Vec::new(); + if !bundle.config_toml.enterprise_managed.is_empty() { + sources.push("enterprise_config"); + } + if !bundle.requirements_toml.enterprise_managed.is_empty() { + sources.push("enterprise_requirements"); + } + + if sources.is_empty() { + "empty".to_string() + } else { + sources.sort_unstable(); + sources.join(",") + } +} + fn status_code_tag(status_code: Option) -> String { status_code .map(|status_code| status_code.to_string()) @@ -839,43 +935,22 @@ mod tests { use super::*; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use codex_config::AppToolApproval; + use codex_backend_client::ConfigBundleResponse; + use codex_backend_client::DeliveredTomlFragment; + use codex_config::CloudConfigFragment; + use codex_config::CloudConfigTomlBundle; + use codex_config::CloudRequirementsFragment; + use codex_config::CloudRequirementsTomlBundle; use codex_config::types::AuthCredentialsStoreMode; - use codex_login::auth::AgentIdentityAuth; - use codex_login::auth::AgentIdentityAuthRecord; - use codex_protocol::protocol::AskForApproval; use pretty_assertions::assert_eq; use serde_json::json; - use std::collections::BTreeMap; use std::collections::VecDeque; - use std::ffi::OsString; use std::future::pending; - use std::io::Read; - use std::io::Write; - use std::net::TcpListener; use std::path::Path; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; - use std::thread; - use tempfile::TempDir; use tempfile::tempdir; - struct EnvVarGuard { - key: &'static str, - original: Option, - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - unsafe { - match &self.original { - Some(value) => std::env::set_var(self.key, value), - None => std::env::remove_var(self.key), - } - } - } - } - fn write_auth_json(codex_home: &Path, value: serde_json::Value) -> std::io::Result<()> { std::fs::write(codex_home.join("auth.json"), serde_json::to_string(&value)?)?; Ok(()) @@ -928,6 +1003,11 @@ mod tests { ) } + async fn auth_manager_with_plan(plan_type: &str) -> Arc { + auth_manager_with_plan_and_identity(plan_type, Some("user-12345"), Some("account-12345")) + .await + } + fn chatgpt_auth_json( plan_type: &str, chatgpt_user_id: Option<&str>, @@ -1004,55 +1084,44 @@ mod tests { auth_json } - struct ManagedAuthContext { - _home: TempDir, - manager: Arc, - } - - async fn managed_auth_context( - plan_type: &str, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - access_token: &str, - refresh_token: &str, - ) -> ManagedAuthContext { - let home = tempdir().expect("tempdir"); - write_auth_json( - home.path(), - chatgpt_auth_json( - plan_type, - chatgpt_user_id, - account_id, - access_token, - refresh_token, - ), - ) - .expect("write auth"); - ManagedAuthContext { - manager: Arc::new( - AuthManager::new( - home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ), - _home: home, + fn test_bundle() -> CloudConfigBundle { + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![test_config_fragment()], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![test_requirements_fragment()], + }, } } - async fn auth_manager_with_plan(plan_type: &str) -> Arc { - auth_manager_with_plan_and_identity(plan_type, Some("user-12345"), Some("account-12345")) - .await + fn test_config_fragment() -> CloudConfigFragment { + CloudConfigFragment { + id: "cfg_1".to_string(), + name: "Base config".to_string(), + contents: "model = \"gpt-5\"".to_string(), + } } - fn parse_for_fetch(contents: Option<&str>) -> Option { - contents.and_then(|contents| { - parse_cloud_requirements(contents, &std::env::temp_dir()) - .ok() - .flatten() - }) + fn test_requirements_fragment() -> CloudRequirementsFragment { + CloudRequirementsFragment { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + } + } + + fn invalid_config_bundle() -> CloudConfigBundle { + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![CloudConfigFragment { + id: "cfg_invalid".to_string(), + name: "Invalid config".to_string(), + contents: "model = [".to_string(), + }], + }, + requirements_toml: CloudRequirementsTomlBundle::default(), + } } fn request_error() -> FetchAttemptError { @@ -1060,39 +1129,50 @@ mod tests { } struct StaticFetcher { - contents: Option, + bundle: CloudConfigBundle, + request_count: AtomicUsize, } - #[async_trait::async_trait] + impl StaticFetcher { + fn new(bundle: CloudConfigBundle) -> Self { + Self { + bundle, + request_count: AtomicUsize::new(0), + } + } + } + + #[async_trait] impl RequirementsFetcher for StaticFetcher { async fn fetch_requirements( &self, _auth: &CodexAuth, - ) -> Result, FetchAttemptError> { - Ok(self.contents.clone()) + ) -> Result { + self.request_count.fetch_add(1, Ordering::SeqCst); + Ok(self.bundle.clone()) } } struct PendingFetcher; - #[async_trait::async_trait] + #[async_trait] impl RequirementsFetcher for PendingFetcher { async fn fetch_requirements( &self, _auth: &CodexAuth, - ) -> Result, FetchAttemptError> { + ) -> Result { pending::<()>().await; - Ok(None) + Ok(CloudConfigBundle::default()) } } struct SequenceFetcher { - responses: tokio::sync::Mutex, FetchAttemptError>>>, + responses: tokio::sync::Mutex>>, request_count: AtomicUsize, } impl SequenceFetcher { - fn new(responses: Vec, FetchAttemptError>>) -> Self { + fn new(responses: Vec>) -> Self { Self { responses: tokio::sync::Mutex::new(VecDeque::from(responses)), request_count: AtomicUsize::new(0), @@ -1100,40 +1180,42 @@ mod tests { } } - #[async_trait::async_trait] + #[async_trait] impl RequirementsFetcher for SequenceFetcher { async fn fetch_requirements( &self, _auth: &CodexAuth, - ) -> Result, FetchAttemptError> { + ) -> Result { self.request_count.fetch_add(1, Ordering::SeqCst); let mut responses = self.responses.lock().await; - responses.pop_front().unwrap_or(Ok(None)) + responses + .pop_front() + .unwrap_or_else(|| Ok(CloudConfigBundle::default())) } } struct TokenFetcher { expected_token: String, - contents: String, + bundle: CloudConfigBundle, request_count: AtomicUsize, } - #[async_trait::async_trait] + #[async_trait] impl RequirementsFetcher for TokenFetcher { async fn fetch_requirements( &self, auth: &CodexAuth, - ) -> Result, FetchAttemptError> { + ) -> Result { self.request_count.fetch_add(1, Ordering::SeqCst); if matches!( auth.get_token().as_deref(), Ok(token) if token == self.expected_token.as_str() ) { - Ok(Some(self.contents.clone())) + Ok(self.bundle.clone()) } else { Err(FetchAttemptError::Unauthorized { status_code: Some(401), - message: "GET /config/requirements failed: 401".to_string(), + message: "GET /config/bundle failed: 401".to_string(), }) } } @@ -1144,12 +1226,12 @@ mod tests { request_count: AtomicUsize, } - #[async_trait::async_trait] + #[async_trait] impl RequirementsFetcher for UnauthorizedFetcher { async fn fetch_requirements( &self, _auth: &CodexAuth, - ) -> Result, FetchAttemptError> { + ) -> Result { self.request_count.fetch_add(1, Ordering::SeqCst); Err(FetchAttemptError::Unauthorized { status_code: Some(401), @@ -1158,389 +1240,293 @@ mod tests { } } + #[test] + fn bundle_shape_tag_describes_sorted_enterprise_sources() { + assert_eq!(bundle_shape_tag(/*bundle*/ None), "none"); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle::default())), + "empty" + ); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![test_config_fragment()], + }, + requirements_toml: CloudRequirementsTomlBundle::default(), + })), + "enterprise_config" + ); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![test_requirements_fragment()], + }, + })), + "enterprise_requirements" + ); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![test_config_fragment()], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![test_requirements_fragment()], + }, + })), + "enterprise_config,enterprise_requirements" + ); + } + #[tokio::test] async fn fetch_cloud_requirements_skips_non_chatgpt_auth() { - let auth_manager = auth_manager_with_api_key().await; + let fetcher = Arc::new(StaticFetcher::new(test_bundle())); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - auth_manager, - Arc::new(StaticFetcher { contents: None }), + auth_manager_with_api_key().await, + fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); - let result = service.fetch().await; - assert_eq!(result, Ok(None)); + + assert_eq!(service.fetch().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); } #[tokio::test] async fn fetch_cloud_requirements_skips_non_business_or_enterprise_plan() { + let fetcher = Arc::new(StaticFetcher::new(test_bundle())); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager_with_plan("pro").await, - Arc::new(StaticFetcher { contents: None }), + fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); - let result = service.fetch().await; - assert_eq!(result, Ok(None)); + + assert_eq!(service.fetch().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); } #[tokio::test] async fn fetch_cloud_requirements_skips_team_like_usage_based_plan() { + let fetcher = Arc::new(StaticFetcher::new(test_bundle())); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager_with_plan("self_serve_business_usage_based").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), + fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); + assert_eq!(service.fetch().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); } #[tokio::test] async fn fetch_cloud_requirements_allows_business_plan() { + let bundle = test_bundle(); let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(StaticFetcher::new(bundle.clone())); let service = CloudRequirementsService::new( auth_manager_with_plan("business").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), + fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) + + assert_eq!(service.fetch().await, Ok(Some(bundle.clone()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert!( + codex_home + .path() + .join(CLOUD_REQUIREMENTS_CACHE_FILENAME) + .exists() ); } #[tokio::test] - async fn cloud_requirements_eligible_auth_allows_agent_identity_business_plan() { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind task registration server"); - let addr = listener - .local_addr() - .expect("task registration server addr"); - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept task registration request"); - let mut request = [0; 4096]; - let _ = stream - .read(&mut request) - .expect("read task registration request"); - let body = r#"{"task_id":"task-123"}"#; - write!( - stream, - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", - body.len(), - body - ) - .expect("write task registration response"); - }); - let record = AgentIdentityAuthRecord { - agent_runtime_id: "agent-runtime-123".to_string(), - agent_private_key: "MC4CAQAwBQYDK2VwBCIEIDQg14jybCLydjHQwXeBzsDM7oB6BSAenodx6oCovQ/D" - .to_string(), - account_id: "account-12345".to_string(), - chatgpt_user_id: "user-12345".to_string(), - email: "user@example.com".to_string(), - plan_type: PlanType::Business, - chatgpt_account_is_fedramp: false, - }; - let authapi_base_url = format!("http://{addr}/backend-api"); - let original_authapi_base_url = std::env::var_os("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL"); - unsafe { - std::env::set_var("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &authapi_base_url); - } - let _authapi_guard = EnvVarGuard { - key: "CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", - original: original_authapi_base_url, - }; - let auth = AgentIdentityAuth::load(record) - .await - .map(CodexAuth::AgentIdentity) - .expect("agent identity auth"); - server.join().expect("task registration server joined"); + async fn fetch_requirements_rejects_invalid_remote_bundle_before_cache_write() { + let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(StaticFetcher::new(invalid_config_bundle())); + let service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); - assert!(cloud_requirements_eligible_auth(&auth)); + let err = service + .fetch() + .await + .expect_err("invalid remote bundle should fail closed"); + + assert_eq!(err.code(), CloudConfigBundleLoadErrorCode::InvalidBundle); + assert!(err.to_string().contains("invalid cloud config bundle")); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert!( + !codex_home + .path() + .join(CLOUD_REQUIREMENTS_CACHE_FILENAME) + .exists() + ); + } + + #[tokio::test] + async fn fetch_requirements_ignores_invalid_cache_and_refetches() { + let codex_home = tempdir().expect("tempdir"); + let replacement_bundle = test_bundle(); + let fetcher = Arc::new(StaticFetcher::new(replacement_bundle.clone())); + let service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + service + .save_cache( + Some("user-12345".to_string()), + Some("account-12345".to_string()), + invalid_config_bundle(), + ) + .await + .expect("write invalid cache"); + + assert_eq!(service.fetch().await, Ok(Some(replacement_bundle.clone()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert_eq!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await + .expect("load refreshed cache") + .bundle, + replacement_bundle + ); } #[tokio::test] async fn fetch_cloud_requirements_allows_business_like_usage_based_plan() { + let fetcher = Arc::new(StaticFetcher::new(test_bundle())); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager_with_plan("enterprise_cbp_usage_based").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), + fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); + + assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); } #[tokio::test] async fn fetch_cloud_requirements_allows_hc_plan_as_enterprise() { + let fetcher = Arc::new(StaticFetcher::new(test_bundle())); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager_with_plan("hc").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), + fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); + + assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); } #[tokio::test] - async fn fetch_cloud_requirements_handles_missing_contents() { - let result = parse_for_fetch(/*contents*/ None); - assert!(result.is_none()); - } - - #[tokio::test] - async fn fetch_cloud_requirements_handles_empty_contents() { - let result = parse_for_fetch(Some(" ")); - assert!(result.is_none()); - } - - #[tokio::test] - async fn fetch_cloud_requirements_handles_invalid_toml() { - let result = parse_for_fetch(Some("not = [")); - assert!(result.is_none()); - } - - #[tokio::test] - async fn fetch_cloud_requirements_ignores_empty_requirements() { - let result = parse_for_fetch(Some("# comment")); - assert!(result.is_none()); - } - - #[tokio::test] - async fn fetch_cloud_requirements_parses_valid_toml() { - let result = parse_for_fetch(Some("allowed_approval_policies = [\"never\"]")); - - assert_eq!( - result, - Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - }) - ); - } - - #[tokio::test] - async fn fetch_cloud_requirements_resolves_relative_deny_read_globs_from_codex_home() { + async fn fetch_requirements_empty_response_is_success_and_cached() { let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(StaticFetcher::new(CloudConfigBundle::default())); let service = CloudRequirementsService::new( auth_manager_with_plan("enterprise").await, - Arc::new(StaticFetcher { - contents: Some( - r#" -[permissions.filesystem] -deny_read = ["./sensitive/**/*.txt"] -"# - .to_string(), - ), - }), + fetcher.clone(), 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( - r#" -[apps.connector_5f3c8c41a1e54ad7a76272c89e2554fa] -enabled = false -"#, - )); - - assert_eq!( - result, - Some(ConfigRequirementsToml { - apps: Some(codex_config::AppsRequirementsToml { - apps: BTreeMap::from([( - "connector_5f3c8c41a1e54ad7a76272c89e2554fa".to_string(), - codex_config::AppRequirementToml { - enabled: Some(false), - tools: None, - }, - )]), - }), - ..Default::default() - }) + assert_eq!(service.fetch().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert!( + codex_home + .path() + .join(CLOUD_REQUIREMENTS_CACHE_FILENAME) + .exists() ); } #[tokio::test] - async fn fetch_cloud_requirements_parses_apps_tool_requirements_toml() { - let result = parse_for_fetch(Some( - r#" -[apps.connector_5f3c8c41a1e54ad7a76272c89e2554fa.tools."calendar/list_events"] -approval_mode = "approve" -"#, - )); - - assert_eq!( - result, - Some(ConfigRequirementsToml { - apps: Some(codex_config::AppsRequirementsToml { - apps: BTreeMap::from([( - "connector_5f3c8c41a1e54ad7a76272c89e2554fa".to_string(), - codex_config::AppRequirementToml { - enabled: None, - tools: Some(codex_config::AppToolsRequirementsToml { - tools: BTreeMap::from([( - "calendar/list_events".to_string(), - codex_config::AppToolRequirementToml { - approval_mode: Some(AppToolApproval::Approve), - }, - )]), - }), - }, - )]), - }), - ..Default::default() - }) + async fn fetch_cloud_requirements_uses_cache_when_valid() { + let bundle = test_bundle(); + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + Arc::new(StaticFetcher::new(bundle.clone())), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, ); + let _ = prime_service.fetch().await; + + let fetcher = Arc::new(SequenceFetcher::new(vec![Err(request_error())])); + let service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!(service.fetch().await, Ok(Some(bundle))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); } #[tokio::test] - async fn fetch_cloud_requirements_parses_plugin_mcp_requirements_toml() { - let result = parse_for_fetch(Some( - r#" -[plugins."sample@test".mcp_servers.sample.identity] -command = "sample-mcp" -"#, - )); - - assert_eq!( - result, - Some(ConfigRequirementsToml { - plugins: Some(BTreeMap::from([( - "sample@test".to_string(), - codex_config::PluginRequirementsToml { - mcp_servers: Some(BTreeMap::from([( - "sample".to_string(), - codex_config::McpServerRequirement { - identity: codex_config::McpServerIdentity::Command { - command: "sample-mcp".to_string(), - }, - }, - )])), - }, - )])), - ..Default::default() - }) + async fn fetch_cloud_requirements_ignores_cache_for_different_auth_identity() { + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudRequirementsService::new( + auth_manager_with_plan_and_identity( + "business", + Some("user-12345"), + Some("account-12345"), + ) + .await, + Arc::new(StaticFetcher::new(test_bundle())), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, ); + let _ = prime_service.fetch().await; + + let replacement_bundle = CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Replacement requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }], + }, + }; + let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(replacement_bundle.clone())])); + let service = CloudRequirementsService::new( + auth_manager_with_plan_and_identity( + "business", + Some("user-99999"), + Some("account-12345"), + ) + .await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!(service.fetch().await, Ok(Some(replacement_bundle))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); } #[tokio::test(start_paused = true)] async fn fetch_cloud_requirements_times_out() { - let auth_manager = auth_manager_with_plan("enterprise").await; let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - auth_manager, + auth_manager_with_plan("enterprise").await, Arc::new(PendingFetcher), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, @@ -1548,11 +1534,11 @@ command = "sample-mcp" let handle = tokio::spawn(async move { service.fetch_with_timeout().await }); tokio::time::advance(CLOUD_REQUIREMENTS_TIMEOUT + Duration::from_millis(1)).await; - let result = handle.await.expect("cloud requirements task"); - let err = result.expect_err("cloud requirements timeout should fail closed"); + let result = handle.await.expect("cloud config bundle task"); + let err = result.expect_err("cloud config bundle timeout should fail closed"); assert!( err.to_string() - .contains("timed out waiting for cloud requirements") + .contains("timed out waiting for cloud config bundle") ); } @@ -1560,7 +1546,7 @@ command = "sample-mcp" async fn fetch_cloud_requirements_retries_until_success() { let fetcher = Arc::new(SequenceFetcher::new(vec![ Err(request_error()), - Ok(Some("allowed_approval_policies = [\"never\"]".to_string())), + Ok(test_bundle()), ])); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( @@ -1574,31 +1560,7 @@ command = "sample-mcp" tokio::task::yield_now().await; tokio::time::advance(Duration::from_secs(1)).await; - assert_eq!( - handle.await.expect("cloud requirements task"), - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); + assert_eq!(handle.await.expect("bundle task"), Ok(Some(test_bundle()))); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); } @@ -1641,49 +1603,20 @@ command = "sample-mcp" ), ) .expect("write refreshed auth"); - let auth = ManagedAuthContext { - _home: auth_home, - manager: auth_manager, - }; - let fetcher = Arc::new(TokenFetcher { expected_token: "fresh-access-token".to_string(), - contents: "allowed_approval_policies = [\"never\"]".to_string(), + bundle: test_bundle(), request_count: AtomicUsize::new(0), }); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - Arc::clone(&auth.manager), + auth_manager, fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); + assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); } @@ -1724,77 +1657,57 @@ command = "sample-mcp" ), ) .expect("write refreshed auth"); - let auth = ManagedAuthContext { - _home: auth_home, - manager: auth_manager, - }; - let fetcher = Arc::new(TokenFetcher { expected_token: "fresh-access-token".to_string(), - contents: "allowed_approval_policies = [\"never\"]".to_string(), + bundle: test_bundle(), request_count: AtomicUsize::new(0), }); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - Arc::clone(&auth.manager), + auth_manager, fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); + assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let cache_file: CloudRequirementsCacheFile = - serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) - .expect("parse cache"); - assert_eq!( - cache_file.signed_payload.chatgpt_user_id, - Some("user-99999".to_string()) - ); - assert_eq!( - cache_file.signed_payload.account_id, - Some("account-12345".to_string()) + service + .load_cache(Some("user-99999"), Some("account-12345")) + .await + .expect("load cache") + .bundle, + test_bundle() ); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); } #[tokio::test] async fn fetch_cloud_requirements_surfaces_auth_recovery_message() { - let auth = managed_auth_context( - "enterprise", - Some("user-12345"), - Some("account-12345"), - "stale-access-token", - "test-refresh-token", - ) - .await; + let auth_home = tempdir().expect("tempdir"); write_auth_json( - auth._home.path(), + auth_home.path(), + chatgpt_auth_json( + "enterprise", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + ), + ) + .expect("write auth"); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + write_auth_json( + auth_home.path(), chatgpt_auth_json( "enterprise", Some("user-12345"), @@ -1804,14 +1717,13 @@ command = "sample-mcp" ), ) .expect("write mismatched auth"); - let fetcher = Arc::new(UnauthorizedFetcher { - message: "GET /config/requirements failed: 401".to_string(), + message: "GET /config/bundle failed: 401".to_string(), request_count: AtomicUsize::new(0), }); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - Arc::clone(&auth.manager), + auth_manager, fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, @@ -1820,10 +1732,14 @@ command = "sample-mcp" let err = service .fetch() .await - .expect_err("cloud requirements should surface auth recovery errors"); + .expect_err("cloud config bundle should surface auth recovery errors"); assert_eq!( - err.to_string(), - "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again." + err, + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, + Some(401), + "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.", + ) ); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); } @@ -1856,7 +1772,7 @@ command = "sample-mcp" let fetcher = Arc::new(UnauthorizedFetcher { message: - "GET https://chatgpt.com/backend-api/wham/config/requirements failed: 401; content-type=text/html; body=nope" + "GET https://chatgpt.com/backend-api/wham/config/bundle failed: 401; content-type=text/html; body=nope" .to_string(), request_count: AtomicUsize::new(0), }); @@ -1871,168 +1787,16 @@ command = "sample-mcp" let err = service .fetch() .await - .expect_err("cloud requirements should fail closed"); + .expect_err("cloud config bundle should fail closed"); assert_eq!( - err.to_string(), - CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE - ); - assert_eq!(err.code(), CloudRequirementsLoadErrorCode::Auth); - assert_eq!(err.status_code(), Some(401)); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_parse_error_does_not_retry() { - let fetcher = Arc::new(SequenceFetcher::new(vec![ - Ok(Some("not = [".to_string())), - Ok(Some("allowed_approval_policies = [\"never\"]".to_string())), - ])); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let err = service - .fetch() - .await - .expect_err("parse error should fail closed"); - let err_text = err.to_string(); - assert!(err_text.contains(CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE)); - assert!(err_text.contains("Details:")); - assert!(err_text.contains("not = [")); - assert_eq!(err.code(), CloudRequirementsLoadErrorCode::Parse); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_invalid_enum_value_surfaces_field_name() { - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( - "allowed_approval_policies = [\"definitely-not-valid\"]".to_string(), - ))])); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher, - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let err = service - .fetch() - .await - .expect_err("invalid enum value should fail closed"); - let err_text = err.to_string(); - assert!(err_text.contains(CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE)); - assert!(err_text.contains("allowed_approval_policies")); - assert!(err_text.contains("definitely-not-valid")); - assert!(err_text.contains("unknown variant")); - assert_eq!(err.code(), CloudRequirementsLoadErrorCode::Parse); - } - - #[tokio::test] - async fn fetch_cloud_requirements_uses_cache_when_valid() { - let codex_home = tempdir().expect("tempdir"); - let prime_service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let _ = prime_service.fetch().await; - - let fetcher = Arc::new(SequenceFetcher::new(vec![Err(request_error())])); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn fetch_cloud_requirements_writes_cache_when_identity_is_incomplete() { - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan_and_identity( - "business", - /*chatgpt_user_id*/ None, - Some("account-12345"), + err, + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, + Some(401), + CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, ) - .await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let cache_file: CloudRequirementsCacheFile = - serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) - .expect("parse cache"); - assert_eq!(cache_file.signed_payload.chatgpt_user_id, None); - assert_eq!( - cache_file.signed_payload.account_id, - Some("account-12345".to_string()) ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); } #[tokio::test] @@ -2040,17 +1804,23 @@ command = "sample-mcp" let codex_home = tempdir().expect("tempdir"); let prime_service = CloudRequirementsService::new( auth_manager_with_plan("business").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), + Arc::new(StaticFetcher::new(test_bundle())), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); let _ = prime_service.fetch().await; - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( - "allowed_approval_policies = [\"on-request\"]".to_string(), - ))])); + let replacement_bundle = CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Replacement requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }], + }, + }; + let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(replacement_bundle.clone())])); let service = CloudRequirementsService::new( auth_manager_with_plan_and_identity( "business", @@ -2063,306 +1833,7 @@ command = "sample-mcp" CLOUD_REQUIREMENTS_TIMEOUT, ); - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_ignores_cache_for_different_auth_identity() { - let codex_home = tempdir().expect("tempdir"); - let prime_service = CloudRequirementsService::new( - auth_manager_with_plan_and_identity( - "business", - Some("user-12345"), - Some("account-12345"), - ) - .await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let _ = prime_service.fetch().await; - - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( - "allowed_approval_policies = [\"on-request\"]".to_string(), - ))])); - let service = CloudRequirementsService::new( - auth_manager_with_plan_and_identity( - "business", - Some("user-99999"), - Some("account-12345"), - ) - .await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_ignores_tampered_cache() { - let codex_home = tempdir().expect("tempdir"); - let prime_service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let _ = prime_service.fetch().await; - - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let mut cache_file: CloudRequirementsCacheFile = - serde_json::from_str(&std::fs::read_to_string(&path).expect("read cache")) - .expect("parse cache"); - cache_file.signed_payload.contents = - Some("allowed_approval_policies = [\"on-request\"]".to_string()); - std::fs::write( - &path, - serde_json::to_vec_pretty(&cache_file).expect("serialize cache"), - ) - .expect("write cache"); - - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( - "allowed_approval_policies = [\"never\"]".to_string(), - ))])); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_ignores_expired_cache() { - let codex_home = tempdir().expect("tempdir"); - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let cache_file = CloudRequirementsCacheFile { - signed_payload: CloudRequirementsCacheSignedPayload { - cached_at: Utc::now(), - expires_at: Utc::now() - ChronoDuration::seconds(1), - chatgpt_user_id: Some("user-12345".to_string()), - account_id: Some("account-12345".to_string()), - contents: Some("allowed_approval_policies = [\"on-request\"]".to_string()), - }, - signature: String::new(), - }; - let payload_bytes = cache_payload_bytes(&cache_file.signed_payload).expect("payload"); - let signature = sign_cache_payload(&payload_bytes).expect("sign payload"); - let cache_file = CloudRequirementsCacheFile { - signature, - ..cache_file - }; - std::fs::write( - &path, - serde_json::to_vec_pretty(&cache_file).expect("serialize cache"), - ) - .expect("write cache"); - - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( - "allowed_approval_policies = [\"never\"]".to_string(), - ))])); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_writes_signed_cache() { - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let _ = service.fetch().await; - - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let cache_file: CloudRequirementsCacheFile = - serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) - .expect("parse cache"); - assert!( - cache_file.signed_payload.expires_at - <= cache_file.signed_payload.cached_at + ChronoDuration::minutes(30) - ); - assert!(cache_file.signed_payload.expires_at > cache_file.signed_payload.cached_at); - assert!(cache_file.signed_payload.cached_at <= Utc::now()); - assert_eq!( - cache_file.signed_payload.chatgpt_user_id, - Some("user-12345".to_string()) - ); - assert_eq!( - cache_file.signed_payload.account_id, - Some("account-12345".to_string()) - ); - assert_eq!( - cache_file - .signed_payload - .contents - .as_deref() - .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, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - }) - ); - let payload_bytes = cache_payload_bytes(&cache_file.signed_payload).expect("payload bytes"); - assert!(verify_cache_signature( - &payload_bytes, - &cache_file.signature - )); - } - - #[tokio::test] - async fn fetch_cloud_requirements_none_is_success_without_retry() { - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(None), Err(request_error())])); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(None)); + assert_eq!(service.fetch().await, Ok(Some(replacement_bundle))); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); } @@ -2387,10 +1858,10 @@ command = "sample-mcp" let err = handle .await - .expect("cloud requirements task") - .expect_err("cloud requirements retry exhaustion should fail closed"); + .expect("cloud config bundle task") + .expect_err("cloud config bundle retry exhaustion should fail closed"); assert_eq!(err.to_string(), CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE); - assert_eq!(err.code(), CloudRequirementsLoadErrorCode::RequestFailed); + assert_eq!(err.code(), CloudConfigBundleLoadErrorCode::RequestFailed); assert_eq!( fetcher.request_count.load(Ordering::SeqCst), CLOUD_REQUIREMENTS_MAX_ATTEMPTS @@ -2398,13 +1869,21 @@ command = "sample-mcp" } #[tokio::test] - async fn refresh_from_remote_updates_cached_cloud_requirements() { + async fn refresh_from_remote_updates_cached_bundle() { + let replacement_bundle = CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Replacement requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }], + }, + }; let codex_home = tempdir().expect("tempdir"); let fetcher = Arc::new(SequenceFetcher::new(vec![ - Ok(Some("allowed_approval_policies = [\"never\"]".to_string())), - Ok(Some( - "allowed_approval_policies = [\"on-request\"]".to_string(), - )), + Ok(test_bundle()), + Ok(replacement_bundle.clone()), ])); let service = CloudRequirementsService::new( auth_manager_with_plan("business").await, @@ -2413,70 +1892,331 @@ command = "sample-mcp" CLOUD_REQUIREMENTS_TIMEOUT, ); - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - + assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); assert!(service.refresh_cache().await); - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let cache_file: CloudRequirementsCacheFile = - serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) - .expect("parse cache"); + let signed_payload = service + .load_cache(Some("user-12345"), Some("account-12345")) + .await + .expect("load cache"); + assert_eq!(signed_payload.bundle, replacement_bundle); + } + + #[test] + fn bundle_response_conversion_preserves_fragment_order() { + let response = ConfigBundleResponse { + config_toml: Some(Some(Box::new(codex_backend_client::DeliveredConfigToml { + enterprise_managed: Some(Some(vec![ + DeliveredTomlFragment::new( + "cfg_high".to_string(), + "High config".to_string(), + "model = \"high\"".to_string(), + ), + DeliveredTomlFragment::new( + "cfg_low".to_string(), + "Low config".to_string(), + "model = \"low\"".to_string(), + ), + ])), + }))), + requirements_toml: Some(Some(Box::new( + codex_backend_client::DeliveredRequirementsToml { + enterprise_managed: Some(Some(vec![DeliveredTomlFragment::new( + "req_high".to_string(), + "High requirements".to_string(), + "allowed_approval_policies = [\"never\"]".to_string(), + )])), + }, + ))), + }; + assert_eq!( - cache_file - .signed_payload - .contents - .as_deref() - .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, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - }) + bundle_from_response(response), + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![ + CloudConfigFragment { + id: "cfg_high".to_string(), + name: "High config".to_string(), + contents: "model = \"high\"".to_string(), + }, + CloudConfigFragment { + id: "cfg_low".to_string(), + name: "Low config".to_string(), + contents: "model = \"low\"".to_string(), + }, + ], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_high".to_string(), + name: "High requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + }], + }, + } + ); + } + + #[test] + fn bundle_response_conversion_treats_missing_sections_as_empty() { + assert_eq!( + bundle_from_response(ConfigBundleResponse::new()), + CloudConfigBundle::default() + ); + } +} + +#[cfg(test)] +mod cache_tests { + use super::*; + use codex_config::CloudConfigFragment; + use codex_config::CloudConfigTomlBundle; + use codex_config::CloudRequirementsFragment; + use codex_config::CloudRequirementsTomlBundle; + use pretty_assertions::assert_eq; + use std::path::Path; + use tempfile::tempdir; + + fn test_bundle() -> CloudConfigBundle { + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![CloudConfigFragment { + id: "cfg_1".to_string(), + name: "Base config".to_string(), + contents: "model = \"gpt-5\"".to_string(), + }], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + }], + }, + } + } + + struct NeverFetcher; + + #[async_trait] + impl RequirementsFetcher for NeverFetcher { + async fn fetch_requirements( + &self, + _auth: &CodexAuth, + ) -> Result { + panic!("cache tests should not fetch from remote"); + } + } + + async fn create_test_service(codex_home: &Path) -> CloudRequirementsService { + let auth_home = tempdir().expect("tempdir"); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + CloudRequirementsService::new( + auth_manager, + Arc::new(NeverFetcher), + codex_home.to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ) + } + + fn signed_cache_file( + signed_payload: CloudRequirementsCacheSignedPayload, + ) -> CloudRequirementsCacheFile { + let payload_bytes = cache_payload_bytes(&signed_payload).expect("payload bytes"); + CloudRequirementsCacheFile { + signature: sign_cache_payload(&payload_bytes).expect("signature"), + signed_payload, + } + } + + fn valid_signed_payload() -> CloudRequirementsCacheSignedPayload { + let cached_at = Utc::now(); + CloudRequirementsCacheSignedPayload { + version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, + cached_at, + expires_at: cached_at + ChronoDuration::minutes(30), + chatgpt_user_id: Some("user-12345".to_string()), + account_id: Some("account-12345".to_string()), + bundle: test_bundle(), + } + } + + fn write_cache_file(cache_path: &Path, cache_file: &CloudRequirementsCacheFile) { + std::fs::write( + cache_path, + serde_json::to_vec_pretty(cache_file).expect("serialize cache"), + ) + .expect("write cache"); + } + + #[tokio::test] + async fn save_writes_signed_payload_and_loads_for_matching_identity() { + let codex_home = tempdir().expect("tempdir"); + let service = create_test_service(codex_home.path()).await; + let bundle = test_bundle(); + + service + .save_cache( + Some("user-12345".to_string()), + Some("account-12345".to_string()), + bundle.clone(), + ) + .await + .expect("save cache"); + + let cache_file: CloudRequirementsCacheFile = + serde_json::from_slice(&std::fs::read(&service.cache_path).expect("read cache")) + .expect("parse cache"); + assert!( + cache_file.signed_payload.expires_at + <= cache_file.signed_payload.cached_at + ChronoDuration::minutes(30) + ); + assert!(cache_file.signed_payload.expires_at > cache_file.signed_payload.cached_at); + assert_eq!( + cache_file, + signed_cache_file(CloudRequirementsCacheSignedPayload { + version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, + cached_at: cache_file.signed_payload.cached_at, + expires_at: cache_file.signed_payload.expires_at, + chatgpt_user_id: Some("user-12345".to_string()), + account_id: Some("account-12345".to_string()), + bundle, + }) + ); + + assert_eq!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await, + Ok(cache_file.signed_payload) + ); + } + + #[tokio::test] + async fn load_rejects_missing_request_identity_before_reading_cache_file() { + let codex_home = tempdir().expect("tempdir"); + let service = create_test_service(codex_home.path()).await; + + assert_eq!( + service + .load_cache(/*chatgpt_user_id*/ None, Some("account-12345")) + .await, + Err(CacheLoadStatus::AuthIdentityIncomplete) + ); + assert_eq!( + service + .load_cache(Some("user-12345"), /*account_id*/ None) + .await, + Err(CacheLoadStatus::AuthIdentityIncomplete) + ); + } + + #[tokio::test] + async fn load_reports_missing_and_malformed_cache_files() { + let codex_home = tempdir().expect("tempdir"); + let service = create_test_service(codex_home.path()).await; + + assert_eq!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await, + Err(CacheLoadStatus::CacheFileNotFound) + ); + + std::fs::write(&service.cache_path, "{").expect("write malformed cache"); + assert!(matches!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await, + Err(CacheLoadStatus::CacheParseFailed(_)) + )); + } + + #[tokio::test] + async fn load_rejects_tampered_payload() { + let codex_home = tempdir().expect("tempdir"); + let service = create_test_service(codex_home.path()).await; + let mut cache_file = signed_cache_file(valid_signed_payload()); + cache_file + .signed_payload + .bundle + .requirements_toml + .enterprise_managed[0] + .contents = "allowed_approval_policies = [\"on-request\"]".to_string(); + write_cache_file(&service.cache_path, &cache_file); + + assert_eq!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await, + Err(CacheLoadStatus::CacheSignatureInvalid) + ); + } + + #[tokio::test] + async fn load_rejects_cache_for_incomplete_or_different_identity() { + let codex_home = tempdir().expect("tempdir"); + let service = create_test_service(codex_home.path()).await; + let cache_file = signed_cache_file(valid_signed_payload()); + write_cache_file(&service.cache_path, &cache_file); + + assert_eq!( + service + .load_cache(Some("user-99999"), Some("account-12345")) + .await, + Err(CacheLoadStatus::CacheIdentityMismatch) + ); + + let mut signed_payload = valid_signed_payload(); + signed_payload.chatgpt_user_id = None; + write_cache_file(&service.cache_path, &signed_cache_file(signed_payload)); + + assert_eq!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await, + Err(CacheLoadStatus::CacheIdentityIncomplete) + ); + } + + #[tokio::test] + async fn load_rejects_expired_cache() { + let codex_home = tempdir().expect("tempdir"); + let service = create_test_service(codex_home.path()).await; + let mut signed_payload = valid_signed_payload(); + signed_payload.expires_at = Utc::now() - ChronoDuration::seconds(1); + write_cache_file(&service.cache_path, &signed_cache_file(signed_payload)); + + assert_eq!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await, + Err(CacheLoadStatus::CacheExpired) + ); + } + + #[tokio::test] + async fn load_rejects_unsupported_cache_version() { + let codex_home = tempdir().expect("tempdir"); + let service = create_test_service(codex_home.path()).await; + let mut signed_payload = valid_signed_payload(); + signed_payload.version = 2; + write_cache_file(&service.cache_path, &signed_cache_file(signed_payload)); + + assert_eq!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await, + Err(CacheLoadStatus::CacheVersionUnsupported(2)) ); } } diff --git a/codex-rs/config/src/cloud_config_bundle.rs b/codex-rs/config/src/cloud_config_bundle.rs new file mode 100644 index 0000000000..cfece32a01 --- /dev/null +++ b/codex-rs/config/src/cloud_config_bundle.rs @@ -0,0 +1,214 @@ +//! Cloud config bundle domain model and shared in-memory loader. +//! +//! The backend bundle groups cloud-delivered config and requirements fragments +//! by source bucket. `CloudConfigBundleLayers` converts those raw buckets into +//! layer entries while preserving each bucket's insertion semantics. + +use crate::CloudConfigFragment; +use crate::ConfigLayerEntry; +use crate::RequirementSource; +use crate::RequirementsLayerEntry; +use crate::cloud_config_layers::CloudConfigLayerError; +use crate::cloud_config_layers::cloud_config_layers_from_fragments_strict; +use crate::cloud_config_layers_from_fragments; +use codex_utils_absolute_path::AbsolutePathBuf; +use futures::future::BoxFuture; +use futures::future::FutureExt; +use futures::future::Shared; +use serde::Deserialize; +use serde::Serialize; +use std::fmt; +use std::future::Future; +use thiserror::Error; + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct CloudConfigBundle { + pub config_toml: CloudConfigTomlBundle, + pub requirements_toml: CloudRequirementsTomlBundle, +} + +impl CloudConfigBundle { + pub fn is_empty(&self) -> bool { + let CloudConfigBundle { + config_toml, + requirements_toml, + } = self; + let CloudConfigTomlBundle { + enterprise_managed: config_enterprise_managed, + } = config_toml; + let CloudRequirementsTomlBundle { + enterprise_managed: requirements_enterprise_managed, + } = requirements_toml; + + config_enterprise_managed.is_empty() && requirements_enterprise_managed.is_empty() + } +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct CloudConfigTomlBundle { + pub enterprise_managed: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct CloudRequirementsTomlBundle { + pub enterprise_managed: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CloudRequirementsFragment { + pub id: String, + pub name: String, + pub contents: String, +} + +/// Cloud config bundle converted into semantic layer buckets. +/// +/// This is not a final config stack. Callers still decide where each bucket is +/// inserted relative to local/system/user layers. +#[derive(Clone, Debug)] +pub struct CloudConfigBundleLayers { + /// Enterprise-managed config layers in `ConfigLayerStack` order. + pub enterprise_managed_config: Vec, + /// Enterprise-managed requirements layers in requirements layer merge order. + pub enterprise_managed_requirements: Vec, +} + +impl CloudConfigBundleLayers { + pub fn from_bundle( + bundle: CloudConfigBundle, + base_dir: &AbsolutePathBuf, + ) -> Result { + Self::from_bundle_impl(bundle, base_dir, /*strict_config*/ false) + } + + pub fn from_bundle_strict_config( + bundle: CloudConfigBundle, + base_dir: &AbsolutePathBuf, + ) -> Result { + Self::from_bundle_impl(bundle, base_dir, /*strict_config*/ true) + } + + fn from_bundle_impl( + bundle: CloudConfigBundle, + base_dir: &AbsolutePathBuf, + strict_config: bool, + ) -> Result { + // Keep this destructuring exhaustive so adding a new bundle bucket forces + // an explicit choice about how it becomes layer data. + let CloudConfigBundle { + config_toml: + CloudConfigTomlBundle { + enterprise_managed: config_enterprise_managed, + }, + requirements_toml: + CloudRequirementsTomlBundle { + enterprise_managed: requirements_enterprise_managed, + }, + } = bundle; + + let enterprise_managed_config = if strict_config { + cloud_config_layers_from_fragments_strict(config_enterprise_managed, base_dir)? + } else { + cloud_config_layers_from_fragments(config_enterprise_managed, base_dir)? + }; + + let mut enterprise_managed_requirements = requirements_enterprise_managed + .into_iter() + .map(|fragment| { + RequirementsLayerEntry::from_toml( + RequirementSource::EnterpriseManaged { + id: fragment.id, + name: fragment.name, + }, + fragment.contents, + ) + .with_base_dir(base_dir.clone()) + }) + .collect::>(); + // Bundle fragments arrive highest-priority first, while requirements + // layers are merged lowest-priority to highest-priority. + enterprise_managed_requirements.reverse(); + + Ok(Self { + enterprise_managed_config, + enterprise_managed_requirements, + }) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CloudConfigBundleLoadErrorCode { + Auth, + Timeout, + RequestFailed, + InvalidBundle, + Internal, +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +#[error("{message}")] +pub struct CloudConfigBundleLoadError { + code: CloudConfigBundleLoadErrorCode, + message: String, + status_code: Option, +} + +impl CloudConfigBundleLoadError { + pub fn new( + code: CloudConfigBundleLoadErrorCode, + status_code: Option, + message: impl Into, + ) -> Self { + Self { + code, + message: message.into(), + status_code, + } + } + + pub fn code(&self) -> CloudConfigBundleLoadErrorCode { + self.code + } + + pub fn status_code(&self) -> Option { + self.status_code + } +} + +#[derive(Clone)] +pub struct CloudConfigBundleLoader { + fut: Shared, CloudConfigBundleLoadError>>>, +} + +impl CloudConfigBundleLoader { + pub fn new(fut: F) -> Self + where + F: Future, CloudConfigBundleLoadError>> + + Send + + 'static, + { + Self { + fut: fut.boxed().shared(), + } + } + + pub async fn get(&self) -> Result, CloudConfigBundleLoadError> { + self.fut.clone().await + } +} + +impl fmt::Debug for CloudConfigBundleLoader { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CloudConfigBundleLoader").finish() + } +} + +impl Default for CloudConfigBundleLoader { + fn default() -> Self { + Self::new(async { Ok(None) }) + } +} + +#[cfg(test)] +#[path = "cloud_config_bundle_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/cloud_config_bundle_tests.rs b/codex-rs/config/src/cloud_config_bundle_tests.rs new file mode 100644 index 0000000000..714aca6bc6 --- /dev/null +++ b/codex-rs/config/src/cloud_config_bundle_tests.rs @@ -0,0 +1,125 @@ +use super::*; +use crate::ConfigLayerSource; +use crate::ConfigRequirementsToml; +use crate::compose_requirements; +use codex_protocol::protocol::AskForApproval; +use pretty_assertions::assert_eq; +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use tempfile::tempdir; + +#[tokio::test] +async fn shared_future_runs_once() { + let counter = Arc::new(AtomicUsize::new(0)); + let counter_clone = Arc::clone(&counter); + let loader = CloudConfigBundleLoader::new(async move { + counter_clone.fetch_add(1, Ordering::SeqCst); + Ok(Some(CloudConfigBundle::default())) + }); + + let (first, second) = tokio::join!(loader.get(), loader.get()); + assert_eq!(first, second); + assert_eq!(counter.load(Ordering::SeqCst), 1); +} + +#[test] +fn bundle_layers_preserve_enterprise_managed_bucket_order() { + let tempdir = tempdir().expect("tempdir"); + let base_dir = AbsolutePathBuf::from_absolute_path(tempdir.path()).expect("absolute path"); + let layers = CloudConfigBundleLayers::from_bundle( + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![ + CloudConfigFragment { + id: "cfg_high".to_string(), + name: "High config".to_string(), + contents: "model = \"high\"".to_string(), + }, + CloudConfigFragment { + id: "cfg_low".to_string(), + name: "Low config".to_string(), + contents: "model = \"low\"".to_string(), + }, + ], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![ + CloudRequirementsFragment { + id: "req_high".to_string(), + name: "High requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }, + CloudRequirementsFragment { + id: "req_low".to_string(), + name: "Low requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + }, + ], + }, + }, + &base_dir, + ) + .expect("bundle should be converted into layers"); + + assert_eq!( + layers + .enterprise_managed_config + .iter() + .map(|layer| layer.name.clone()) + .collect::>(), + vec![ + ConfigLayerSource::EnterpriseManaged { + id: "cfg_low".to_string(), + name: "Low config".to_string(), + }, + ConfigLayerSource::EnterpriseManaged { + id: "cfg_high".to_string(), + name: "High config".to_string(), + }, + ] + ); + assert_eq!( + compose_requirements(layers.enterprise_managed_requirements) + .expect("requirements should compose") + .expect("requirements should be present") + .into_toml(), + ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), + ..Default::default() + } + ); +} + +#[test] +fn bundle_layers_can_strict_validate_enterprise_managed_config() { + let tempdir = tempdir().expect("tempdir"); + let base_dir = AbsolutePathBuf::from_absolute_path(tempdir.path()).expect("absolute path"); + let err = CloudConfigBundleLayers::from_bundle_strict_config( + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![CloudConfigFragment { + id: "cfg".to_string(), + name: "Cloud config".to_string(), + contents: "unknown_key = true".to_string(), + }], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: Vec::new(), + }, + }, + &base_dir, + ) + .expect_err("strict config should reject unknown fields"); + + assert_eq!( + err, + CloudConfigLayerError::Invalid { + fragment: crate::CloudConfigFragmentSource { + id: "cfg".to_string(), + name: "Cloud config".to_string(), + }, + message: "unknown configuration field `unknown_key`".to_string(), + } + ); +} diff --git a/codex-rs/config/src/cloud_config_layers.rs b/codex-rs/config/src/cloud_config_layers.rs index bdb11631e4..f6fcd40b4a 100644 --- a/codex-rs/config/src/cloud_config_layers.rs +++ b/codex-rs/config/src/cloud_config_layers.rs @@ -7,8 +7,13 @@ use crate::ConfigLayerEntry; use crate::ConfigLayerSource; use crate::TomlValue; +use crate::config_toml::ConfigToml; use crate::loader::resolve_relative_paths_in_config_toml; +use crate::strict_config::config_error_from_ignored_toml_value_fields_for_source_name; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::AbsolutePathBufGuard; +use serde::Deserialize; +use serde::Serialize; use std::fmt; use std::io; use thiserror::Error; @@ -18,7 +23,7 @@ use thiserror::Error; /// The bundle orders fragments from highest precedence to lowest precedence. /// This module returns config layers in stack order, so callers can append the /// result between system and user config without re-sorting. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct CloudConfigFragment { pub id: String, pub name: String, @@ -63,6 +68,21 @@ pub enum CloudConfigLayerError { pub fn cloud_config_layers_from_fragments( fragments: impl IntoIterator, base_dir: &AbsolutePathBuf, +) -> Result, CloudConfigLayerError> { + cloud_config_layers_from_fragments_impl(fragments, base_dir, /*strict_config*/ false) +} + +pub(crate) fn cloud_config_layers_from_fragments_strict( + fragments: impl IntoIterator, + base_dir: &AbsolutePathBuf, +) -> Result, CloudConfigLayerError> { + cloud_config_layers_from_fragments_impl(fragments, base_dir, /*strict_config*/ true) +} + +fn cloud_config_layers_from_fragments_impl( + fragments: impl IntoIterator, + base_dir: &AbsolutePathBuf, + strict_config: bool, ) -> Result, CloudConfigLayerError> { let mut layers = Vec::new(); for fragment in fragments { @@ -73,6 +93,9 @@ pub fn cloud_config_layers_from_fragments( fragment: source_ref.clone(), message: err.to_string(), })?; + if strict_config { + validate_fragment_strictly(&source_ref, &raw_toml, &value, base_dir)?; + } let resolved = resolve_relative_paths_in_config_toml(value, base_dir.as_path()).map_err(|err| { CloudConfigLayerError::Invalid { @@ -97,6 +120,26 @@ pub fn cloud_config_layers_from_fragments( Ok(layers) } +fn validate_fragment_strictly( + source_ref: &CloudConfigFragmentSource, + raw_toml: &str, + value: &TomlValue, + base_dir: &AbsolutePathBuf, +) -> Result<(), CloudConfigLayerError> { + let _guard = AbsolutePathBufGuard::new(base_dir.as_path()); + if let Some(config_error) = config_error_from_ignored_toml_value_fields_for_source_name::< + ConfigToml, + >(&source_ref.to_string(), raw_toml, value.clone()) + { + return Err(CloudConfigLayerError::Invalid { + fragment: source_ref.clone(), + message: config_error.message, + }); + } + + Ok(()) +} + impl From for io::Error { fn from(error: CloudConfigLayerError) -> Self { io::Error::new(io::ErrorKind::InvalidData, error) diff --git a/codex-rs/config/src/cloud_config_layers_tests.rs b/codex-rs/config/src/cloud_config_layers_tests.rs index c7f685fa67..c2bc7383bc 100644 --- a/codex-rs/config/src/cloud_config_layers_tests.rs +++ b/codex-rs/config/src/cloud_config_layers_tests.rs @@ -58,6 +58,27 @@ fn layers_are_returned_in_stack_order() { ); } +#[test] +fn strict_layers_reject_unknown_config_fields() { + let base_dir = base_dir(); + let err = cloud_config_layers_from_fragments_strict( + vec![fragment("strict", "Strict layer", "unknown_key = true")], + &base_dir, + ) + .expect_err("strict config should reject unknown fields"); + + assert_eq!( + err, + CloudConfigLayerError::Invalid { + fragment: CloudConfigFragmentSource { + id: "strict".to_string(), + name: "Strict layer".to_string(), + }, + message: "unknown configuration field `unknown_key`".to_string(), + } + ); +} + #[test] fn enterprise_layers_precede_user_and_override_system() { let base_dir = base_dir(); diff --git a/codex-rs/config/src/cloud_requirements.rs b/codex-rs/config/src/cloud_requirements.rs deleted file mode 100644 index 85b904824d..0000000000 --- a/codex-rs/config/src/cloud_requirements.rs +++ /dev/null @@ -1,105 +0,0 @@ -use crate::config_requirements::ConfigRequirementsToml; -use futures::future::BoxFuture; -use futures::future::FutureExt; -use futures::future::Shared; -use std::fmt; -use std::future::Future; -use thiserror::Error; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum CloudRequirementsLoadErrorCode { - Auth, - Timeout, - Parse, - RequestFailed, - Internal, -} - -#[derive(Clone, Debug, Eq, Error, PartialEq)] -#[error("{message}")] -pub struct CloudRequirementsLoadError { - code: CloudRequirementsLoadErrorCode, - message: String, - status_code: Option, -} - -impl CloudRequirementsLoadError { - pub fn new( - code: CloudRequirementsLoadErrorCode, - status_code: Option, - message: impl Into, - ) -> Self { - Self { - code, - message: message.into(), - status_code, - } - } - - pub fn code(&self) -> CloudRequirementsLoadErrorCode { - self.code - } - - pub fn status_code(&self) -> Option { - self.status_code - } -} - -#[derive(Clone)] -pub struct CloudRequirementsLoader { - fut: Shared< - BoxFuture<'static, Result, CloudRequirementsLoadError>>, - >, -} - -impl CloudRequirementsLoader { - pub fn new(fut: F) -> Self - where - F: Future, CloudRequirementsLoadError>> - + Send - + 'static, - { - Self { - fut: fut.boxed().shared(), - } - } - - pub async fn get(&self) -> Result, CloudRequirementsLoadError> { - self.fut.clone().await - } -} - -impl fmt::Debug for CloudRequirementsLoader { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("CloudRequirementsLoader").finish() - } -} - -impl Default for CloudRequirementsLoader { - fn default() -> Self { - Self::new(async { Ok(None) }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use std::sync::Arc; - use std::sync::atomic::AtomicUsize; - use std::sync::atomic::Ordering; - - #[tokio::test] - async fn shared_future_runs_once() { - let counter = Arc::new(AtomicUsize::new(0)); - let counter_clone = Arc::clone(&counter); - let loader = CloudRequirementsLoader::new(async move { - counter_clone.fetch_add(1, Ordering::SeqCst); - Ok(Some(ConfigRequirementsToml::default())) - }); - - let (first, second) = tokio::join!(loader.get(), loader.get()); - assert_eq!(first, second); - assert_eq!(counter.load(Ordering::SeqCst), 1); - } -} diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 86d64e4153..dcaefb8dbe 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -29,7 +29,6 @@ pub enum RequirementSource { domain: String, key: String, }, - CloudRequirements, /// Multiple requirements layers contributed to the final value. Sources are /// stored highest-priority first, matching the order surfaced in errors. Composite { @@ -87,9 +86,6 @@ impl fmt::Display for RequirementSource { RequirementSource::MdmManagedPreferences { domain, key } => { write!(f, "MDM {domain}:{key}") } - RequirementSource::CloudRequirements => { - write!(f, "cloud requirements") - } RequirementSource::Composite { sources } => { write!(f, "requirements layers: ")?; for (index, source) in sources.iter().enumerate() { @@ -1510,22 +1506,6 @@ mod tests { ); } - #[test] - fn composite_requirement_source_display_lists_sources_in_priority_order() { - let source = RequirementSource::composite([ - RequirementSource::MdmManagedPreferences { - domain: "com.openai.codex".to_string(), - key: "requirements_toml_base64".to_string(), - }, - RequirementSource::LegacyManagedConfigTomlFromMdm, - ]); - - assert_eq!( - source.to_string(), - "requirements layers: MDM com.openai.codex:requirements_toml_base64, MDM managed_config.toml (legacy)" - ); - } - fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources { let ConfigRequirementsToml { allowed_approval_policies, @@ -1906,7 +1886,7 @@ mod tests { fn merge_unset_fields_ignores_blank_guardian_override() { let mut target = ConfigRequirementsWithSources::default(); target.merge_unset_fields( - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, ConfigRequirementsToml { guardian_policy_config: Some(" \n\t".to_string()), ..Default::default() @@ -2252,8 +2232,11 @@ allowed_approvals_reviewers = ["user"] #[test] fn merge_unset_fields_merges_apps_across_sources_with_enabled_evaluation() { - let higher_source = RequirementSource::CloudRequirements; - let lower_source = RequirementSource::LegacyManagedConfigTomlFromMdm; + let higher_source = RequirementSource::LegacyManagedConfigTomlFromMdm; + let lower_source = RequirementSource::MdmManagedPreferences { + domain: "com.openai.codex".to_string(), + key: "requirements_toml_base64".to_string(), + }; let mut target = ConfigRequirementsWithSources::default(); target.merge_unset_fields( @@ -2294,7 +2277,7 @@ allowed_approvals_reviewers = ["user"] let mut target = ConfigRequirementsWithSources::default(); target.merge_unset_fields( - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, ConfigRequirementsToml { apps: Some(apps_requirements(&[])), ..Default::default() @@ -2415,7 +2398,7 @@ allowed_approvals_reviewers = ["user"] "#, )?; - let source_location = RequirementSource::CloudRequirements; + let source_location = RequirementSource::LegacyManagedConfigTomlFromMdm; let mut target = ConfigRequirementsWithSources::default(); target.merge_unset_fields(source_location.clone(), source); let requirements = ConfigRequirements::try_from(target)?; @@ -2725,7 +2708,7 @@ allowed_approvals_reviewers = ["user"] #[test] fn remote_sandbox_config_first_match_overrides_top_level() -> Result<()> { - let source = RequirementSource::CloudRequirements; + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let mut requirements_toml: ConfigRequirementsToml = from_str( r#" allowed_sandbox_modes = ["read-only"] @@ -2816,7 +2799,7 @@ allowed_approvals_reviewers = ["user"] #[test] fn remote_sandbox_config_does_not_override_higher_precedence_sandbox_modes() -> Result<()> { - let high_source = RequirementSource::CloudRequirements; + let high_source = RequirementSource::LegacyManagedConfigTomlFromMdm; let mut high_precedence: ConfigRequirementsToml = from_str( r#" allowed_sandbox_modes = ["read-only"] @@ -3003,7 +2986,7 @@ statusMessage = "checking" fn merge_unset_fields_does_not_overwrite_existing_hooks() -> Result<()> { let mut target = ConfigRequirementsWithSources::default(); target.merge_unset_fields( - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, from_str::( r#" [hooks] @@ -3047,7 +3030,7 @@ command = "python3 /system/hooks/pre.py" ); assert_eq!( target.hooks.as_ref().map(|hooks| hooks.source.clone()), - Some(RequirementSource::CloudRequirements) + Some(RequirementSource::LegacyManagedConfigTomlFromMdm) ); Ok(()) } @@ -3111,7 +3094,7 @@ command = "python3 /enterprise/hooks/pre.py" "/tmp/blocked.sock" = "deny" "#; - let source = RequirementSource::CloudRequirements; + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let mut requirements_with_sources = ConfigRequirementsWithSources::default(); requirements_with_sources.merge_unset_fields(source.clone(), from_str(toml_str)?); @@ -3184,7 +3167,7 @@ command = "python3 /enterprise/hooks/pre.py" allow_local_binding = false "#; - let source = RequirementSource::CloudRequirements; + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let mut requirements_with_sources = ConfigRequirementsWithSources::default(); requirements_with_sources.merge_unset_fields(source.clone(), from_str(toml_str)?); diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index cb3984729b..0c79ffacff 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -1,5 +1,5 @@ +mod cloud_config_bundle; mod cloud_config_layers; -mod cloud_requirements; mod config_layer_source; mod config_requirements; pub mod config_toml; @@ -25,19 +25,25 @@ pub mod schema; mod skills_config; mod state; mod strict_config; +pub mod test_support; mod thread_config; mod tui_keymap; pub mod types; pub const CONFIG_TOML_FILE: &str = "config.toml"; +pub use cloud_config_bundle::CloudConfigBundle; +pub use cloud_config_bundle::CloudConfigBundleLayers; +pub use cloud_config_bundle::CloudConfigBundleLoadError; +pub use cloud_config_bundle::CloudConfigBundleLoadErrorCode; +pub use cloud_config_bundle::CloudConfigBundleLoader; +pub use cloud_config_bundle::CloudConfigTomlBundle; +pub use cloud_config_bundle::CloudRequirementsFragment; +pub use cloud_config_bundle::CloudRequirementsTomlBundle; pub use cloud_config_layers::CloudConfigFragment; pub use cloud_config_layers::CloudConfigFragmentSource; pub use cloud_config_layers::CloudConfigLayerError; pub use cloud_config_layers::cloud_config_layers_from_fragments; -pub use cloud_requirements::CloudRequirementsLoadError; -pub use cloud_requirements::CloudRequirementsLoadErrorCode; -pub use cloud_requirements::CloudRequirementsLoader; pub use codex_app_server_protocol::ConfigLayerSource; pub use codex_protocol::config_types::ProfileV2Name; pub use codex_protocol::config_types::ProfileV2NameParseError; diff --git a/codex-rs/config/src/loader/README.md b/codex-rs/config/src/loader/README.md index e004cea87c..b711ebbb61 100644 --- a/codex-rs/config/src/loader/README.md +++ b/codex-rs/config/src/loader/README.md @@ -1,6 +1,6 @@ # `codex-config` loader -This module is the canonical place to **load and describe Codex configuration layers** (user config, CLI/session overrides, managed config, and MDM-managed preferences) and to produce: +This module is the canonical place to **load and describe Codex configuration layers** (user config, CLI/session overrides, cloud-managed config, managed config, and MDM-managed preferences) and to produce: - An **effective merged** TOML config. - **Per-key origins** metadata (which layer “wins” for a given key). @@ -10,7 +10,7 @@ This module is the canonical place to **load and describe Codex configuration la Exported from `codex_config::loader`: -- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, options, cloud_requirements, thread_config_loader) -> ConfigLayerStack` +- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, options, thread_config_loader) -> ConfigLayerStack` - `ConfigLayerStack` - `effective_config() -> toml::Value` - `origins() -> HashMap` @@ -25,13 +25,19 @@ Exported from `codex_config::loader`: Precedence is **top overrides bottom**: -1. **MDM** managed preferences (macOS only) -2. **System** managed config (e.g. `managed_config.toml`) -3. **Session flags** (CLI overrides, applied as dotted-path TOML writes) -4. **User** config (`config.toml`) +1. `LegacyManagedConfigTomlFromMdm` (MDM-delivered `managed_config.toml`, while it is being phased out) +2. `LegacyManagedConfigTomlFromFile` (`managed_config.toml`, while it is being phased out) +3. `SessionFlags` (CLI overrides, applied as dotted-path TOML writes) +4. `Project` config (`.codex/config.toml`) +5. `User` profile config, when present +6. `User` config (`config.toml`) +7. `EnterpriseManaged` cloud-managed config bundle layers +8. `System` config (`/etc/codex/config.toml` or the Windows system config path) -Thread config entries supplied by `thread_config_loader` are inserted according -to their translated `ConfigLayerSource` precedence. +`ConfigLayerStack` stores layers in the opposite order internally: lowest +precedence first, highest precedence last, so later layers override earlier +layers when folded. Thread config entries supplied by `thread_config_loader` are +inserted according to their translated `ConfigLayerSource` precedence. Layers with a `disabled_reason` are still surfaced for UI, but are ignored when computing the effective config and origins metadata. This is what @@ -42,9 +48,8 @@ computing the effective config and origins metadata. This is what Most callers want the effective config plus metadata: ```rust -use codex_config::NoopThreadConfigLoader; -use codex_config::CloudRequirementsLoader; use codex_config::LoaderOverrides; +use codex_config::NoopThreadConfigLoader; use codex_config::loader::load_config_layers_state; use codex_exec_server::LOCAL_FS; use codex_utils_absolute_path::AbsolutePathBuf; @@ -58,7 +63,6 @@ let layers = load_config_layers_state( Some(cwd), &cli_overrides, LoaderOverrides::default(), - CloudRequirementsLoader::default(), &NoopThreadConfigLoader, ).await?; diff --git a/codex-rs/config/src/loader/macos.rs b/codex-rs/config/src/loader/macos.rs index 199e7f6daa..974cb27604 100644 --- a/codex-rs/config/src/loader/macos.rs +++ b/codex-rs/config/src/loader/macos.rs @@ -1,6 +1,4 @@ -use super::merge_requirements_with_remote_sandbox_config; -use crate::config_requirements::ConfigRequirementsToml; -use crate::config_requirements::ConfigRequirementsWithSources; +use crate::RequirementsLayerEntry; use crate::config_requirements::RequirementSource; use crate::config_toml::ConfigToml; use crate::diagnostics::ConfigDiagnosticSource; @@ -75,35 +73,27 @@ fn load_managed_admin_config( .transpose() } -pub(crate) async fn load_managed_admin_requirements_toml( - target: &mut ConfigRequirementsWithSources, +pub(crate) async fn load_managed_admin_requirements_layer( override_base64: Option<&str>, -) -> io::Result<()> { +) -> io::Result> { if let Some(encoded) = override_base64 { let trimmed = encoded.trim(); if trimmed.is_empty() { - return Ok(()); + return Ok(None); } - merge_requirements_with_remote_sandbox_config( - target, - managed_preferences_requirements_source(), - parse_managed_requirements_base64(trimmed)?, - ); - return Ok(()); + return parse_managed_requirements_base64(trimmed).map(|contents| { + Some(RequirementsLayerEntry::from_toml( + managed_preferences_requirements_source(), + contents, + )) + }); } match task::spawn_blocking(load_managed_admin_requirements).await { - Ok(result) => { - if let Some(requirements) = result? { - merge_requirements_with_remote_sandbox_config( - target, - managed_preferences_requirements_source(), - requirements, - ); - } - Ok(()) - } + Ok(result) => Ok(result?.map(|contents| { + RequirementsLayerEntry::from_toml(managed_preferences_requirements_source(), contents) + })), Err(join_err) => { if join_err.is_cancelled() { tracing::error!("Managed requirements load task was cancelled"); @@ -115,7 +105,7 @@ pub(crate) async fn load_managed_admin_requirements_toml( } } -fn load_managed_admin_requirements() -> io::Result> { +fn load_managed_admin_requirements() -> io::Result> { load_managed_preference(MANAGED_PREFERENCES_REQUIREMENTS_KEY)? .as_deref() .map(str::trim) @@ -217,13 +207,8 @@ fn validate_managed_config_toml_strictly_if_requested( } } -fn parse_managed_requirements_base64(encoded: &str) -> io::Result { - toml::from_str::(&decode_managed_preferences_base64(encoded)?).map_err( - |err| { - tracing::error!("Failed to parse managed requirements TOML: {err}"); - io::Error::new(io::ErrorKind::InvalidData, err) - }, - ) +fn parse_managed_requirements_base64(encoded: &str) -> io::Result { + decode_managed_preferences_base64(encoded) } fn decode_managed_preferences_base64(encoded: &str) -> io::Result { diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index a657af0f36..bc0fb482e4 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -6,10 +6,10 @@ mod tests; use self::layer_io::LoadedConfigLayers; use crate::CONFIG_TOML_FILE; +use crate::CloudConfigBundleLayers; use crate::ProfileV2Name; -use crate::cloud_requirements::CloudRequirementsLoader; -use crate::config_requirements::ConfigRequirementsToml; -use crate::config_requirements::ConfigRequirementsWithSources; +use crate::RequirementsLayerEntry; +use crate::compose_requirements; use crate::config_requirements::RequirementSource; use crate::config_requirements::SandboxModeRequirement; use crate::config_toml::ConfigToml; @@ -75,14 +75,16 @@ async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> O typed_first_layer_config_error_from_entries::(layers, CONFIG_TOML_FILE).await } -/// To build up the set of admin-enforced constraints, we build up from multiple -/// configuration layers in the following order, but a constraint defined in an -/// earlier layer cannot be overridden by a later layer: +/// To build up the set of admin-enforced constraints, requirements layers are +/// collected in ascending precedence order, matching config layers, and then +/// composed with config-style TOML merging plus field-specific handling for +/// hooks, rules, deny-read permissions, and remote sandbox config: /// -/// - cloud: managed cloud requirements -/// - admin: managed preferences (*) /// - system `/etc/codex/requirements.toml` (Unix) or /// `%ProgramData%\OpenAI\Codex\requirements.toml` (Windows) +/// - cloud: enterprise-managed cloud config bundle requirements +/// - legacy: managed_config.toml reinterpreted as requirements.toml +/// - admin: managed preferences (*) /// /// For backwards compatibility, we also load from /// `managed_config.toml` and map it to `requirements.toml`. @@ -92,6 +94,7 @@ async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> O /// - admin: managed preferences (*) /// - system `/etc/codex/config.toml` (Unix) or /// `%ProgramData%\OpenAI\Codex\config.toml` (Windows) +/// - cloud enterprise-managed cloud config bundle fragments /// - user `${CODEX_HOME}/config.toml` /// - profile `${CODEX_HOME}/.config.toml`, when selected /// - cwd `${PWD}/config.toml` (loaded but disabled when the directory is untrusted) @@ -114,56 +117,77 @@ pub async fn load_config_layers_state( cwd: Option, cli_overrides: &[(String, TomlValue)], options: impl Into, - cloud_requirements: CloudRequirementsLoader, thread_config_loader: &dyn ThreadConfigLoader, ) -> io::Result { let ConfigLoadOptions { loader_overrides: overrides, strict_config, + cloud_config_bundle, } = options.into(); let active_user_profile = overrides.user_config_profile.clone(); let ignore_managed_requirements = overrides.ignore_managed_requirements; let ignore_user_config = overrides.ignore_user_config; let ignore_user_and_project_exec_policy_rules = overrides.ignore_user_and_project_exec_policy_rules; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); + let mut requirements_layers = Vec::new(); + let mut bundle_requirements_layers = Vec::new(); + let mut system_requirements_layer = None; + let managed_preferences_requirements_layer; + let mut cloud_config_layers = Vec::new(); if !ignore_managed_requirements { - if let Some(requirements) = cloud_requirements.get().await.map_err(io::Error::other)? { - merge_requirements_with_remote_sandbox_config( - &mut config_requirements_toml, - RequirementSource::CloudRequirements, - requirements, - ); + if let Some(bundle) = cloud_config_bundle.get().await.map_err(io::Error::other)? { + let cloud_config_base_dir = AbsolutePathBuf::from_absolute_path(codex_home)?; + let bundle_layers = if strict_config { + CloudConfigBundleLayers::from_bundle_strict_config(bundle, &cloud_config_base_dir)? + } else { + CloudConfigBundleLayers::from_bundle(bundle, &cloud_config_base_dir)? + }; + let CloudConfigBundleLayers { + enterprise_managed_config, + enterprise_managed_requirements, + } = bundle_layers; + bundle_requirements_layers = enterprise_managed_requirements; + cloud_config_layers = enterprise_managed_config; } #[cfg(target_os = "macos")] - macos::load_managed_admin_requirements_toml( - &mut config_requirements_toml, - overrides - .macos_managed_config_requirements_base64 - .as_deref(), - ) - .await?; + { + managed_preferences_requirements_layer = macos::load_managed_admin_requirements_layer( + overrides + .macos_managed_config_requirements_base64 + .as_deref(), + ) + .await?; + } + #[cfg(not(target_os = "macos"))] + { + managed_preferences_requirements_layer = None; + } // Honor the system requirements.toml location. let requirements_toml_file = system_requirements_toml_file_with_overrides(&overrides)?; - load_requirements_toml(fs, &mut config_requirements_toml, &requirements_toml_file).await?; + system_requirements_layer = load_requirements_toml(fs, &requirements_toml_file).await?; + } else { + managed_preferences_requirements_layer = None; } - // Make a best-effort to support the legacy `managed_config.toml` as a - // requirements specification. let loaded_config_layers = layer_io::load_config_layers_internal(fs, codex_home, overrides.clone(), strict_config) .await?; if !ignore_managed_requirements { - load_requirements_from_legacy_scheme( - &mut config_requirements_toml, + requirements_layers.extend(system_requirements_layer); + requirements_layers.extend(bundle_requirements_layers); + // Continue to support the legacy `managed_config.toml` locations as + // requirements layers for backwards compatibility. + requirements_layers.extend(requirements_layers_from_legacy_scheme( loaded_config_layers.clone(), - ) - .await?; + )?); + requirements_layers.extend(managed_preferences_requirements_layer); } + let config_requirements_toml = compose_requirements(requirements_layers)?.unwrap_or_default(); + let thread_config_context = ThreadConfigContext { thread_id: None, cwd: cwd.clone(), @@ -210,6 +234,7 @@ pub async fn load_config_layers_state( ) .await?; layers.push(system_layer); + layers.extend(cloud_config_layers); // Add the base user config layer. When profile-v2 is selected, add the // profile config as a second user layer on top so the profile only needs to @@ -535,15 +560,12 @@ fn validate_cli_overrides_strictly( Ok(()) } -/// If available, apply requirements from the platform system -/// `requirements.toml` location to `config_requirements_toml` by filling in -/// any unset fields. -#[doc(hidden)] +/// If available, load requirements from the platform's system `requirements.toml` +/// location as a requirements layer. pub async fn load_requirements_toml( fs: &dyn ExecutorFileSystem, - config_requirements_toml: &mut ConfigRequirementsWithSources, requirements_toml_file: &AbsolutePathBuf, -) -> io::Result<()> { +) -> io::Result> { match fs .read_file_text(requirements_toml_file, /*sandbox*/ None) .await @@ -558,39 +580,31 @@ pub async fn load_requirements_toml( ), ) })?; - let _guard = AbsolutePathBufGuard::new(requirements_parent.as_path()); - let requirements_config: ConfigRequirementsToml = - toml::from_str(&contents).map_err(|e| { - io::Error::new( - io::ErrorKind::InvalidData, - format!( - "Error parsing requirements file {}: {e}", - requirements_toml_file.as_path().display(), - ), - ) - })?; - merge_requirements_with_remote_sandbox_config( - config_requirements_toml, - RequirementSource::SystemRequirementsToml { - file: requirements_toml_file.clone(), - }, - requirements_config, - ); + let base_dir = AbsolutePathBuf::from_absolute_path(requirements_parent)?; + Ok(Some( + RequirementsLayerEntry::from_toml( + RequirementSource::SystemRequirementsToml { + file: requirements_toml_file.clone(), + }, + contents, + ) + .with_base_dir(base_dir), + )) } Err(e) => { if e.kind() != io::ErrorKind::NotFound { - return Err(io::Error::new( + Err(io::Error::new( e.kind(), format!( "Failed to read requirements file {}: {e}", requirements_toml_file.as_path().display(), ), - )); + )) + } else { + Ok(None) } } } - - Ok(()) } #[cfg(unix)] @@ -704,31 +718,33 @@ fn windows_program_data_dir_from_known_folder() -> io::Result { Ok(path) } -async fn load_requirements_from_legacy_scheme( - config_requirements_toml: &mut ConfigRequirementsWithSources, +fn requirements_layers_from_legacy_scheme( loaded_config_layers: LoadedConfigLayers, -) -> io::Result<()> { - // In this implementation, earlier layers cannot be overwritten by later - // layers, so list managed_config_from_mdm first because it has the highest - // precedence. +) -> io::Result> { + // List the file-backed legacy layer first because requirements layers are + // composed lowest-precedence to highest-precedence, and MDM has higher + // precedence than the legacy managed_config.toml file. let LoadedConfigLayers { managed_config, managed_config_from_mdm, } = loaded_config_layers; - for (source, config) in managed_config_from_mdm - .map(|config| { - ( - RequirementSource::LegacyManagedConfigTomlFromMdm, - config.managed_config, - ) - }) - .into_iter() - .chain(managed_config.map(|c| { + let layer_count = + usize::from(managed_config.is_some()) + usize::from(managed_config_from_mdm.is_some()); + let mut layers = Vec::with_capacity(layer_count); + for (source, config) in managed_config + .map(|c| { ( RequirementSource::LegacyManagedConfigTomlFromFile { file: c.file }, c.managed_config, ) + }) + .into_iter() + .chain(managed_config_from_mdm.map(|config| { + ( + RequirementSource::LegacyManagedConfigTomlFromMdm, + config.managed_config, + ) })) { let legacy_config: LegacyManagedConfigToml = @@ -739,26 +755,56 @@ async fn load_requirements_from_legacy_scheme( ) })?; - merge_requirements_with_remote_sandbox_config( - config_requirements_toml, + layers.push(RequirementsLayerEntry::from_toml_value( source, - ConfigRequirementsToml::from(legacy_config), - ); + legacy_requirements_to_toml_value(legacy_config)?, + )); } - Ok(()) + Ok(layers) } -pub(super) fn merge_requirements_with_remote_sandbox_config( - target: &mut ConfigRequirementsWithSources, - source: RequirementSource, - mut requirements: ConfigRequirementsToml, -) { - if requirements.remote_sandbox_config.is_some() { - let host_name = crate::host_name(); - requirements.apply_remote_sandbox_config(host_name.as_deref()); +fn legacy_requirements_to_toml_value(legacy: LegacyManagedConfigToml) -> io::Result { + let LegacyManagedConfigToml { + approval_policy, + approvals_reviewer, + sandbox_mode, + } = legacy; + let mut table = toml::map::Map::new(); + if let Some(approval_policy) = approval_policy { + table.insert( + "allowed_approval_policies".to_string(), + toml_value_from_serializable(vec![approval_policy])?, + ); } - target.merge_unset_fields(source, requirements); + if let Some(approvals_reviewer) = approvals_reviewer { + let mut allowed_reviewers = vec![approvals_reviewer]; + if approvals_reviewer == ApprovalsReviewer::AutoReview { + allowed_reviewers.push(ApprovalsReviewer::User); + } + table.insert( + "allowed_approvals_reviewers".to_string(), + toml_value_from_serializable(allowed_reviewers)?, + ); + } + if let Some(sandbox_mode) = sandbox_mode { + let required_mode: SandboxModeRequirement = sandbox_mode.into(); + // Allowing read-only is a requirement for Codex to function correctly. + // So in this backfill path, we append read-only if it's not already specified. + let mut allowed_modes = vec![SandboxModeRequirement::ReadOnly]; + if required_mode != SandboxModeRequirement::ReadOnly { + allowed_modes.push(required_mode); + } + table.insert( + "allowed_sandbox_modes".to_string(), + toml_value_from_serializable(allowed_modes)?, + ); + } + Ok(TomlValue::Table(table)) +} + +fn toml_value_from_serializable(value: T) -> io::Result { + TomlValue::try_from(value).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) } struct ProjectTrustContext { @@ -1342,39 +1388,6 @@ struct LegacyManagedConfigToml { sandbox_mode: Option, } -impl From for ConfigRequirementsToml { - fn from(legacy: LegacyManagedConfigToml) -> Self { - let mut config_requirements_toml = ConfigRequirementsToml::default(); - - let LegacyManagedConfigToml { - approval_policy, - approvals_reviewer, - sandbox_mode, - } = legacy; - if let Some(approval_policy) = approval_policy { - config_requirements_toml.allowed_approval_policies = Some(vec![approval_policy]); - } - if let Some(approvals_reviewer) = approvals_reviewer { - let mut allowed_reviewers = vec![approvals_reviewer]; - if approvals_reviewer == ApprovalsReviewer::AutoReview { - allowed_reviewers.push(ApprovalsReviewer::User); - } - config_requirements_toml.allowed_approvals_reviewers = Some(allowed_reviewers); - } - if let Some(sandbox_mode) = sandbox_mode { - let required_mode: SandboxModeRequirement = sandbox_mode.into(); - // Allowing read-only is a requirement for Codex to function correctly. - // So in this backfill path, we append read-only if it's not already specified. - let mut allowed_modes = vec![SandboxModeRequirement::ReadOnly]; - if required_mode != SandboxModeRequirement::ReadOnly { - allowed_modes.push(required_mode); - } - config_requirements_toml.allowed_sandbox_modes = Some(allowed_modes); - } - config_requirements_toml - } -} - // Cannot name this `mod tests` because of tests.rs in this folder. #[cfg(test)] mod unit_tests { @@ -1421,54 +1434,63 @@ foo = "xyzzy" } #[test] - fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() { + fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() -> io::Result<()> { let legacy = LegacyManagedConfigToml { approval_policy: None, approvals_reviewer: None, sandbox_mode: Some(SandboxMode::WorkspaceWrite), }; - let requirements = ConfigRequirementsToml::from(legacy); - assert_eq!( - requirements.allowed_sandbox_modes, - Some(vec![ - SandboxModeRequirement::ReadOnly, - SandboxModeRequirement::WorkspaceWrite - ]) + legacy_requirements_to_toml_value(legacy)?, + TomlValue::Table(toml::map::Map::from_iter([( + "allowed_sandbox_modes".to_string(), + TomlValue::Array(vec![ + TomlValue::String("read-only".to_string()), + TomlValue::String("workspace-write".to_string()), + ]), + )])) ); + Ok(()) } #[test] - fn legacy_managed_config_backfill_allows_user_when_guardian_is_required() { + fn legacy_managed_config_backfill_allows_user_when_guardian_is_required() -> io::Result<()> { let legacy = LegacyManagedConfigToml { approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::AutoReview), sandbox_mode: None, }; - let requirements = ConfigRequirementsToml::from(legacy); - assert_eq!( - requirements.allowed_approvals_reviewers, - Some(vec![ApprovalsReviewer::AutoReview, ApprovalsReviewer::User,]) + legacy_requirements_to_toml_value(legacy)?, + TomlValue::Table(toml::map::Map::from_iter([( + "allowed_approvals_reviewers".to_string(), + TomlValue::Array(vec![ + TomlValue::String("guardian_subagent".to_string()), + TomlValue::String("user".to_string()), + ]), + )])) ); + Ok(()) } #[test] - fn legacy_managed_config_backfill_preserves_user_only_approvals_reviewer() { + fn legacy_managed_config_backfill_preserves_user_only_approvals_reviewer() -> io::Result<()> { let legacy = LegacyManagedConfigToml { approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_mode: None, }; - let requirements = ConfigRequirementsToml::from(legacy); - assert_eq!( - requirements.allowed_approvals_reviewers, - Some(vec![ApprovalsReviewer::User]) + legacy_requirements_to_toml_value(legacy)?, + TomlValue::Table(toml::map::Map::from_iter([( + "allowed_approvals_reviewers".to_string(), + TomlValue::Array(vec![TomlValue::String("user".to_string())]), + )])) ); + Ok(()) } #[cfg(windows)] diff --git a/codex-rs/config/src/loader/tests.rs b/codex-rs/config/src/loader/tests.rs index 9d4511a8a6..6492f9a0c0 100644 --- a/codex-rs/config/src/loader/tests.rs +++ b/codex-rs/config/src/loader/tests.rs @@ -128,7 +128,6 @@ model = "gpt-work" /*cwd*/ None, &[], overrides, - CloudRequirementsLoader::default(), &crate::NoopThreadConfigLoader, ) .await @@ -187,7 +186,6 @@ model = "gpt-main" /*cwd*/ None, &[], overrides, - CloudRequirementsLoader::default(), &crate::NoopThreadConfigLoader, ) .await @@ -244,7 +242,6 @@ model = "gpt-dev" /*cwd*/ None, &[], overrides, - CloudRequirementsLoader::default(), &crate::NoopThreadConfigLoader, ) .await diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index e4d03c3d3c..d5f2b050c9 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -5,6 +5,7 @@ use super::fingerprint::record_origins; use super::fingerprint::version_for_toml; use super::key_aliases::normalized_with_key_aliases; use super::merge::merge_toml_values; +use crate::CloudConfigBundleLoader; use crate::ProfileV2Name; use codex_app_server_protocol::ConfigLayer; use codex_app_server_protocol::ConfigLayerMetadata; @@ -21,6 +22,7 @@ use toml::Value as TomlValue; pub struct ConfigLoadOptions { pub loader_overrides: LoaderOverrides, pub strict_config: bool, + pub cloud_config_bundle: CloudConfigBundleLoader, } impl From for ConfigLoadOptions { @@ -28,6 +30,7 @@ impl From for ConfigLoadOptions { Self { loader_overrides, strict_config: false, + cloud_config_bundle: CloudConfigBundleLoader::default(), } } } @@ -471,8 +474,8 @@ impl ConfigLayerStack { /// Returns the merged config-layer view. /// - /// This only merges ordinary config layers and does not apply requirements - /// such as cloud requirements. + /// This only merges ordinary config layers. Requirements are composed and + /// tracked separately. pub fn effective_config(&self) -> TomlValue { let mut merged = TomlValue::Table(toml::map::Map::new()); for layer in self.get_layers( diff --git a/codex-rs/config/src/strict_config.rs b/codex-rs/config/src/strict_config.rs index fb64458e07..c1523c706c 100644 --- a/codex-rs/config/src/strict_config.rs +++ b/codex-rs/config/src/strict_config.rs @@ -37,7 +37,6 @@ pub(crate) fn config_error_from_ignored_toml_value_fields( ) } -#[cfg(any(target_os = "macos", test))] pub(crate) fn config_error_from_ignored_toml_value_fields_for_source_name( source_name: &str, contents: &str, diff --git a/codex-rs/config/src/test_support.rs b/codex-rs/config/src/test_support.rs new file mode 100644 index 0000000000..44627f3892 --- /dev/null +++ b/codex-rs/config/src/test_support.rs @@ -0,0 +1,80 @@ +//! Test-only helpers exposed for cross-crate integration tests. +//! +//! Production code should not depend on this module. + +use crate::CloudConfigBundle; +use crate::CloudConfigBundleLoader; +use crate::CloudConfigFragment; +use crate::CloudRequirementsFragment; + +#[derive(Debug, Clone, Default)] +pub struct CloudConfigBundleFixture { + bundle: CloudConfigBundle, +} + +impl CloudConfigBundleFixture { + pub fn enterprise_requirement(contents: impl Into) -> Self { + Self::default().add_enterprise_requirement(contents) + } + + pub fn enterprise_config(contents: impl Into) -> Self { + Self::default().add_enterprise_config(contents) + } + + pub fn loader_with_enterprise_requirement( + contents: impl Into, + ) -> CloudConfigBundleLoader { + Self::enterprise_requirement(contents).into_loader() + } + + pub fn loader_with_enterprise_config(contents: impl Into) -> CloudConfigBundleLoader { + Self::enterprise_config(contents).into_loader() + } + + pub fn add_enterprise_requirement(mut self, contents: impl Into) -> Self { + let index = self.bundle.requirements_toml.enterprise_managed.len() + 1; + self.bundle + .requirements_toml + .enterprise_managed + .push(CloudRequirementsFragment { + id: format!("req_{index}"), + name: if index == 1 { + "Base requirements".to_string() + } else { + format!("Requirements {index}") + }, + contents: contents.into(), + }); + self + } + + pub fn add_enterprise_config(mut self, contents: impl Into) -> Self { + let index = self.bundle.config_toml.enterprise_managed.len() + 1; + self.bundle + .config_toml + .enterprise_managed + .push(CloudConfigFragment { + id: format!("cfg_{index}"), + name: if index == 1 { + "Base config".to_string() + } else { + format!("Config {index}") + }, + contents: contents.into(), + }); + self + } + + pub fn into_bundle(self) -> CloudConfigBundle { + self.bundle + } + + pub fn into_loader(self) -> CloudConfigBundleLoader { + let bundle = self.into_bundle(); + CloudConfigBundleLoader::new(async move { Ok(Some(bundle)) }) + } +} + +#[cfg(test)] +#[path = "test_support_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/test_support_tests.rs b/codex-rs/config/src/test_support_tests.rs new file mode 100644 index 0000000000..379d6c2be2 --- /dev/null +++ b/codex-rs/config/src/test_support_tests.rs @@ -0,0 +1,25 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn adds_enterprise_requirements_in_order() { + let bundle = CloudConfigBundleFixture::enterprise_requirement("first") + .add_enterprise_requirement("second") + .into_bundle(); + + assert_eq!( + bundle.requirements_toml.enterprise_managed, + vec![ + CloudRequirementsFragment { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + contents: "first".to_string(), + }, + CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Requirements 2".to_string(), + contents: "second".to_string(), + }, + ] + ); +} diff --git a/codex-rs/core-plugins/src/test_support.rs b/codex-rs/core-plugins/src/test_support.rs index 9e37a14312..07f1fd4f26 100644 --- a/codex-rs/core-plugins/src/test_support.rs +++ b/codex-rs/core-plugins/src/test_support.rs @@ -3,7 +3,6 @@ use std::path::Path; use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::PluginsConfigInput; -use codex_config::CloudRequirementsLoader; use codex_config::LoaderOverrides; use codex_config::NoopThreadConfigLoader; use codex_config::loader::load_config_layers_state; @@ -106,7 +105,6 @@ pub(crate) async fn load_plugins_config(codex_home: &Path, cwd: &Path) -> Plugin Some(cwd), &[], LoaderOverrides::without_managed_config_for_tests(), - CloudRequirementsLoader::default(), &NoopThreadConfigLoader, ) .await diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index d0097d7537..5edecebec0 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -3,27 +3,31 @@ use crate::config::ConfigOverrides; use crate::config::ConstraintError; use codex_app_server_protocol::ConfigLayerSource; use codex_config::CONFIG_TOML_FILE; -use codex_config::CloudRequirementsLoadError; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoadError; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigError; use codex_config::ConfigLayerEntry; use codex_config::ConfigLayerStackOrdering; use codex_config::ConfigLoadError; +use codex_config::ConfigLoadOptions; use codex_config::ConfigRequirements; use codex_config::ConfigRequirementsToml; use codex_config::ConfigRequirementsWithSources; use codex_config::FilesystemDenyReadPattern; use codex_config::LoaderOverrides; use codex_config::RequirementSource; +use codex_config::RequirementsLayerEntry; use codex_config::SessionThreadConfig; use codex_config::StaticThreadConfigLoader; use codex_config::ThreadConfigSource; +use codex_config::compose_requirements; use codex_config::config_error_from_ignored_toml_fields; use codex_config::config_error_from_toml; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; use codex_config::loader::load_config_layers_state; use codex_config::loader::load_requirements_toml; +use codex_config::test_support::CloudConfigBundleFixture; use codex_exec_server::LOCAL_FS; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WebSearchMode; @@ -46,6 +50,22 @@ fn config_error_from_io(err: &std::io::Error) -> &ConfigError { .expect("expected ConfigLoadError") } +fn cloud_config_bundle_requirement_source() -> RequirementSource { + RequirementSource::EnterpriseManaged { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + } +} + +async fn load_single_requirements_toml( + requirements_file: &AbsolutePathBuf, +) -> anyhow::Result { + let layer = load_requirements_toml(LOCAL_FS.as_ref(), requirements_file) + .await? + .expect("requirements.toml should load"); + Ok(compose_requirements(vec![layer])?.expect("requirements should be present")) +} + async fn make_config_for_test( codex_home: &Path, project_path: &Path, @@ -147,7 +167,6 @@ invalid = ["#; Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -179,7 +198,6 @@ invalid = ["#, ignore_user_config: true, ..Default::default() }, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -210,7 +228,6 @@ async fn ignore_rules_marks_config_stack_for_exec_policy_rule_skip() -> std::io: ignore_user_and_project_exec_policy_rules: true, ..Default::default() }, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -236,7 +253,6 @@ invalid = ["#; Some(cwd), &[] as &[(String, TomlValue)], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -288,7 +304,6 @@ async fn top_level_allow_managed_hooks_only_in_user_config_does_not_enable_requi Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -323,7 +338,6 @@ command = "python3 /tmp/user-hook.py" Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -533,7 +547,6 @@ extra = true Some(cwd), &[] as &[(String, TomlValue)], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -567,7 +580,6 @@ async fn returns_empty_when_all_layers_missing() { Some(cwd), &[] as &[(String, TomlValue)], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -644,7 +656,6 @@ approval_policy = "on-failure" Some(cwd), &[] as &[(String, TomlValue)], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -707,7 +718,6 @@ async fn includes_thread_config_layers_in_stack() -> anyhow::Result<()> { Some(cwd), &[("features.plugins".to_string(), TomlValue::Boolean(true))], overrides, - CloudRequirementsLoader::default(), &StaticThreadConfigLoader::new(vec![ThreadConfigSource::Session(SessionThreadConfig { features: BTreeMap::from([("plugins".to_string(), false)]), ..Default::default() @@ -787,7 +797,6 @@ flag = false Some(cwd), &[] as &[(String, TomlValue)], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -891,7 +900,6 @@ allowed_sandbox_modes = ["read-only"] Some(AbsolutePathBuf::try_from(tmp.path())?), &[] as &[(String, TomlValue)], loader_overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -953,7 +961,6 @@ allowed_approval_policies = ["never"] Some(AbsolutePathBuf::try_from(tmp.path())?), &[] as &[(String, TomlValue)], loader_overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -990,14 +997,8 @@ personality = true ) .await?; - let requirements_file = AbsolutePathBuf::try_from(requirements_file)?; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); - load_requirements_toml( - LOCAL_FS.as_ref(), - &mut config_requirements_toml, - &requirements_file, - ) - .await?; + let config_requirements_toml = + load_single_requirements_toml(&AbsolutePathBuf::try_from(requirements_file)?).await?; assert_eq!( config_requirements_toml @@ -1073,7 +1074,7 @@ personality = true #[cfg(target_os = "macos")] #[tokio::test] -async fn cloud_requirements_take_precedence_over_mdm_requirements() -> anyhow::Result<()> { +async fn mdm_requirements_take_precedence_over_cloud_config_bundle() -> anyhow::Result<()> { use base64::Engine; let tmp = tempdir()?; @@ -1091,49 +1092,34 @@ allowed_approval_policies = ["on-request"] tmp.path(), Some(AbsolutePathBuf::try_from(tmp.path())?), &[] as &[(String, TomlValue)], - loader_overrides, - CloudRequirementsLoader::new(async { - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - guardian_policy_config: None, - })) - }), + ConfigLoadOptions { + loader_overrides, + cloud_config_bundle: CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approval_policies = ["never"]"#, + ), + ..Default::default() + }, &codex_config::NoopThreadConfigLoader, ) .await?; assert_eq!( state.requirements().approval_policy.value(), - AskForApproval::Never + AskForApproval::OnRequest ); assert_eq!( state .requirements() .approval_policy - .can_set(&AskForApproval::OnRequest), + .can_set(&AskForApproval::Never), Err(ConstraintError::InvalidValue { field_name: "approval_policy", - candidate: "OnRequest".into(), - allowed: "[Never]".into(), - requirement_source: RequirementSource::CloudRequirements, + candidate: "Never".into(), + allowed: "[OnRequest]".into(), + requirement_source: RequirementSource::MdmManagedPreferences { + domain: "com.openai.codex".to_string(), + key: "requirements_toml_base64".to_string(), + }, }) ); @@ -1141,7 +1127,7 @@ allowed_approval_policies = ["on-request"] } #[tokio::test(flavor = "current_thread")] -async fn cloud_requirements_are_not_overwritten_by_system_requirements() -> anyhow::Result<()> { +async fn cloud_config_bundle_are_not_overwritten_by_system_requirements() -> anyhow::Result<()> { let tmp = tempdir()?; let requirements_file = tmp.path().join("requirements.toml"); tokio::fs::write( @@ -1152,38 +1138,20 @@ allowed_approval_policies = ["on-request"] ) .await?; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); - config_requirements_toml.merge_unset_fields( - RequirementSource::CloudRequirements, - ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - guardian_policy_config: None, - }, - ); - load_requirements_toml( + let system_layer = load_requirements_toml( LOCAL_FS.as_ref(), - &mut config_requirements_toml, &AbsolutePathBuf::try_from(requirements_file)?, ) - .await?; + .await? + .expect("system requirements should load"); + let config_requirements_toml = compose_requirements(vec![ + system_layer, + RequirementsLayerEntry::from_toml( + cloud_config_bundle_requirement_source(), + r#"allowed_approval_policies = ["never"]"#, + ), + ])? + .expect("requirements should be present"); assert_eq!( config_requirements_toml @@ -1197,7 +1165,7 @@ allowed_approval_policies = ["on-request"] .allowed_approval_policies .as_ref() .map(|sourced| sourced.source.clone()), - Some(RequirementSource::CloudRequirements) + Some(cloud_config_bundle_requirement_source()) ); Ok(()) @@ -1217,22 +1185,21 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] ) .await?; - let cloud_source = RequirementSource::CloudRequirements; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); - config_requirements_toml.merge_unset_fields( - cloud_source.clone(), - toml::from_str( - r#" -allowed_sandbox_modes = ["read-only"] -"#, - )?, - ); - load_requirements_toml( + let cloud_source = cloud_config_bundle_requirement_source(); + let system_layer = load_requirements_toml( LOCAL_FS.as_ref(), - &mut config_requirements_toml, &AbsolutePathBuf::try_from(requirements_file)?, ) - .await?; + .await? + .expect("system requirements should load"); + let config_requirements_toml = compose_requirements(vec![ + system_layer, + RequirementsLayerEntry::from_toml( + cloud_source.clone(), + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ])? + .expect("requirements should be present"); let config_requirements: ConfigRequirements = config_requirements_toml.try_into()?; assert_eq!( @@ -1266,13 +1233,7 @@ deny_read = ["./sensitive", "../shared/secret.txt"] .await?; let requirements_file = AbsolutePathBuf::try_from(requirements_file)?; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); - load_requirements_toml( - LOCAL_FS.as_ref(), - &mut config_requirements_toml, - &requirements_file, - ) - .await?; + let config_requirements_toml = load_single_requirements_toml(&requirements_file).await?; let permissions = config_requirements_toml .permissions @@ -1320,13 +1281,7 @@ deny_read = ["./sensitive/**/*.txt"] .await?; let requirements_file = AbsolutePathBuf::try_from(requirements_file)?; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); - load_requirements_toml( - LOCAL_FS.as_ref(), - &mut config_requirements_toml, - &requirements_file, - ) - .await?; + let config_requirements_toml = load_single_requirements_toml(&requirements_file).await?; let permissions = config_requirements_toml .permissions @@ -1360,44 +1315,26 @@ deny_read = ["./sensitive/**/*.txt"] } #[tokio::test] -async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> { +async fn load_config_layers_includes_cloud_config_bundle() -> anyhow::Result<()> { let tmp = tempdir()?; let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; - let requirements = ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - guardian_policy_config: None, - }; - let expected = requirements.clone(); - let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) }); + let requirements = r#"allowed_approval_policies = ["never"]"#; + let expected: ConfigRequirementsToml = toml::from_str(requirements)?; + let cloud_config_bundle = + CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements); let layers = load_config_layers_state( LOCAL_FS.as_ref(), &codex_home, Some(cwd), &[] as &[(String, TomlValue)], - LoaderOverrides::default(), - cloud_requirements, + ConfigLoadOptions { + cloud_config_bundle, + ..Default::default() + }, &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1415,7 +1352,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> field_name: "approval_policy", candidate: "OnRequest".into(), allowed: "[Never]".into(), - requirement_source: RequirementSource::CloudRequirements, + requirement_source: cloud_config_bundle_requirement_source(), }) ); @@ -1680,6 +1617,88 @@ extends = ":workspace" Ok(()) } +#[tokio::test] +async fn load_config_layers_inserts_cloud_config_between_system_and_user() -> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"model = "user" +"#, + ) + .await?; + + let system_config_path = tmp.path().join("system_config.toml"); + tokio::fs::write( + &system_config_path, + r#"model = "system" +model_provider = "system-provider" +review_model = "system-review" +"#, + ) + .await?; + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_config_path = Some(system_config_path.clone()); + + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + ConfigLoadOptions { + loader_overrides: overrides, + cloud_config_bundle: CloudConfigBundleFixture::loader_with_enterprise_config( + r#"model = "cloud" +model_provider = "cloud-provider" +"#, + ), + ..Default::default() + }, + &codex_config::NoopThreadConfigLoader, + ) + .await?; + + let merged = layers.effective_config(); + let table = merged.as_table().expect("merged config should be a table"); + assert_eq!(table.get("model"), Some(&TomlValue::String("user".into()))); + assert_eq!( + table.get("model_provider"), + Some(&TomlValue::String("cloud-provider".into())) + ); + assert_eq!( + table.get("review_model"), + Some(&TomlValue::String("system-review".into())) + ); + assert_eq!( + layers + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ false, + ) + .iter() + .map(|layer| layer.name.clone()) + .collect::>(), + vec![ + ConfigLayerSource::System { + file: AbsolutePathBuf::from_absolute_path(&system_config_path)?, + }, + ConfigLayerSource::EnterpriseManaged { + id: "cfg_1".to_string(), + name: "Base config".to_string(), + }, + ConfigLayerSource::User { + file: AbsolutePathBuf::from_absolute_path(codex_home.join(CONFIG_TOML_FILE))?, + profile: None, + }, + ] + ); + + Ok(()) +} + #[tokio::test] async fn load_config_layers_can_ignore_managed_requirements() -> anyhow::Result<()> { let tmp = tempdir()?; @@ -1706,18 +1725,15 @@ async fn load_config_layers_can_ignore_managed_requirements() -> anyhow::Result< overrides.system_requirements_path = Some(system_requirements_path); overrides.ignore_managed_requirements = true; - let cloud_requirements = CloudRequirementsLoader::new(async { - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - ..Default::default() - })) - }); + let cloud_config_bundle = CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approval_policies = ["never"]"#, + ); let mut config = ConfigBuilder::default() .codex_home(codex_home) .fallback_cwd(Some(cwd.to_path_buf())) .loader_overrides(overrides) - .cloud_requirements(cloud_requirements) + .cloud_config_bundle(cloud_config_bundle) .build() .await?; @@ -1747,36 +1763,36 @@ async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result tokio::fs::create_dir_all(&managed_dir).await?; let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; - let requirements = ConfigRequirementsToml { - hooks: Some(codex_config::ManagedHooksRequirementsToml { - managed_dir: Some(managed_dir.clone()), - windows_managed_dir: None, - hooks: codex_config::HookEventsToml { - pre_tool_use: vec![codex_config::MatcherGroup { - matcher: Some("^Bash$".to_string()), - hooks: vec![codex_config::HookHandlerConfig::Command { - command: format!("python3 {}/pre.py", managed_dir.display()), - command_windows: None, - timeout_sec: Some(10), - r#async: false, - status_message: Some("checking".to_string()), - }], - }], - ..Default::default() - }, - }), - ..ConfigRequirementsToml::default() - }; - let expected = requirements.clone(); - let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) }); + let requirements = format!( + r#" +[hooks] +managed_dir = '{}' + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = 'python3 {}/pre.py' +timeout = 10 +statusMessage = "checking" +"#, + managed_dir.display(), + managed_dir.display() + ); + let expected: ConfigRequirementsToml = toml::from_str(&requirements)?; + let cloud_config_bundle = + CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements); let layers = load_config_layers_state( LOCAL_FS.as_ref(), &codex_home, Some(cwd), &[] as &[(String, TomlValue)], - LoaderOverrides::default(), - cloud_requirements, + ConfigLoadOptions { + cloud_config_bundle, + ..Default::default() + }, &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1788,12 +1804,94 @@ async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result .managed_hooks .as_ref() .map(|hooks| hooks.source.clone()), - Some(Some(RequirementSource::CloudRequirements)) + Some(Some(cloud_config_bundle_requirement_source())) ); Ok(()) } +#[tokio::test] +async fn load_config_layers_resolves_relative_bundle_requirements_paths_against_codex_home() +-> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + + let requirements = r#" +[permissions.filesystem] +deny_read = ["secrets/**"] +"#; + let cloud_config_bundle = + CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements); + + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + ConfigLoadOptions { + loader_overrides: LoaderOverrides::without_managed_config_for_tests(), + cloud_config_bundle, + ..Default::default() + }, + &codex_config::NoopThreadConfigLoader, + ) + .await?; + + let permissions = layers + .requirements_toml() + .permissions + .clone() + .expect("permissions requirements should load"); + let filesystem = permissions + .filesystem + .expect("filesystem requirements should load"); + + assert_eq!( + filesystem.deny_read, + Some(vec![ + FilesystemDenyReadPattern::from_input(&format!("{}/secrets/**", codex_home.display())) + .expect("bundle requirements path should resolve against codex_home") + ]) + ); + + Ok(()) +} + +#[tokio::test] +async fn strict_config_rejects_unknown_cloud_config_key() { + let tmp = tempdir().expect("tempdir"); + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home) + .await + .expect("create codex home"); + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path()).expect("cwd"); + + let err = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + ConfigLoadOptions { + loader_overrides: LoaderOverrides::without_managed_config_for_tests(), + strict_config: true, + cloud_config_bundle: CloudConfigBundleFixture::loader_with_enterprise_config( + "unknown_key = true", + ), + }, + &codex_config::NoopThreadConfigLoader, + ) + .await + .expect_err("strict config should reject unknown cloud config keys"); + + assert!( + err.to_string() + .contains("unknown configuration field `unknown_key`"), + "{err:?}" + ); +} + #[tokio::test] async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow::Result<()> { let tmp = tempdir()?; @@ -1801,23 +1899,24 @@ async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow:: tokio::fs::create_dir_all(&codex_home).await?; let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; - let requirements: ConfigRequirementsToml = toml::from_str( - r#" + let requirements = r#" allowed_sandbox_modes = ["read-only"] [[remote_sandbox_config]] hostname_patterns = ["*"] allowed_sandbox_modes = ["read-only", "workspace-write"] - "#, - )?; - let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) }); + "#; + let cloud_config_bundle = + CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements); let layers = load_config_layers_state( LOCAL_FS.as_ref(), &codex_home, Some(cwd), &[] as &[(String, TomlValue)], - LoaderOverrides::default(), - cloud_requirements, + ConfigLoadOptions { + cloud_config_bundle, + ..Default::default() + }, &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1841,7 +1940,7 @@ async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow:: } #[tokio::test] -async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyhow::Result<()> { +async fn load_config_layers_fails_when_cloud_config_bundle_loader_fails() -> anyhow::Result<()> { let tmp = tempdir()?; let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; @@ -1852,21 +1951,23 @@ async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyh &codex_home, Some(cwd), &[] as &[(String, TomlValue)], - LoaderOverrides::default(), - CloudRequirementsLoader::new(async { - Err(CloudRequirementsLoadError::new( - codex_config::CloudRequirementsLoadErrorCode::RequestFailed, - /*status_code*/ None, - "cloud requirements failed", - )) - }), + ConfigLoadOptions { + cloud_config_bundle: CloudConfigBundleLoader::new(async { + Err(CloudConfigBundleLoadError::new( + codex_config::CloudConfigBundleLoadErrorCode::RequestFailed, + /*status_code*/ None, + "cloud config bundle failed", + )) + }), + ..Default::default() + }, &codex_config::NoopThreadConfigLoader, ) .await - .expect_err("cloud requirements failure should fail closed"); + .expect_err("cloud config bundle failure should fail closed"); assert_eq!(err.kind(), std::io::ErrorKind::Other); - assert!(err.to_string().contains("cloud requirements failed")); + assert!(err.to_string().contains("cloud config bundle failed")); Ok(()) } @@ -1909,7 +2010,6 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1992,7 +2092,6 @@ async fn linked_worktree_project_layers_keep_worktree_config_but_use_root_repo_h Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2074,7 +2173,6 @@ async fn linked_worktree_project_layers_use_root_repo_hooks_without_worktree_con Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2145,7 +2243,6 @@ async fn nested_project_root_markers_do_not_redirect_regular_repo_hooks() -> std Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2333,7 +2430,6 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2374,7 +2470,6 @@ async fn codex_home_is_not_loaded_as_project_layer_from_home_dir() -> std::io::R Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2440,7 +2535,6 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2516,7 +2610,6 @@ profile = "ignored" Some(cwd.clone()), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2563,7 +2656,6 @@ profile = "ignored" Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2652,7 +2744,6 @@ wire_api = "responses" Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2754,7 +2845,6 @@ async fn project_trust_does_not_match_configured_alias_for_canonical_cwd() -> st Some(AbsolutePathBuf::from_absolute_path(&project_root)?), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2920,7 +3010,6 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io:: Some(cwd.clone()), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2989,7 +3078,6 @@ async fn project_layer_without_config_toml_is_disabled_when_untrusted_or_unknown Some(cwd.clone()), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -3050,7 +3138,6 @@ async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io Some(cwd), &cli_overrides, LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -3096,7 +3183,6 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<() Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 65d20d94f0..795057963e 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -104,6 +104,7 @@ use rmcp::model::ElicitationCapability; use rmcp::model::FormElicitationCapability; use rmcp::model::UrlElicitationCapability; +use codex_config::test_support::CloudConfigBundleFixture; use std::collections::BTreeMap; use std::collections::HashMap; use std::path::Path; @@ -1389,15 +1390,14 @@ async fn experimental_network_requirements_enable_proxy_without_feature() -> std let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - network: Some(codex_config::NetworkRequirementsToml { - enabled: Some(true), - ..Default::default() - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[experimental_network] +enabled = true +"#, + ), + ) .build() .await?; @@ -1867,7 +1867,7 @@ async fn managed_unrestricted_permission_profile_still_enables_network_requireme enabled: Some(true), ..Default::default() }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )); let mut requirements_toml = config.config_layer_stack.requirements_toml().clone(); requirements_toml.network = Some(codex_config::NetworkRequirementsToml { @@ -3932,7 +3932,7 @@ fn filter_plugin_mcp_servers_by_allowlist_enforces_plugin_and_identity_rules() { http_mcp("https://example.com/mcp"), ), ]); - let source = RequirementSource::CloudRequirements; + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let requirements = Sourced::new( BTreeMap::from([( "sample@test".to_string(), @@ -3982,7 +3982,7 @@ fn filter_plugin_mcp_servers_by_allowlist_enforces_plugin_and_identity_rules() { #[test] fn filter_plugin_mcp_servers_by_allowlist_blocks_unlisted_plugin() { let mut servers = HashMap::from([("server-a".to_string(), stdio_mcp("cmd-a"))]); - let source = RequirementSource::CloudRequirements; + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let requirements = Sourced::new( BTreeMap::from([( "other@test".to_string(), @@ -4385,7 +4385,7 @@ enabled = true } #[tokio::test] -async fn to_mcp_config_applies_plugin_mcp_cloud_requirements() -> anyhow::Result<()> { +async fn to_mcp_config_applies_plugin_mcp_cloud_config_bundle() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let plugin_root = codex_home .path() @@ -4422,27 +4422,16 @@ enabled = true "#, )?; - let requirements = codex_config::ConfigRequirementsToml { - plugins: Some(BTreeMap::from([( - "sample@test".to_string(), - codex_config::PluginRequirementsToml { - mcp_servers: Some(BTreeMap::from([( - "sample".to_string(), - McpServerRequirement { - identity: McpServerIdentity::Url { - url: "https://sample.example/mcp".to_string(), - }, - }, - )])), - }, - )])), - ..Default::default() - }; let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[plugins."sample@test".mcp_servers.sample.identity] +url = "https://sample.example/mcp" +"#, + ), + ) .build() .await?; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); @@ -4463,7 +4452,10 @@ enabled = true Some(( false, Some(McpServerDisabledReason::Requirements { - source: RequirementSource::CloudRequirements, + source: RequirementSource::EnterpriseManaged { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + }, }) )) ); @@ -4504,15 +4496,15 @@ enabled = true "#, )?; - let requirements = codex_config::ConfigRequirementsToml { - mcp_servers: Some(BTreeMap::new()), - ..Default::default() - }; let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[mcp_servers] +"#, + ), + ) .build() .await?; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); @@ -4526,7 +4518,10 @@ enabled = true Some(( false, Some(McpServerDisabledReason::Requirements { - source: RequirementSource::CloudRequirements, + source: RequirementSource::EnterpriseManaged { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + }, }) )) ); @@ -5144,7 +5139,6 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> { Some(cwd), &Vec::new(), overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -5278,7 +5272,6 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> { Some(cwd), &[("model".to_string(), TomlValue::String("cli".to_string()))], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -9000,12 +8993,11 @@ async fn requirements_disallowing_default_sandbox_falls_back_to_required_default let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await?; assert_eq!( @@ -9024,35 +9016,14 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s "#, )?; - let requirements = codex_config::ConfigRequirementsToml { - allowed_approval_policies: None, - allowed_approvals_reviewers: None, - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - guardian_policy_config: None, - }; - let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await?; assert_eq!( @@ -9072,21 +9043,16 @@ sandbox = "unelevated" "#, )?; - let requirements = codex_config::ConfigRequirementsToml { - windows: Some(codex_config::WindowsRequirementsToml { - allowed_sandbox_implementations: Some(vec![ - codex_config::types::WindowsSandboxModeToml::Elevated, - ]), - }), - ..Default::default() - }; - let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"[windows] +allowed_sandbox_implementations = ["elevated"] +"#, + ), + ) .build() .await?; @@ -9117,12 +9083,11 @@ sandbox_mode = "danger-full-access" let err = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await .expect_err("requirements-constrained yolo should require sandbox approval"); @@ -9152,12 +9117,11 @@ default_permissions = "dev" let err = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await .expect_err("requirements-constrained full-access profile should require sandbox approval"); @@ -9174,11 +9138,6 @@ default_permissions = "dev" async fn permission_profile_override_falls_back_when_disallowed_by_requirements() -> std::io::Result<()> { let codex_home = TempDir::new()?; - let requirements = codex_config::ConfigRequirementsToml { - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - ..Default::default() - }; - let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) @@ -9186,9 +9145,11 @@ async fn permission_profile_override_falls_back_when_disallowed_by_requirements( permission_profile: Some(PermissionProfile::Disabled), ..Default::default() }) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await?; @@ -9204,11 +9165,6 @@ async fn permission_profile_override_falls_back_when_disallowed_by_requirements( #[tokio::test] async fn active_profile_is_cleared_when_requirements_force_fallback() -> std::io::Result<()> { let codex_home = TempDir::new()?; - let requirements = codex_config::ConfigRequirementsToml { - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - ..Default::default() - }; - let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) @@ -9216,9 +9172,11 @@ async fn active_profile_is_cleared_when_requirements_force_fallback() -> std::io default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string()), ..Default::default() }) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await?; @@ -9328,14 +9286,11 @@ async fn requirements_web_search_mode_overrides_danger_full_access_default() -> let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_web_search_modes: Some(vec![ - codex_config::WebSearchModeRequirement::Cached, - ]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_web_search_modes = ["cached"]"#, + ), + ) .build() .await?; @@ -9369,12 +9324,11 @@ trust_level = "untrusted" let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(workspace.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approval_policies = ["on-request"]"#, + ), + ) .build() .await?; @@ -9398,12 +9352,11 @@ async fn explicit_approval_policy_falls_back_when_disallowed_by_requirements() - let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approval_policies = ["on-request"]"#, + ), + ) .build() .await?; assert_eq!( @@ -9419,17 +9372,15 @@ async fn feature_requirements_normalize_effective_feature_values() -> std::io::R let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([ - ("personality".to_string(), true), - ("shell_tool".to_string(), false), - ]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +personality = true +shell_tool = false +"#, + ), + ) .build() .await?; @@ -9453,14 +9404,14 @@ async fn feature_requirements_auto_review_disables_guardian_approval() -> std::i let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([("auto_review".to_string(), false)]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +auto_review = false +"#, + ), + ) .build() .await?; @@ -9475,17 +9426,15 @@ async fn browser_feature_requirements_are_valid() -> std::io::Result<()> { let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([ - ("in_app_browser".to_string(), false), - ("browser_use".to_string(), false), - ]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +in_app_browser = false +browser_use = false +"#, + ), + ) .build() .await?; @@ -9581,17 +9530,15 @@ shell_tool = true let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([ - ("personality".to_string(), true), - ("shell_tool".to_string(), false), - ]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +personality = true +shell_tool = false +"#, + ), + ) .build() .await?; @@ -9701,12 +9648,11 @@ async fn requirements_disallowing_default_approvals_reviewer_falls_back_to_requi let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approvals_reviewers = ["guardian_subagent"]"#, + ), + ) .build() .await?; @@ -9727,12 +9673,11 @@ async fn root_approvals_reviewer_falls_back_when_disallowed_by_requirements() -> let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approvals_reviewers = ["guardian_subagent"]"#, + ), + ) .build() .await?; @@ -9748,6 +9693,37 @@ async fn root_approvals_reviewer_falls_back_when_disallowed_by_requirements() -> Ok(()) } +#[tokio::test] +async fn profile_approvals_reviewer_falls_back_when_disallowed_by_requirements() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + let selected_config = codex_home.path().join("default.config.toml"); + std::fs::write( + &selected_config, + r#"approvals_reviewer = "user" +"#, + )?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .loader_overrides(LoaderOverrides { + user_config_path: Some(selected_config.abs()), + user_config_profile: Some("default".parse().expect("profile-v2 name")), + ..LoaderOverrides::without_managed_config_for_tests() + }) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approvals_reviewers = ["guardian_subagent"]"#, + ), + ) + .build() + .await?; + + assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview); + Ok(()) +} + #[tokio::test] async fn approvals_reviewer_preserves_valid_user_choice_when_allowed_by_requirements() -> std::io::Result<()> { @@ -9761,15 +9737,11 @@ async fn approvals_reviewer_preserves_valid_user_choice_when_allowed_by_requirem let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ - ApprovalsReviewer::User, - ApprovalsReviewer::AutoReview, - ]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approvals_reviewers = ["user", "guardian_subagent"]"#, + ), + ) .build() .await?; @@ -10255,17 +10227,15 @@ async fn feature_requirements_normalize_runtime_feature_mutations() -> std::io:: let mut config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([ - ("personality".to_string(), true), - ("shell_tool".to_string(), false), - ]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +personality = true +shell_tool = false +"#, + ), + ) .build() .await?; @@ -10291,14 +10261,14 @@ async fn feature_requirements_warn_on_collab_legacy_alias() -> std::io::Result<( let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([("collab".to_string(), true)]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +collab = true +"#, + ), + ) .build() .await?; @@ -10321,14 +10291,14 @@ async fn feature_requirements_warn_and_ignore_unknown_feature() -> std::io::Resu let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([("made_up_feature".to_string(), true)]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +made_up_feature = true +"#, + ), + ) .build() .await?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 051064f279..9506819d03 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -7,7 +7,7 @@ use crate::unified_exec::MIN_EMPTY_YIELD_TIME_MS; use crate::windows_sandbox::WindowsSandboxLevelExt; use crate::windows_sandbox::resolve_windows_sandbox_mode; use crate::windows_sandbox::resolve_windows_sandbox_private_desktop; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; @@ -1112,7 +1112,7 @@ pub struct ConfigBuilder { harness_overrides: Option, loader_overrides: Option, strict_config: bool, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, thread_config_loader: Option>, fallback_cwd: Option, } @@ -1143,8 +1143,8 @@ impl ConfigBuilder { self } - pub fn cloud_requirements(mut self, cloud_requirements: CloudRequirementsLoader) -> Self { - self.cloud_requirements = cloud_requirements; + pub fn cloud_config_bundle(mut self, cloud_config_bundle: CloudConfigBundleLoader) -> Self { + self.cloud_config_bundle = cloud_config_bundle; self } @@ -1173,7 +1173,7 @@ impl ConfigBuilder { harness_overrides, loader_overrides, strict_config, - cloud_requirements, + cloud_config_bundle, thread_config_loader, fallback_cwd, } = self; @@ -1198,8 +1198,8 @@ impl ConfigBuilder { ConfigLoadOptions { loader_overrides, strict_config, + cloud_config_bundle, }, - cloud_requirements, thread_config_loader .as_deref() .unwrap_or(&codex_config::NoopThreadConfigLoader), @@ -1623,7 +1623,6 @@ pub async fn load_config_as_toml_with_cli_and_load_options( cwd.cloned(), &cli_overrides, options, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1834,7 +1833,6 @@ pub async fn load_global_mcp_servers( cwd, &cli_overrides, LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index c01fd5467a..0427ce5a4a 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -5,10 +5,10 @@ use codex_config::AppRequirementToml; use codex_config::AppToolRequirementToml; use codex_config::AppToolsRequirementsToml; use codex_config::AppsRequirementsToml; -use codex_config::CloudRequirementsLoader; use codex_config::ConfigLayerStack; use codex_config::ConfigRequirements; use codex_config::ConfigRequirementsToml; +use codex_config::test_support::CloudConfigBundleFixture; use codex_config::types::AppConfig; use codex_config::types::AppToolConfig; use codex_config::types::AppToolsConfig; @@ -455,15 +455,13 @@ approvals_reviewer = "user" "#, ) .expect("write config"); - let requirements = ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), - ..Default::default() - }; let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approvals_reviewers = ["auto_review"]"#, + ), + ) .build() .await .expect("config should build"); @@ -541,7 +539,7 @@ fn requirements_enabled_does_not_override_disabled_connector() { } #[tokio::test] -async fn cloud_requirements_disable_connector_overrides_user_apps_config() { +async fn cloud_config_bundle_disable_connector_overrides_user_apps_config() { let codex_home = tempdir().expect("tempdir should succeed"); std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -552,25 +550,17 @@ enabled = true ) .expect("write config"); - let requirements = ConfigRequirementsToml { - apps: Some(AppsRequirementsToml { - apps: BTreeMap::from([( - "connector_123123".to_string(), - AppRequirementToml { - enabled: Some(false), - tools: None, - }, - )]), - }), - ..Default::default() - }; - let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[apps.connector_123123] +enabled = false +"#, + ), + ) .build() .await .expect("config should build"); @@ -592,29 +582,21 @@ enabled = true } #[tokio::test] -async fn cloud_requirements_disable_connector_applies_without_user_apps_table() { +async fn cloud_config_bundle_disable_connector_applies_without_user_apps_table() { let codex_home = tempdir().expect("tempdir should succeed"); std::fs::write(codex_home.path().join(CONFIG_TOML_FILE), "").expect("write config"); - let requirements = ConfigRequirementsToml { - apps: Some(AppsRequirementsToml { - apps: BTreeMap::from([( - "connector_123123".to_string(), - AppRequirementToml { - enabled: Some(false), - tools: None, - }, - )]), - }), - ..Default::default() - }; - let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[apps.connector_123123] +enabled = false +"#, + ), + ) .build() .await .expect("config should build"); @@ -870,7 +852,7 @@ fn managed_app_tool_approval_uses_raw_tool_name() { } #[tokio::test] -async fn cloud_requirements_tool_approval_overrides_user_apps_config() { +async fn cloud_config_bundle_tool_approval_overrides_user_apps_config() { let codex_home = tempdir().expect("tempdir should succeed"); std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -881,21 +863,17 @@ approval_mode = "prompt" ) .expect("write config"); - let requirements = ConfigRequirementsToml { - apps: Some(app_tool_requirements( - "connector_123123", - "calendar/list_events", - AppToolApproval::Approve, - )), - ..Default::default() - }; - let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[apps.connector_123123.tools."calendar/list_events"] +approval_mode = "approve" +"#, + ), + ) .build() .await .expect("config should build"); diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index a2d5fdaa19..b91eaa26ca 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -10,7 +10,6 @@ use anyhow::Result; use async_trait::async_trait; use codex_app_server_protocol::ConfigLayerSource; use codex_config::CONFIG_TOML_FILE; -use codex_config::CloudRequirementsLoader; use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; use codex_config::LoaderOverrides; @@ -60,7 +59,6 @@ async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec an enabled: Some(true), ..Default::default() }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )); let mut requirements_toml = config.config_layer_stack.requirements_toml().clone(); requirements_toml.network = Some(codex_config::NetworkRequirementsToml { @@ -7101,7 +7101,7 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes( }), ..Default::default() }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )); let layers = config .config_layer_stack diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 3d7fd21228..be07b803f8 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -8,10 +8,9 @@ use ctor::ctor; use std::sync::OnceLock; use tempfile::TempDir; -use codex_config::CloudRequirementsLoader; -use codex_config::ConfigRequirementsToml; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; -use codex_config::NetworkRequirementsToml; +use codex_config::test_support::CloudConfigBundleFixture; use codex_core::CodexThread; use codex_core::config::Config; use codex_core::config::ConfigBuilder; @@ -169,40 +168,37 @@ pub fn fetch_dotslash_file( /// temporary directory. Using a per-test directory keeps tests hermetic and /// avoids clobbering a developer’s real `~/.codex`. pub async fn load_default_config_for_test(codex_home: &TempDir) -> Config { - load_default_config_for_test_with_cloud_requirements( + load_default_config_for_test_with_cloud_config_bundle( codex_home, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ) .await } -/// Returns a default `Config` with test-provided cloud requirements applied +/// Returns a default `Config` with test-provided cloud bundle requirements applied. /// during config construction. -pub async fn load_default_config_for_test_with_cloud_requirements( +pub async fn load_default_config_for_test_with_cloud_config_bundle( codex_home: &TempDir, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, ) -> Config { ConfigBuilder::default() .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) .codex_home(codex_home.path().to_path_buf()) .harness_overrides(default_test_overrides()) - .cloud_requirements(cloud_requirements) + .cloud_config_bundle(cloud_config_bundle) .build() .await .expect("defaults for test should always succeed") } -pub fn managed_network_requirements_loader() -> CloudRequirementsLoader { - CloudRequirementsLoader::new(async { - Ok(Some(ConfigRequirementsToml { - network: Some(NetworkRequirementsToml { - enabled: Some(true), - allow_local_binding: Some(true), - ..Default::default() - }), - ..Default::default() - })) - }) +pub fn managed_network_requirements_loader() -> CloudConfigBundleLoader { + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[experimental_network] +enabled = true +allow_local_binding = true +"#, + ) } #[cfg(target_os = "linux")] diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 73e9e0ece8..4fe0609a71 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -12,7 +12,7 @@ use std::time::Duration; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_core::CodexThread; use codex_core::ThreadManager; use codex_core::config::Config; @@ -49,7 +49,7 @@ use crate::PathBufExt; use crate::TempDirExt; use crate::get_remote_test_env; use crate::load_default_config_for_test; -use crate::load_default_config_for_test_with_cloud_requirements; +use crate::load_default_config_for_test_with_cloud_config_bundle; use crate::responses::WebSocketTestServer; use crate::responses::output_value_to_text; use crate::responses::start_mock_server; @@ -213,7 +213,7 @@ pub struct TestCodexBuilder { pre_build_hooks: Vec>, workspace_setups: Vec>, home: Option>, - cloud_requirements: Option, + cloud_config_bundle: Option, user_shell_override: Option, exec_server_url: Option, } @@ -262,8 +262,11 @@ impl TestCodexBuilder { self } - pub fn with_cloud_requirements(mut self, cloud_requirements: CloudRequirementsLoader) -> Self { - self.cloud_requirements = Some(cloud_requirements); + pub fn with_cloud_config_bundle( + mut self, + cloud_config_bundle: CloudConfigBundleLoader, + ) -> Self { + self.cloud_config_bundle = Some(cloud_config_bundle); self } @@ -545,8 +548,8 @@ impl TestCodexBuilder { for hook in self.pre_build_hooks.drain(..) { hook(home.path()); } - let mut config = if let Some(cloud_requirements) = self.cloud_requirements.take() { - load_default_config_for_test_with_cloud_requirements(home, cloud_requirements).await + let mut config = if let Some(cloud_config_bundle) = self.cloud_config_bundle.take() { + load_default_config_for_test_with_cloud_config_bundle(home, cloud_config_bundle).await } else { load_default_config_for_test(home).await }; @@ -1031,7 +1034,7 @@ pub fn test_codex() -> TestCodexBuilder { pre_build_hooks: vec![], workspace_setups: vec![], home: None, - cloud_requirements: None, + cloud_config_bundle: None, user_shell_override: None, exec_server_url: None, } diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index a91196a0b9..aceac7320e 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -2999,7 +2999,7 @@ allow_local_binding = true let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex() .with_home(home) - .with_cloud_requirements(managed_network_requirements_loader()) + .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config @@ -3279,7 +3279,7 @@ allow_local_binding = true }; let mut builder = test_codex() .with_home(home) - .with_cloud_requirements(managed_network_requirements_loader()) + .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); }); @@ -3484,7 +3484,7 @@ allow_local_binding = true }; let mut builder = test_codex() .with_home(home) - .with_cloud_requirements(managed_network_requirements_loader()) + .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); let cwd = config.cwd.clone(); diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index a535eb4b8e..5813508e7a 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -2229,7 +2229,7 @@ allow_local_binding = true panic!("failed to write permission request hook test fixture: {error}"); } }) - .with_cloud_requirements(managed_network_requirements_loader()) + .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { trust_discovered_hooks(config); config.permissions.approval_policy = Constrained::allow_any(approval_policy); diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index ba754b025f..d86a0f620e 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -916,7 +916,7 @@ allow_local_binding = true let permission_profile = permission_profile_for_config.clone(); let mut builder = test_codex() .with_home(home) - .with_cloud_requirements(managed_network_requirements_loader()) + .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { config.use_experimental_unified_exec_tool = true; config diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index e857f358c9..406197d720 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -51,7 +51,8 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStartedNotification; use codex_arg0::Arg0DispatchPaths; -use codex_cloud_config::cloud_requirements_loader_for_storage; +use codex_cloud_config::cloud_config_bundle_loader_for_storage; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadError; use codex_config::ConfigLoadOptions; use codex_config::LoaderOverrides; @@ -330,53 +331,54 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result ..Default::default() }; - let config_toml = match load_config_as_toml_with_cli_and_load_options( + let bootstrap_config_toml = load_config_toml_or_exit( &codex_home, Some(&config_cwd), cli_kv_overrides.clone(), - ConfigLoadOptions { - loader_overrides: loader_overrides.clone(), - strict_config, - }, + loader_overrides.clone(), + strict_config, + CloudConfigBundleLoader::default(), ) - .await - { - Ok(config_toml) => config_toml, - Err(err) => { - let config_error = err - .get_ref() - .and_then(|err| err.downcast_ref::()) - .map(ConfigLoadError::config_error); - if let Some(config_error) = config_error { - eprintln!( - "Error loading config.toml:\n{}", - format_config_error_with_source(config_error) - ); - } else { - eprintln!("Error loading config.toml: {err}"); - } - std::process::exit(1); - } - }; + .await; - let chatgpt_base_url = config_toml + let chatgpt_base_url = bootstrap_config_toml .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); - // TODO(gt): Make cloud requirements failures blocking once we can fail-closed. - let cloud_requirements = cloud_requirements_loader_for_storage( + let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, - config_toml.cli_auth_credentials_store.unwrap_or_default(), + bootstrap_config_toml + .cli_auth_credentials_store + .unwrap_or_default(), chatgpt_base_url, ) .await; let run_cli_overrides = cli_kv_overrides.clone(); let run_loader_overrides = loader_overrides.clone(); - let run_cloud_requirements = cloud_requirements.clone(); + let run_cloud_config_bundle = cloud_config_bundle.clone(); let model_provider = if oss { - let resolved = resolve_oss_provider(oss_provider.as_deref(), &config_toml); + let config_toml_with_cloud_config; + let config_toml_for_oss = if oss_provider.is_none() { + // The first load intentionally skips cloud config so we can read + // auth/base-url settings needed to fetch the bundle. If OSS mode + // needs a default provider from config, reload with the bundle. + config_toml_with_cloud_config = load_config_toml_or_exit( + &codex_home, + Some(&config_cwd), + cli_kv_overrides.clone(), + loader_overrides.clone(), + strict_config, + cloud_config_bundle.clone(), + ) + .await; + &config_toml_with_cloud_config + } else { + &bootstrap_config_toml + }; + + let resolved = resolve_oss_provider(oss_provider.as_deref(), config_toml_for_oss); if let Some(provider) = resolved { Some(provider) @@ -437,7 +439,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result .harness_overrides(overrides) .loader_overrides(loader_overrides.clone()) .strict_config(strict_config) - .cloud_requirements(cloud_requirements.clone()) + .cloud_config_bundle(cloud_config_bundle.clone()) .build() }; let config = build_exec_config( @@ -536,7 +538,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result cli_overrides: run_cli_overrides, loader_overrides: run_loader_overrides, strict_config, - cloud_requirements: run_cloud_requirements, + cloud_config_bundle: run_cloud_config_bundle, feedback: CodexFeedback::new(), log_db: None, state_db: state_db.clone(), @@ -606,6 +608,46 @@ where } } +#[allow(clippy::print_stderr)] +async fn load_config_toml_or_exit( + codex_home: &Path, + cwd: Option<&AbsolutePathBuf>, + cli_kv_overrides: Vec<(String, codex_config::TomlValue)>, + loader_overrides: LoaderOverrides, + strict_config: bool, + cloud_config_bundle: CloudConfigBundleLoader, +) -> codex_config::config_toml::ConfigToml { + match load_config_as_toml_with_cli_and_load_options( + codex_home, + cwd, + cli_kv_overrides, + ConfigLoadOptions { + loader_overrides, + strict_config, + cloud_config_bundle, + }, + ) + .await + { + Ok(config_toml) => config_toml, + Err(err) => { + let config_error = err + .get_ref() + .and_then(|err| err.downcast_ref::()) + .map(ConfigLoadError::config_error); + if let Some(config_error) = config_error { + eprintln!( + "Error loading config.toml:\n{}", + format_config_error_with_source(config_error) + ); + } else { + eprintln!("Error loading config.toml: {err}"); + } + std::process::exit(1); + } + } +} + async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let ExecRunArgs { in_process_start_args, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index d9bebb1b32..8b2f214987 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -578,6 +578,7 @@ async fn thread_lifecycle_params_include_legacy_sandbox_when_no_active_profile() let codex_home = tempdir().expect("create temp codex home"); let cwd = tempdir().expect("create temp cwd"); let config = ConfigBuilder::default() + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) .codex_home(codex_home.path().to_path_buf()) .harness_overrides(ConfigOverrides { sandbox_mode: Some(SandboxMode::DangerFullAccess), diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index fd13753ed5..92d17fbabd 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -274,9 +274,6 @@ fn fallback_managed_hooks_source_path( Some(RequirementSource::MdmManagedPreferences { domain, key }) => { synthetic_layer_path(&format!("/requirements.toml")) } - Some(RequirementSource::CloudRequirements) => { - synthetic_layer_path("/requirements.toml") - } Some(RequirementSource::Composite { .. }) => { synthetic_layer_path("/requirements.toml") } @@ -635,7 +632,6 @@ fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> Hoo Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => { HookSource::LegacyManagedConfigMdm } - Some(RequirementSource::CloudRequirements) => HookSource::CloudRequirements, Some(RequirementSource::Composite { sources }) => { // Requirements hook composition preserves contributing sources in // priority order, but discovery only carries one source for the diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 9b642503a0..4b7697b34f 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -120,12 +120,12 @@ fn requirements_with_managed_hooks_only( ConfigRequirements { allow_managed_hooks_only: Some(Sourced::new( allow_managed_hooks_only, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), managed_hooks: managed_hooks.clone().map(|hooks| { ConstrainedWithSource::new( Constrained::allow_any(hooks), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ) }), ..ConfigRequirements::default() @@ -183,7 +183,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: ConfigRequirements { managed_hooks: Some(ConstrainedWithSource::new( Constrained::allow_any(managed_hooks.clone()), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }, @@ -208,7 +208,10 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: assert!(engine.warnings().is_empty()); assert_eq!(engine.handlers.len(), 1); - assert_eq!(engine.handlers[0].source, HookSource::CloudRequirements); + assert_eq!( + engine.handlers[0].source, + HookSource::LegacyManagedConfigMdm + ); let listed = crate::list_hooks(crate::HooksConfig { legacy_notify_argv: None, feature_enabled: true, @@ -286,7 +289,7 @@ async fn requirements_managed_hooks_execute_windows_command_override() { ConfigRequirements { managed_hooks: Some(ConstrainedWithSource::new( Constrained::allow_any(managed_hooks.clone()), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }, @@ -447,7 +450,7 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { ConfigRequirements { managed_hooks: Some(ConstrainedWithSource::new( Constrained::allow_any(managed_hooks.clone()), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }, @@ -471,7 +474,10 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { ); assert_eq!(engine.handlers.len(), 1); - assert_eq!(engine.handlers[0].source, HookSource::CloudRequirements); + assert_eq!( + engine.handlers[0].source, + HookSource::LegacyManagedConfigMdm + ); let discovered = super::discovery::discover_handlers( Some(&config_layer_stack), Vec::new(), @@ -671,7 +677,7 @@ fn requirements_managed_hooks_load_when_managed_dir_is_missing() { ConfigRequirements { managed_hooks: Some(ConstrainedWithSource::new( Constrained::allow_any(managed_hooks.clone()), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }, @@ -1237,7 +1243,12 @@ print(json.dumps({ assert_eq!(outcome.hook_events.len(), 1); assert_eq!(outcome.hook_events[0].run.source, HookSource::Plugin); - assert_eq!(outcome.hook_events[0].run.status, HookRunStatus::Completed); + assert_eq!( + outcome.hook_events[0].run.status, + HookRunStatus::Completed, + "hook entries: {:#?}", + outcome.hook_events[0].run.entries + ); assert_eq!(outcome.hook_events[0].run.entries.len(), 1); assert_eq!( outcome.hook_events[0].run.entries[0].kind, diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index c9d91eed55..562df95989 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -628,7 +628,7 @@ mod tests { let requirements = ConfigRequirements { approval_policy: ConstrainedWithSource::new( Constrained::allow_any(AskForApproval::OnRequest.to_core()), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ), approvals_reviewer: ConstrainedWithSource::new( Constrained::allow_any(ApprovalsReviewer::AutoReview), @@ -653,25 +653,25 @@ mod tests { )), enforce_residency: ConstrainedWithSource::new( Constrained::allow_any(Some(ResidencyRequirement::Us)), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ), web_search_mode: ConstrainedWithSource::new( Constrained::allow_any(WebSearchMode::Cached), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ), allow_managed_hooks_only: Some(Sourced::new( /*value*/ true, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), allow_appshots: Some(Sourced::new( /*value*/ false, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), feature_requirements: Some(Sourced::new( FeatureRequirementsToml { entries: BTreeMap::from([("guardian_approval".to_string(), true)]), }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), network: Some(Sourced::new( NetworkConstraints { @@ -684,7 +684,7 @@ mod tests { }), ..Default::default() }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), filesystem: Some(Sourced::new( FilesystemConstraints { @@ -694,7 +694,7 @@ mod tests { file: requirements_file.clone(), }, )), - guardian_policy_config_source: Some(RequirementSource::CloudRequirements), + guardian_policy_config_source: Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ..ConfigRequirements::default() }; @@ -749,9 +749,10 @@ mod tests { .expect("config layer stack"); let rendered = render_to_text(&render_debug_config_lines(&stack)); - assert!( - rendered.contains("allowed_approval_policies: on-request (source: cloud requirements)") - ); + let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); + assert!(rendered.contains(&format!( + "allowed_approval_policies: on-request (source: {requirements_source})" + ))); assert!(rendered.contains( "allowed_approvals_reviewers: guardian_subagent (source: MDM managed_config.toml (legacy))" )); @@ -764,22 +765,28 @@ mod tests { .as_str(), ) ); - assert!( - rendered.contains( - "allowed_web_search_modes: cached, disabled (source: cloud requirements)" - ) - ); - assert!(rendered.contains("allow_managed_hooks_only: true (source: cloud requirements)")); - assert!(rendered.contains("allow_appshots: false (source: cloud requirements)")); - assert!( - rendered.contains("guardian_policy_config: configured (source: cloud requirements)") - ); - assert!(rendered.contains("features: guardian_approval=true (source: cloud requirements)")); + assert!(rendered.contains(&format!( + "allowed_web_search_modes: cached, disabled (source: {requirements_source})" + ))); + assert!(rendered.contains(&format!( + "allow_managed_hooks_only: true (source: {requirements_source})" + ))); + assert!(rendered.contains(&format!( + "allow_appshots: false (source: {requirements_source})" + ))); + assert!(rendered.contains(&format!( + "guardian_policy_config: configured (source: {requirements_source})" + ))); + assert!(rendered.contains(&format!( + "features: guardian_approval=true (source: {requirements_source})" + ))); assert!(rendered.contains("mcp_servers: docs (source: MDM managed_config.toml (legacy))")); - assert!(rendered.contains("enforce_residency: us (source: cloud requirements)")); - assert!(rendered.contains( - "experimental_network: enabled=true, domains={example.com=allow} (source: cloud requirements)" - )); + assert!(rendered.contains(&format!( + "enforce_residency: us (source: {requirements_source})" + ))); + assert!(rendered.contains(&format!( + "experimental_network: enabled=true, domains={{example.com=allow}} (source: {requirements_source})" + ))); assert!( rendered.contains( format!( @@ -834,7 +841,7 @@ mod tests { }), ..Default::default() }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), ..ConfigRequirements::default() }; @@ -844,9 +851,10 @@ mod tests { .expect("config layer stack"); let rendered = render_to_text(&render_debug_config_lines(&stack)); - assert!(rendered.contains( - "experimental_network: unix_sockets={/tmp/blocked.sock=deny, /tmp/codex.sock=allow} (source: cloud requirements)" - )); + let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); + assert!(rendered.contains(&format!( + "experimental_network: unix_sockets={{/tmp/blocked.sock=deny, /tmp/codex.sock=allow}} (source: {requirements_source})" + ))); } #[test] @@ -956,7 +964,7 @@ approval_policy = "never" let requirements = ConfigRequirements { web_search_mode: ConstrainedWithSource::new( Constrained::allow_any(WebSearchMode::Disabled), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ), ..ConfigRequirements::default() }; @@ -988,9 +996,10 @@ approval_policy = "never" .expect("config layer stack"); let rendered = render_to_text(&render_debug_config_lines(&stack)); - assert!( - rendered.contains("allowed_web_search_modes: disabled (source: cloud requirements)") - ); + let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); + assert!(rendered.contains(&format!( + "allowed_web_search_modes: disabled (source: {requirements_source})" + ))); } #[test] @@ -1018,7 +1027,7 @@ approval_policy = "never" ..Default::default() }, }), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }; @@ -1033,9 +1042,10 @@ approval_policy = "never" .expect("config layer stack"); let rendered = render_to_text(&render_debug_config_lines(&stack)); + let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); assert!(rendered.contains("hooks:")); assert!(rendered.contains("handlers=1")); - assert!(rendered.contains("(source: cloud requirements)")); + assert!(rendered.contains(&format!("(source: {requirements_source})"))); } #[test] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e9cc88dca1..b53ec0f83d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -38,8 +38,8 @@ use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadSortKey as AppServerThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; -use codex_cloud_config::cloud_requirements_loader_for_storage; -use codex_config::CloudRequirementsLoader; +use codex_cloud_config::cloud_config_bundle_loader_for_storage; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadError; use codex_config::LoaderOverrides; use codex_config::format_config_error_with_source; @@ -298,7 +298,7 @@ async fn start_embedded_app_server( cli_kv_overrides: Vec<(String, toml::Value)>, loader_overrides: LoaderOverrides, strict_config: bool, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, state_db: Option, @@ -310,7 +310,7 @@ async fn start_embedded_app_server( cli_kv_overrides, loader_overrides, strict_config, - cloud_requirements, + cloud_config_bundle, feedback, log_db, state_db, @@ -510,7 +510,7 @@ async fn start_app_server( cli_kv_overrides: Vec<(String, toml::Value)>, loader_overrides: LoaderOverrides, strict_config: bool, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, state_db: Option, @@ -523,7 +523,7 @@ async fn start_app_server( cli_kv_overrides, loader_overrides, strict_config, - cloud_requirements, + cloud_config_bundle, feedback, log_db, state_db, @@ -550,7 +550,7 @@ pub(crate) async fn start_app_server_for_picker( Vec::new(), LoaderOverrides::default(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, state_db, @@ -584,7 +584,7 @@ async fn start_embedded_app_server_with( cli_kv_overrides: Vec<(String, toml::Value)>, loader_overrides: LoaderOverrides, strict_config: bool, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, state_db: Option, @@ -611,7 +611,7 @@ where cli_overrides: cli_kv_overrides, loader_overrides, strict_config, - cloud_requirements, + cloud_config_bundle, feedback, log_db, state_db, @@ -977,51 +977,58 @@ pub async fn run_main( loader_overrides.user_config_profile = Some(profile_v2.clone()); } - #[allow(clippy::print_stderr)] - let config_toml = match load_config_as_toml_with_cli_and_load_options( + let bootstrap_config_toml = load_config_toml_or_exit( &codex_home, config_cwd.as_ref(), cli_kv_overrides.clone(), - codex_config::ConfigLoadOptions { - loader_overrides: loader_overrides.clone(), - strict_config, - }, + loader_overrides.clone(), + strict_config, + CloudConfigBundleLoader::default(), ) - .await - { - Ok(config_toml) => config_toml, - Err(err) => { - let config_error = err - .get_ref() - .and_then(|err| err.downcast_ref::()) - .map(ConfigLoadError::config_error); - if let Some(config_error) = config_error { - eprintln!( - "Error loading config.toml:\n{}", - format_config_error_with_source(config_error) - ); - } else { - eprintln!("Error loading config.toml: {err}"); - } - std::process::exit(1); - } - }; + .await; - let chatgpt_base_url = config_toml + let chatgpt_base_url = bootstrap_config_toml .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); - let cloud_requirements = cloud_requirements_loader_for_storage( + let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, - config_toml.cli_auth_credentials_store.unwrap_or_default(), + bootstrap_config_toml + .cli_auth_credentials_store + .unwrap_or_default(), chatgpt_base_url, ) .await; + let cwd_override = if app_server_target.uses_remote_workspace() { + None + } else { + cwd.clone() + }; + let mut manually_selected_oss_provider = None; let model_provider_override = if cli.oss { - let resolved = resolve_oss_provider(cli.oss_provider.as_deref(), &config_toml); + let config_toml_with_cloud_config; + let config_toml_for_oss = if cli.oss_provider.is_none() { + // The first load intentionally skips cloud config so we can read + // auth/base-url settings needed to fetch the bundle. If OSS mode + // needs a default provider from config, reload with the bundle. + config_toml_with_cloud_config = load_config_toml_or_exit( + &codex_home, + config_cwd.as_ref(), + cli_kv_overrides.clone(), + loader_overrides.clone(), + strict_config, + cloud_config_bundle.clone(), + ) + .await; + &config_toml_with_cloud_config + } else { + &bootstrap_config_toml + }; + + let resolved = resolve_oss_provider(cli.oss_provider.as_deref(), config_toml_for_oss); if let Some(provider) = resolved { Some(provider) @@ -1062,11 +1069,7 @@ pub async fn run_main( model, approval_policy, sandbox_mode, - cwd: if app_server_target.uses_remote_workspace() { - None - } else { - cwd - }, + cwd: cwd_override, model_provider: model_provider_override.clone(), codex_self_exe: arg0_paths.codex_self_exe.clone(), codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), @@ -1081,7 +1084,7 @@ pub async fn run_main( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - cloud_requirements.clone(), + cloud_config_bundle.clone(), strict_config, ) .await; @@ -1137,7 +1140,7 @@ pub async fn run_main( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - cloud_requirements.clone(), + cloud_config_bundle.clone(), strict_config, ) .await; @@ -1156,6 +1159,11 @@ pub async fn run_main( tracing::warn!(error = %err, "failed to deserialize config for personality migration"); } } + let config_toml_log_dir_configured = config + .config_layer_stack + .effective_config() + .as_table() + .is_some_and(|table| table.contains_key("log_dir")); #[allow(clippy::print_stderr)] match check_execpolicy_for_warnings(&config.config_layer_stack).await { @@ -1199,7 +1207,7 @@ pub async fn run_main( } } - let (tui_file_layer, _tui_file_log_guard) = if config_toml.log_dir.is_some() { + let (tui_file_layer, _tui_file_log_guard) = if config_toml_log_dir_configured { let log_dir = config.log_dir.clone(); std::fs::create_dir_all(&log_dir)?; let mut log_file_opts = OpenOptions::new(); @@ -1281,7 +1289,7 @@ pub async fn run_main( manually_selected_oss_provider, overrides, cli_kv_overrides, - cloud_requirements, + cloud_config_bundle, feedback, log_db, state_db, @@ -1303,7 +1311,7 @@ async fn run_ratatui_app( manually_selected_oss_provider: Option, overrides: ConfigOverrides, cli_kv_overrides: Vec<(String, toml::Value)>, - mut cloud_requirements: CloudRequirementsLoader, + mut cloud_config_bundle: CloudConfigBundleLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, state_db: Option, @@ -1365,7 +1373,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), loader_overrides.clone(), strict_config, - cloud_requirements.clone(), + cloud_config_bundle.clone(), feedback.clone(), log_db.clone(), state_db.clone(), @@ -1448,11 +1456,11 @@ async fn run_ratatui_app( { trust_decision_was_made = onboarding_result.directory_trust_persisted; } - // 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 + // If this onboarding run included the login step, always refresh the cloud config bundle + // and rebuild config. This avoids missing newly available cloud-managed policy due to login // status detection edge cases. if show_login_screen && !uses_remote_workspace { - cloud_requirements = cloud_requirements_loader_for_storage( + cloud_config_bundle = cloud_config_bundle_loader_for_storage( initial_config.codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, initial_config.cli_auth_credentials_store_mode, @@ -1470,7 +1478,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - cloud_requirements.clone(), + cloud_config_bundle.clone(), strict_config, ) .await @@ -1674,7 +1682,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - cloud_requirements.clone(), + cloud_config_bundle.clone(), strict_config, fallback_cwd, ) @@ -1685,7 +1693,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - cloud_requirements.clone(), + cloud_config_bundle.clone(), strict_config, ) .await @@ -1745,7 +1753,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), loader_overrides.clone(), strict_config, - cloud_requirements.clone(), + cloud_config_bundle.clone(), feedback.clone(), log_db.clone(), state_db.clone(), @@ -1902,14 +1910,14 @@ async fn load_config_or_exit( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, loader_overrides: LoaderOverrides, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, strict_config: bool, ) -> Config { load_config_or_exit_with_fallback_cwd( cli_kv_overrides, overrides, loader_overrides, - cloud_requirements, + cloud_config_bundle, strict_config, /*fallback_cwd*/ None, ) @@ -1920,7 +1928,7 @@ async fn load_config_or_exit_with_fallback_cwd( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, loader_overrides: LoaderOverrides, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, strict_config: bool, fallback_cwd: Option, ) -> Config { @@ -1930,7 +1938,7 @@ async fn load_config_or_exit_with_fallback_cwd( .harness_overrides(overrides) .loader_overrides(loader_overrides) .strict_config(strict_config) - .cloud_requirements(cloud_requirements) + .cloud_config_bundle(cloud_config_bundle) .fallback_cwd(fallback_cwd) .build() .await @@ -1943,6 +1951,46 @@ async fn load_config_or_exit_with_fallback_cwd( } } +#[allow(clippy::print_stderr)] +async fn load_config_toml_or_exit( + codex_home: &Path, + cwd: Option<&AbsolutePathBuf>, + cli_kv_overrides: Vec<(String, codex_config::TomlValue)>, + loader_overrides: LoaderOverrides, + strict_config: bool, + cloud_config_bundle: CloudConfigBundleLoader, +) -> codex_config::config_toml::ConfigToml { + match load_config_as_toml_with_cli_and_load_options( + codex_home, + cwd, + cli_kv_overrides, + codex_config::ConfigLoadOptions { + loader_overrides, + strict_config, + cloud_config_bundle, + }, + ) + .await + { + Ok(config_toml) => config_toml, + Err(err) => { + let config_error = err + .get_ref() + .and_then(|err| err.downcast_ref::()) + .map(ConfigLoadError::config_error); + if let Some(config_error) = config_error { + eprintln!( + "Error loading config.toml:\n{}", + format_config_error_with_source(config_error) + ); + } else { + eprintln!("Error loading config.toml: {err}"); + } + std::process::exit(1); + } + } +} + /// Determine if the user has decided whether to trust the current directory. fn should_show_trust_screen(config: &Config) -> bool { config.active_project.trust_level.is_none() @@ -2017,7 +2065,7 @@ mod tests { Vec::new(), LoaderOverrides::default(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, state_db, @@ -2771,7 +2819,7 @@ mod tests { Vec::new(), LoaderOverrides::default(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, /*state_db*/ None, diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index fa3561a7fa..0d8a1fa773 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1012,7 +1012,7 @@ mod tests { use codex_app_server_client::InProcessAppServerClient; use codex_app_server_client::InProcessClientStartArgs; use codex_arg0::Arg0DispatchPaths; - use codex_cloud_config::cloud_requirements_loader_for_storage; + use codex_cloud_config::cloud_config_bundle_loader_for_storage; use codex_config::types::AuthCredentialsStoreMode; use pretty_assertions::assert_eq; @@ -1033,7 +1033,7 @@ mod tests { cli_overrides: Vec::new(), loader_overrides: Default::default(), strict_config: false, - cloud_requirements: cloud_requirements_loader_for_storage( + cloud_config_bundle: cloud_config_bundle_loader_for_storage( codex_home_path.clone(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index 40d33eb2f6..4e5c420f63 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -16,7 +16,8 @@ use codex_app_server_protocol::Thread as AppServerThread; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadSortKey; use codex_arg0::Arg0DispatchPaths; -use codex_cloud_config::cloud_requirements_loader_for_storage; +use codex_cloud_config::cloud_config_bundle_loader_for_storage; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadOptions; use codex_config::LoaderOverrides; use codex_exec_server::EnvironmentManager; @@ -251,6 +252,7 @@ async fn start_app_server_for_archive_command( ConfigLoadOptions { loader_overrides: loader_overrides.clone(), strict_config, + cloud_config_bundle: CloudConfigBundleLoader::default(), }, ) .await @@ -259,7 +261,7 @@ async fn start_app_server_for_archive_command( .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); - let cloud_requirements = cloud_requirements_loader_for_storage( + let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, config_toml.cli_auth_credentials_store.unwrap_or_default(), @@ -298,7 +300,7 @@ async fn start_app_server_for_archive_command( }) .loader_overrides(loader_overrides.clone()) .strict_config(strict_config) - .cloud_requirements(cloud_requirements.clone()) + .cloud_config_bundle(cloud_config_bundle.clone()) .build() .await .wrap_err("failed to load configuration")?; @@ -312,7 +314,7 @@ async fn start_app_server_for_archive_command( cli_kv_overrides, loader_overrides, strict_config, - cloud_requirements, + cloud_config_bundle, codex_feedback::CodexFeedback::new(), /*log_db*/ None, state_db, From c74be11672cdf90cda60869961075965b02f213a Mon Sep 17 00:00:00 2001 From: joeflorencio-openai Date: Tue, 2 Jun 2026 14:11:20 -0700 Subject: [PATCH 37/59] fix: update image generation test helper rename (#25938) ## Summary - update the app-server image generation integration test to use `TestAppServer` - completes the test helper rename from #25701 for this newer test file ## Validation - `cargo fmt -- --config imports_granularity=Item` - `cargo check -p codex-app-server --test all` Note: `just fmt` ran Rust formatting but failed on Python/SDK formatting because the sandbox could not access the local `uv` cache. --- codex-rs/app-server/tests/suite/v2/image_generation.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/image_generation.rs b/codex-rs/app-server/tests/suite/v2/image_generation.rs index 4752f1497e..e1f4cfbc9a 100644 --- a/codex-rs/app-server/tests/suite/v2/image_generation.rs +++ b/codex-rs/app-server/tests/suite/v2/image_generation.rs @@ -4,7 +4,7 @@ use std::time::Duration; use anyhow::Context; use anyhow::Result; use app_test_support::ChatGptAuthFixture; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::ItemCompletedNotification; @@ -76,7 +76,8 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -157,7 +158,7 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> } async fn wait_for_image_generation_completed( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, ) -> Result { loop { let notification = mcp From f6d64bd6ab3bf1a9587bbc13a00711029120d3e9 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 2 Jun 2026 14:16:12 -0700 Subject: [PATCH 38/59] core: stop passing legacy SandboxPolicy to guardian reviews (#25911) ## Why Guardian review turns already submit a read-only `PermissionProfile`, which is the permissions model the runtime should honor. Passing the equivalent legacy `SandboxPolicy` through `ThreadSettingsOverrides` keeps two representations of the same read-only constraint alive on this path and makes the guardian flow depend on compatibility plumbing that is being phased out. ## What Changed - Set `sandbox_policy` to `None` when the guardian review session submits its child `Op::UserInput`. - Keep `permission_profile: Some(PermissionProfile::read_only())` and `approval_policy: Some(AskForApproval::Never)`, so the guardian review remains read-only and cannot request approvals. - Remove the now-unused `SandboxPolicy` import and redundant comment from `codex-rs/core/src/guardian/review_session.rs`. ## Verification Not run locally; this is a narrow cleanup of redundant thread-settings override state. --- codex-rs/core/src/guardian/review_session.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index fed87cd75f..b8c5c98075 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -20,7 +20,6 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; @@ -719,9 +718,7 @@ async fn run_review_on_session( .total_token_usage() .await .unwrap_or_default(); - // The legacy SandboxPolicy should match the PermissionProfile. let guardian_permission_profile = PermissionProfile::read_only(); - let legacy_sandbox_policy = SandboxPolicy::new_read_only_policy(); let submit_result = run_before_review_deadline( deadline, @@ -736,7 +733,7 @@ async fn run_review_on_session( #[allow(deprecated)] cwd: Some(params.parent_turn.cwd.to_path_buf()), approval_policy: Some(AskForApproval::Never), - sandbox_policy: Some(legacy_sandbox_policy), + sandbox_policy: None, permission_profile: Some(guardian_permission_profile), summary: Some(params.reasoning_summary), personality: params.personality, From e7039f98449d0e39687aab14eda4b4383b3bce31 Mon Sep 17 00:00:00 2001 From: joeflorencio-openai Date: Tue, 2 Jun 2026 14:30:12 -0700 Subject: [PATCH 39/59] Split cloud config bundle service modules (#25668) ## Summary - Splits the monolithic `codex-cloud-config` implementation into focused modules. - Keeps behavior unchanged from the preceding config bundle runtime switch. ## Details This is the reviewability follow-up after the lineage-preserving migration PRs. The split separates backend transport, loader construction, cache handling, metrics, validation, service orchestration, and focused tests into named files. Verification: `just fmt`; `just test -p codex-cloud-config`. --- codex-rs/Cargo.lock | 1 - codex-rs/cloud-config/Cargo.toml | 1 - codex-rs/cloud-config/src/backend.rs | 136 ++ codex-rs/cloud-config/src/bundle_loader.rs | 68 + codex-rs/cloud-config/src/cache.rs | 253 +++ codex-rs/cloud-config/src/cache_tests.rs | 206 ++ codex-rs/cloud-config/src/lib.rs | 2226 +------------------- codex-rs/cloud-config/src/metrics.rs | 95 + codex-rs/cloud-config/src/service.rs | 500 +++++ codex-rs/cloud-config/src/service_tests.rs | 1035 +++++++++ codex-rs/cloud-config/src/validation.rs | 34 + 11 files changed, 2336 insertions(+), 2219 deletions(-) create mode 100644 codex-rs/cloud-config/src/backend.rs create mode 100644 codex-rs/cloud-config/src/bundle_loader.rs create mode 100644 codex-rs/cloud-config/src/cache.rs create mode 100644 codex-rs/cloud-config/src/cache_tests.rs create mode 100644 codex-rs/cloud-config/src/metrics.rs create mode 100644 codex-rs/cloud-config/src/service.rs create mode 100644 codex-rs/cloud-config/src/service_tests.rs create mode 100644 codex-rs/cloud-config/src/validation.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e7bb18b5f6..33392db931 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2347,7 +2347,6 @@ dependencies = [ name = "codex-cloud-config" version = "0.0.0" dependencies = [ - "async-trait", "base64 0.22.1", "chrono", "codex-backend-client", diff --git a/codex-rs/cloud-config/Cargo.toml b/codex-rs/cloud-config/Cargo.toml index b9afaee105..6bf58c8399 100644 --- a/codex-rs/cloud-config/Cargo.toml +++ b/codex-rs/cloud-config/Cargo.toml @@ -8,7 +8,6 @@ license.workspace = true workspace = true [dependencies] -async-trait = { workspace = true } base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } codex-backend-client = { workspace = true } diff --git a/codex-rs/cloud-config/src/backend.rs b/codex-rs/cloud-config/src/backend.rs new file mode 100644 index 0000000000..cb99316a70 --- /dev/null +++ b/codex-rs/cloud-config/src/backend.rs @@ -0,0 +1,136 @@ +use codex_backend_client::Client as BackendClient; +use codex_backend_client::ConfigBundleResponse; +use codex_backend_client::DeliveredTomlFragment; +use codex_config::CloudConfigBundle; +use codex_config::CloudConfigFragment; +use codex_config::CloudConfigTomlBundle; +use codex_config::CloudRequirementsFragment; +use codex_config::CloudRequirementsTomlBundle; +use codex_login::CodexAuth; +use std::future::Future; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RetryableFailureKind { + BackendClientInit, + Request { status_code: Option }, +} + +impl RetryableFailureKind { + pub(crate) fn status_code(self) -> Option { + match self { + Self::BackendClientInit => None, + Self::Request { status_code } => status_code, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum BundleRequestError { + Retryable(RetryableFailureKind), + Unauthorized { + status_code: Option, + message: String, + }, +} + +/// Retrieves one cloud config bundle from the backend. +/// +/// Implementations should return the backend-selected bundle exactly as delivered and leave +/// validation, caching, and config/requirements parsing decisions to the service layer. +pub(crate) trait BundleClient: Send + Sync { + fn get_bundle( + &self, + auth: &CodexAuth, + ) -> impl Future> + Send; +} + +pub(crate) struct BackendBundleClient { + base_url: String, +} + +impl BackendBundleClient { + pub(crate) fn new(base_url: String) -> Self { + Self { base_url } + } +} + +impl BundleClient for BackendBundleClient { + async fn get_bundle(&self, auth: &CodexAuth) -> Result { + let client = BackendClient::from_auth(self.base_url.clone(), auth) + .inspect_err(|err| { + tracing::warn!( + error = %err, + "Failed to construct backend client for cloud config bundle" + ); + }) + .map_err(|_| BundleRequestError::Retryable(RetryableFailureKind::BackendClientInit))?; + + let response = client + .get_config_bundle() + .await + .inspect_err(|err| { + tracing::warn!(error = %err, "Failed to fetch cloud config bundle"); + }) + .map_err(|err| { + let status_code = err.status().map(|status| status.as_u16()); + if err.is_unauthorized() { + BundleRequestError::Unauthorized { + status_code, + message: err.to_string(), + } + } else { + BundleRequestError::Retryable(RetryableFailureKind::Request { status_code }) + } + })?; + + Ok(bundle_from_response(response)) + } +} + +pub(crate) fn bundle_from_response(response: ConfigBundleResponse) -> CloudConfigBundle { + let config_toml = response + .config_toml + .flatten() + .map(|config_toml| *config_toml) + .and_then(|config_toml| config_toml.enterprise_managed.flatten()) + .unwrap_or_default() + .into_iter() + .map(config_fragment_from_delivered) + .collect(); + let requirements_toml = response + .requirements_toml + .flatten() + .map(|requirements_toml| *requirements_toml) + .and_then(|requirements_toml| requirements_toml.enterprise_managed.flatten()) + .unwrap_or_default() + .into_iter() + .map(requirements_fragment_from_delivered) + .collect(); + + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: config_toml, + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: requirements_toml, + }, + } +} + +fn config_fragment_from_delivered(fragment: DeliveredTomlFragment) -> CloudConfigFragment { + CloudConfigFragment { + id: fragment.id, + name: fragment.name, + contents: fragment.contents, + } +} + +fn requirements_fragment_from_delivered( + fragment: DeliveredTomlFragment, +) -> CloudRequirementsFragment { + CloudRequirementsFragment { + id: fragment.id, + name: fragment.name, + contents: fragment.contents, + } +} diff --git a/codex-rs/cloud-config/src/bundle_loader.rs b/codex-rs/cloud-config/src/bundle_loader.rs new file mode 100644 index 0000000000..c269460c1f --- /dev/null +++ b/codex-rs/cloud-config/src/bundle_loader.rs @@ -0,0 +1,68 @@ +use crate::backend::BackendBundleClient; +use crate::service::CLOUD_CONFIG_BUNDLE_TIMEOUT; +use crate::service::CloudConfigBundleService; +use codex_config::CloudConfigBundleLoadError; +use codex_config::CloudConfigBundleLoadErrorCode; +use codex_config::CloudConfigBundleLoader; +use codex_config::types::AuthCredentialsStoreMode; +use codex_login::AuthManager; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::OnceLock; +use tokio::task::JoinHandle; + +fn refresher_task_slot() -> &'static Mutex>> { + static REFRESHER_TASK: OnceLock>>> = OnceLock::new(); + REFRESHER_TASK.get_or_init(|| Mutex::new(None)) +} + +pub fn cloud_config_bundle_loader( + auth_manager: Arc, + chatgpt_base_url: String, + codex_home: PathBuf, +) -> CloudConfigBundleLoader { + let service = CloudConfigBundleService::new( + auth_manager, + Arc::new(BackendBundleClient::new(chatgpt_base_url)), + codex_home, + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + let refresh_service = service.clone(); + let task = tokio::spawn(async move { service.load_startup_bundle_with_timeout().await }); + let refresh_task = + tokio::spawn(async move { refresh_service.refresh_cache_in_background().await }); + let mut refresher_guard = refresher_task_slot().lock().unwrap_or_else(|err| { + tracing::warn!("cloud config bundle refresher task slot was poisoned"); + err.into_inner() + }); + if let Some(existing_task) = refresher_guard.replace(refresh_task) { + existing_task.abort(); + } + CloudConfigBundleLoader::new(async move { + task.await.map_err(|err| { + tracing::error!(error = %err, "Cloud config bundle task failed"); + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Internal, + /*status_code*/ None, + format!("cloud config bundle load failed: {err}"), + ) + })? + }) +} + +pub async fn cloud_config_bundle_loader_for_storage( + codex_home: PathBuf, + enable_codex_api_key_env: bool, + credentials_store_mode: AuthCredentialsStoreMode, + chatgpt_base_url: String, +) -> CloudConfigBundleLoader { + let auth_manager = AuthManager::shared( + codex_home.clone(), + enable_codex_api_key_env, + credentials_store_mode, + Some(chatgpt_base_url.clone()), + ) + .await; + cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home) +} diff --git a/codex-rs/cloud-config/src/cache.rs b/codex-rs/cloud-config/src/cache.rs new file mode 100644 index 0000000000..1e68646159 --- /dev/null +++ b/codex-rs/cloud-config/src/cache.rs @@ -0,0 +1,253 @@ +//! Signed on-disk cache for cloud config bundles. +//! +//! The cache is scoped to the authenticated ChatGPT user and account, has a +//! short TTL, and is HMAC-signed so malformed or edited files fail closed. + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use chrono::DateTime; +use chrono::Duration as ChronoDuration; +use chrono::Utc; +use codex_config::AbsolutePathBuf; +use codex_config::CloudConfigBundle; +use hmac::Hmac; +use hmac::Mac; +use serde::Deserialize; +use serde::Serialize; +use sha2::Sha256; +use std::path::Path; +use std::time::Duration; +use thiserror::Error; +use tokio::fs; + +const CLOUD_CONFIG_BUNDLE_CACHE_VERSION: u32 = 1; +pub(super) const CLOUD_CONFIG_BUNDLE_CACHE_FILENAME: &str = "cloud-config-bundle-cache.json"; +const CLOUD_CONFIG_BUNDLE_CACHE_TTL: Duration = Duration::from_secs(30 * 60); +const CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY: &[u8] = + b"codex-cloud-config-bundle-cache-v1-6160ae70-bcfd-4ca8-a99b-40f73b3b072e"; +const CLOUD_CONFIG_BUNDLE_CACHE_READ_HMAC_KEYS: &[&[u8]] = + &[CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY]; + +type HmacSha256 = Hmac; + +#[derive(Clone)] +pub(super) struct CloudConfigBundleCache { + path: AbsolutePathBuf, +} + +impl CloudConfigBundleCache { + pub(super) fn new(codex_home: AbsolutePathBuf) -> Self { + Self { + path: codex_home.join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME), + } + } + + pub(super) fn path(&self) -> &Path { + &self.path + } + + pub(super) async fn load( + &self, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + ) -> Result { + let (Some(chatgpt_user_id), Some(account_id)) = (chatgpt_user_id, account_id) else { + return Err(CacheLoadStatus::AuthIdentityIncomplete); + }; + + let bytes = match fs::read(&self.path).await { + Ok(bytes) => bytes, + Err(err) => { + if err.kind() != std::io::ErrorKind::NotFound { + return Err(CacheLoadStatus::CacheReadFailed(err.to_string())); + } + return Err(CacheLoadStatus::CacheFileNotFound); + } + }; + + let cache_file: CloudConfigBundleCacheFile = match serde_json::from_slice(&bytes) { + Ok(cache_file) => cache_file, + Err(err) => { + return Err(CacheLoadStatus::CacheParseFailed(err.to_string())); + } + }; + let payload_bytes = match cache_payload_bytes(&cache_file.signed_payload) { + Some(payload_bytes) => payload_bytes, + None => { + return Err(CacheLoadStatus::CacheParseFailed( + "failed to serialize cache payload".to_string(), + )); + } + }; + if !verify_cache_signature(&payload_bytes, &cache_file.signature) { + return Err(CacheLoadStatus::CacheSignatureInvalid); + } + if cache_file.signed_payload.version != CLOUD_CONFIG_BUNDLE_CACHE_VERSION { + return Err(CacheLoadStatus::CacheVersionUnsupported( + cache_file.signed_payload.version, + )); + } + + let (Some(cached_chatgpt_user_id), Some(cached_account_id)) = ( + cache_file.signed_payload.chatgpt_user_id.as_deref(), + cache_file.signed_payload.account_id.as_deref(), + ) else { + return Err(CacheLoadStatus::CacheIdentityIncomplete); + }; + + if cached_chatgpt_user_id != chatgpt_user_id || cached_account_id != account_id { + return Err(CacheLoadStatus::CacheIdentityMismatch); + } + + if cache_file.signed_payload.expires_at <= Utc::now() { + return Err(CacheLoadStatus::CacheExpired); + } + + Ok(cache_file.signed_payload) + } + + pub(super) fn log_load_status(&self, status: &CacheLoadStatus) { + if matches!(status, CacheLoadStatus::CacheFileNotFound) { + return; + } + + let warn = matches!( + status, + CacheLoadStatus::CacheReadFailed(_) + | CacheLoadStatus::CacheParseFailed(_) + | CacheLoadStatus::CacheSignatureInvalid + ); + + if warn { + tracing::warn!(path = %self.path.display(), "{status}"); + } else { + tracing::info!(path = %self.path.display(), "{status}"); + } + } + + pub(super) async fn save( + &self, + chatgpt_user_id: Option, + account_id: Option, + bundle: CloudConfigBundle, + ) -> Result<(), CloudConfigBundleCacheError> { + let now = Utc::now(); + let expires_at = now + .checked_add_signed( + ChronoDuration::from_std(CLOUD_CONFIG_BUNDLE_CACHE_TTL) + .map_err(|_| CloudConfigBundleCacheError)?, + ) + .ok_or(CloudConfigBundleCacheError)?; + let signed_payload = CloudConfigBundleCacheSignedPayload { + version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, + cached_at: now, + expires_at, + chatgpt_user_id, + account_id, + bundle, + }; + let payload_bytes = + cache_payload_bytes(&signed_payload).ok_or(CloudConfigBundleCacheError)?; + let serialized = serde_json::to_vec_pretty(&CloudConfigBundleCacheFile { + signature: sign_cache_payload(&payload_bytes).ok_or(CloudConfigBundleCacheError)?, + signed_payload, + }) + .map_err(|_| CloudConfigBundleCacheError)?; + + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent) + .await + .map_err(|_| CloudConfigBundleCacheError)?; + } + + fs::write(&self.path, serialized) + .await + .map_err(|_| CloudConfigBundleCacheError)?; + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub(super) enum CacheLoadStatus { + #[error("Skipping cloud config bundle cache read because auth identity is incomplete.")] + AuthIdentityIncomplete, + #[error("Cloud config bundle cache file not found.")] + CacheFileNotFound, + #[error("Failed to read cloud config bundle cache: {0}.")] + CacheReadFailed(String), + #[error("Failed to parse cloud config bundle cache: {0}.")] + CacheParseFailed(String), + #[error("Cloud config bundle cache failed signature verification.")] + CacheSignatureInvalid, + #[error("Ignoring cloud config bundle cache because cached identity is incomplete.")] + CacheIdentityIncomplete, + #[error("Ignoring cloud config bundle cache for different auth identity.")] + CacheIdentityMismatch, + #[error("Ignoring cloud config bundle cache with unsupported version {0}.")] + CacheVersionUnsupported(u32), + #[error("Cloud config bundle cache expired.")] + CacheExpired, + #[error("Ignoring cloud config bundle cache because the cached bundle is invalid.")] + CacheInvalidBundle, +} + +#[derive(Debug, Error)] +#[error("failed to write cloud config bundle cache")] +pub(super) struct CloudConfigBundleCacheError; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(super) struct CloudConfigBundleCacheFile { + pub(super) signed_payload: CloudConfigBundleCacheSignedPayload, + pub(super) signature: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(super) struct CloudConfigBundleCacheSignedPayload { + pub(super) version: u32, + pub(super) cached_at: DateTime, + pub(super) expires_at: DateTime, + pub(super) chatgpt_user_id: Option, + pub(super) account_id: Option, + pub(super) bundle: CloudConfigBundle, +} + +pub(super) fn cache_payload_bytes( + payload: &CloudConfigBundleCacheSignedPayload, +) -> Option> { + serde_json::to_vec(&payload).ok() +} + +pub(super) fn sign_cache_payload(payload_bytes: &[u8]) -> Option { + let mut mac = HmacSha256::new_from_slice(CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY).ok()?; + mac.update(payload_bytes); + let signature = mac.finalize().into_bytes(); + Some(BASE64_STANDARD.encode(signature)) +} + +pub(super) fn verify_cache_signature(payload_bytes: &[u8], signature: &str) -> bool { + let signature_bytes = match BASE64_STANDARD.decode(signature) { + Ok(signature_bytes) => signature_bytes, + Err(_) => return false, + }; + + CLOUD_CONFIG_BUNDLE_CACHE_READ_HMAC_KEYS + .iter() + .any(|key| verify_cache_signature_with_key(payload_bytes, &signature_bytes, key)) +} + +fn verify_cache_signature_with_key( + payload_bytes: &[u8], + signature_bytes: &[u8], + key: &[u8], +) -> bool { + let mut mac = match HmacSha256::new_from_slice(key) { + Ok(mac) => mac, + Err(_) => return false, + }; + mac.update(payload_bytes); + mac.verify_slice(signature_bytes).is_ok() +} + +#[cfg(test)] +#[path = "cache_tests.rs"] +mod tests; diff --git a/codex-rs/cloud-config/src/cache_tests.rs b/codex-rs/cloud-config/src/cache_tests.rs new file mode 100644 index 0000000000..0416ed7e77 --- /dev/null +++ b/codex-rs/cloud-config/src/cache_tests.rs @@ -0,0 +1,206 @@ +use super::*; +use codex_config::AbsolutePathBuf; +use codex_config::CloudConfigFragment; +use codex_config::CloudConfigTomlBundle; +use codex_config::CloudRequirementsFragment; +use codex_config::CloudRequirementsTomlBundle; +use pretty_assertions::assert_eq; +use std::path::Path; +use tempfile::tempdir; + +fn test_bundle() -> CloudConfigBundle { + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![CloudConfigFragment { + id: "cfg_1".to_string(), + name: "Base config".to_string(), + contents: "model = \"gpt-5\"".to_string(), + }], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + }], + }, + } +} + +fn signed_cache_file( + signed_payload: CloudConfigBundleCacheSignedPayload, +) -> CloudConfigBundleCacheFile { + let payload_bytes = cache_payload_bytes(&signed_payload).expect("payload bytes"); + CloudConfigBundleCacheFile { + signature: sign_cache_payload(&payload_bytes).expect("signature"), + signed_payload, + } +} + +fn valid_signed_payload() -> CloudConfigBundleCacheSignedPayload { + let cached_at = Utc::now(); + CloudConfigBundleCacheSignedPayload { + version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, + cached_at, + expires_at: cached_at + ChronoDuration::minutes(30), + chatgpt_user_id: Some("user-12345".to_string()), + account_id: Some("account-12345".to_string()), + bundle: test_bundle(), + } +} + +fn write_cache_file(cache: &CloudConfigBundleCache, cache_file: &CloudConfigBundleCacheFile) { + std::fs::write( + cache.path(), + serde_json::to_vec_pretty(cache_file).expect("serialize cache"), + ) + .expect("write cache"); +} + +fn create_test_cache(codex_home: &Path) -> CloudConfigBundleCache { + CloudConfigBundleCache::new(AbsolutePathBuf::resolve_path_against_base(codex_home, "/")) +} + +#[tokio::test] +async fn save_writes_signed_payload_and_loads_for_matching_identity() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + let bundle = test_bundle(); + + cache + .save( + Some("user-12345".to_string()), + Some("account-12345".to_string()), + bundle.clone(), + ) + .await + .expect("save cache"); + + let cache_file: CloudConfigBundleCacheFile = + serde_json::from_slice(&std::fs::read(cache.path()).expect("read cache")) + .expect("parse cache"); + assert!( + cache_file.signed_payload.expires_at + <= cache_file.signed_payload.cached_at + ChronoDuration::minutes(30) + ); + assert!(cache_file.signed_payload.expires_at > cache_file.signed_payload.cached_at); + assert_eq!( + cache_file, + signed_cache_file(CloudConfigBundleCacheSignedPayload { + version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, + cached_at: cache_file.signed_payload.cached_at, + expires_at: cache_file.signed_payload.expires_at, + chatgpt_user_id: Some("user-12345".to_string()), + account_id: Some("account-12345".to_string()), + bundle, + }) + ); + + assert_eq!( + cache.load(Some("user-12345"), Some("account-12345")).await, + Ok(cache_file.signed_payload) + ); +} + +#[tokio::test] +async fn load_rejects_missing_request_identity_before_reading_cache_file() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + + assert_eq!( + cache + .load(/*chatgpt_user_id*/ None, Some("account-12345")) + .await, + Err(CacheLoadStatus::AuthIdentityIncomplete) + ); + assert_eq!( + cache.load(Some("user-12345"), /*account_id*/ None).await, + Err(CacheLoadStatus::AuthIdentityIncomplete) + ); +} + +#[tokio::test] +async fn load_reports_missing_and_malformed_cache_files() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + + assert_eq!( + cache.load(Some("user-12345"), Some("account-12345")).await, + Err(CacheLoadStatus::CacheFileNotFound) + ); + + std::fs::write(cache.path(), "{").expect("write malformed cache"); + assert!(matches!( + cache.load(Some("user-12345"), Some("account-12345")).await, + Err(CacheLoadStatus::CacheParseFailed(_)) + )); +} + +#[tokio::test] +async fn load_rejects_tampered_payload() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + let mut cache_file = signed_cache_file(valid_signed_payload()); + cache_file + .signed_payload + .bundle + .requirements_toml + .enterprise_managed[0] + .contents = "allowed_approval_policies = [\"on-request\"]".to_string(); + write_cache_file(&cache, &cache_file); + + assert_eq!( + cache.load(Some("user-12345"), Some("account-12345")).await, + Err(CacheLoadStatus::CacheSignatureInvalid) + ); +} + +#[tokio::test] +async fn load_rejects_cache_for_incomplete_or_different_identity() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + let cache_file = signed_cache_file(valid_signed_payload()); + write_cache_file(&cache, &cache_file); + + assert_eq!( + cache.load(Some("user-99999"), Some("account-12345")).await, + Err(CacheLoadStatus::CacheIdentityMismatch) + ); + + let mut signed_payload = valid_signed_payload(); + signed_payload.chatgpt_user_id = None; + write_cache_file(&cache, &signed_cache_file(signed_payload)); + + assert_eq!( + cache.load(Some("user-12345"), Some("account-12345")).await, + Err(CacheLoadStatus::CacheIdentityIncomplete) + ); +} + +#[tokio::test] +async fn load_rejects_expired_cache() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + let mut signed_payload = valid_signed_payload(); + signed_payload.expires_at = Utc::now() - ChronoDuration::seconds(1); + write_cache_file(&cache, &signed_cache_file(signed_payload)); + + assert_eq!( + cache.load(Some("user-12345"), Some("account-12345")).await, + Err(CacheLoadStatus::CacheExpired) + ); +} + +#[tokio::test] +async fn load_rejects_unsupported_cache_version() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + let mut signed_payload = valid_signed_payload(); + signed_payload.version = 2; + write_cache_file(&cache, &signed_cache_file(signed_payload)); + + assert_eq!( + cache.load(Some("user-12345"), Some("account-12345")).await, + Err(CacheLoadStatus::CacheVersionUnsupported(2)) + ); +} diff --git a/codex-rs/cloud-config/src/lib.rs b/codex-rs/cloud-config/src/lib.rs index 72d2b4d1cf..0e742f5522 100644 --- a/codex-rs/cloud-config/src/lib.rs +++ b/codex-rs/cloud-config/src/lib.rs @@ -3,2220 +3,12 @@ //! This crate owns transport, caching, and refresh behavior for cloud-delivered //! config data. Parsing and composition remain in `codex-config`. -use async_trait::async_trait; -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use chrono::DateTime; -use chrono::Duration as ChronoDuration; -use chrono::Utc; -use codex_backend_client::Client as BackendClient; -use codex_backend_client::ConfigBundleResponse; -use codex_backend_client::DeliveredTomlFragment; -use codex_config::AbsolutePathBuf; -use codex_config::CloudConfigBundle; -use codex_config::CloudConfigBundleLayers; -use codex_config::CloudConfigBundleLoadError; -use codex_config::CloudConfigBundleLoadErrorCode; -use codex_config::CloudConfigBundleLoader; -use codex_config::CloudConfigFragment; -use codex_config::CloudConfigTomlBundle; -use codex_config::CloudRequirementsFragment; -use codex_config::CloudRequirementsTomlBundle; -use codex_config::compose_requirements; -use codex_config::types::AuthCredentialsStoreMode; -use codex_core::util::backoff; -use codex_login::AuthManager; -use codex_login::CodexAuth; -use codex_login::RefreshTokenError; -use codex_protocol::account::PlanType; -use hmac::Hmac; -use hmac::Mac; -use serde::Deserialize; -use serde::Serialize; -use sha2::Sha256; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::Mutex; -use std::sync::OnceLock; -use std::time::Duration; -use std::time::Instant; -use thiserror::Error; -use tokio::fs; -use tokio::task::JoinHandle; -use tokio::time::sleep; -use tokio::time::timeout; - -const CLOUD_REQUIREMENTS_TIMEOUT: Duration = Duration::from_secs(15); -const CLOUD_REQUIREMENTS_MAX_ATTEMPTS: usize = 5; -const CLOUD_CONFIG_BUNDLE_CACHE_VERSION: u32 = 1; -const CLOUD_REQUIREMENTS_CACHE_FILENAME: &str = "cloud-config-bundle-cache.json"; -const CLOUD_REQUIREMENTS_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); -const CLOUD_REQUIREMENTS_CACHE_TTL: Duration = Duration::from_secs(30 * 60); -const CLOUD_REQUIREMENTS_FETCH_ATTEMPT_METRIC: &str = "codex.cloud_config_bundle.fetch_attempt"; -const CLOUD_REQUIREMENTS_FETCH_FINAL_METRIC: &str = "codex.cloud_config_bundle.fetch_final"; -const CLOUD_REQUIREMENTS_LOAD_METRIC: &str = "codex.cloud_config_bundle.load"; -const CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE: &str = - "Failed to load cloud config bundle (workspace-managed policies)."; -const CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE: &str = concat!( - "Your authentication session could not be refreshed automatically. ", - "Please log out and sign in again." -); -const CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY: &[u8] = - b"codex-cloud-config-bundle-cache-v1-6160ae70-bcfd-4ca8-a99b-40f73b3b072e"; -const CLOUD_REQUIREMENTS_CACHE_READ_HMAC_KEYS: &[&[u8]] = - &[CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY]; - -type HmacSha256 = Hmac; - -fn refresher_task_slot() -> &'static Mutex>> { - static REFRESHER_TASK: OnceLock>>> = OnceLock::new(); - REFRESHER_TASK.get_or_init(|| Mutex::new(None)) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum RetryableFailureKind { - BackendClientInit, - Request { status_code: Option }, -} - -impl RetryableFailureKind { - fn status_code(self) -> Option { - match self { - Self::BackendClientInit => None, - Self::Request { status_code } => status_code, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -enum FetchAttemptError { - Retryable(RetryableFailureKind), - Unauthorized { - status_code: Option, - message: String, - }, -} - -#[derive(Clone, Debug, Eq, Error, PartialEq)] -enum CacheLoadStatus { - #[error("Skipping cloud config bundle cache read because auth identity is incomplete.")] - AuthIdentityIncomplete, - #[error("Cloud config bundle cache file not found.")] - CacheFileNotFound, - #[error("Failed to read cloud config bundle cache: {0}.")] - CacheReadFailed(String), - #[error("Failed to parse cloud config bundle cache: {0}.")] - CacheParseFailed(String), - #[error("Cloud config bundle cache failed signature verification.")] - CacheSignatureInvalid, - #[error("Ignoring cloud config bundle cache because cached identity is incomplete.")] - CacheIdentityIncomplete, - #[error("Ignoring cloud config bundle cache for different auth identity.")] - CacheIdentityMismatch, - #[error("Ignoring cloud config bundle cache with unsupported version {0}.")] - CacheVersionUnsupported(u32), - #[error("Cloud config bundle cache expired.")] - CacheExpired, - #[error("Ignoring cloud config bundle cache because the cached bundle is invalid.")] - CacheInvalidBundle, -} - -#[derive(Debug, Error)] -enum CloudRequirementsError { - #[error("failed to write cloud config bundle cache")] - CacheWrite, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -struct CloudRequirementsCacheFile { - signed_payload: CloudRequirementsCacheSignedPayload, - signature: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -struct CloudRequirementsCacheSignedPayload { - version: u32, - cached_at: DateTime, - expires_at: DateTime, - chatgpt_user_id: Option, - account_id: Option, - bundle: CloudConfigBundle, -} - -fn sign_cache_payload(payload_bytes: &[u8]) -> Option { - let mut mac = HmacSha256::new_from_slice(CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY).ok()?; - mac.update(payload_bytes); - let signature = mac.finalize().into_bytes(); - Some(BASE64_STANDARD.encode(signature)) -} - -fn verify_cache_signature_with_key( - payload_bytes: &[u8], - signature_bytes: &[u8], - key: &[u8], -) -> bool { - let mut mac = match HmacSha256::new_from_slice(key) { - Ok(mac) => mac, - Err(_) => return false, - }; - mac.update(payload_bytes); - mac.verify_slice(signature_bytes).is_ok() -} - -fn verify_cache_signature(payload_bytes: &[u8], signature: &str) -> bool { - let signature_bytes = match BASE64_STANDARD.decode(signature) { - Ok(signature_bytes) => signature_bytes, - Err(_) => return false, - }; - - CLOUD_REQUIREMENTS_CACHE_READ_HMAC_KEYS - .iter() - .any(|key| verify_cache_signature_with_key(payload_bytes, &signature_bytes, key)) -} - -fn auth_identity(auth: &CodexAuth) -> (Option, Option) { - (auth.get_chatgpt_user_id(), auth.get_account_id()) -} - -fn cloud_requirements_eligible_auth(auth: &CodexAuth) -> bool { - let Some(plan_type) = auth.account_plan_type() else { - return false; - }; - auth.uses_codex_backend() - && (plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise)) -} - -fn optional_bundle(bundle: CloudConfigBundle) -> Option { - if bundle.is_empty() { - None - } else { - Some(bundle) - } -} - -fn cache_payload_bytes(payload: &CloudRequirementsCacheSignedPayload) -> Option> { - serde_json::to_vec(&payload).ok() -} - -/// Retrieves one cloud config bundle from the backend. -/// -/// Implementations should return the backend-selected bundle exactly as delivered and leave -/// validation, caching, and config/requirements parsing decisions to the service layer. -#[async_trait] -trait RequirementsFetcher: Send + Sync { - async fn fetch_requirements( - &self, - auth: &CodexAuth, - ) -> Result; -} - -struct BackendRequirementsFetcher { - base_url: String, -} - -impl BackendRequirementsFetcher { - fn new(base_url: String) -> Self { - Self { base_url } - } -} - -#[async_trait] -impl RequirementsFetcher for BackendRequirementsFetcher { - async fn fetch_requirements( - &self, - auth: &CodexAuth, - ) -> Result { - let client = BackendClient::from_auth(self.base_url.clone(), auth) - .inspect_err(|err| { - tracing::warn!( - error = %err, - "Failed to construct backend client for cloud config bundle" - ); - }) - .map_err(|_| FetchAttemptError::Retryable(RetryableFailureKind::BackendClientInit))?; - - let response = client - .get_config_bundle() - .await - .inspect_err(|err| { - tracing::warn!(error = %err, "Failed to fetch cloud config bundle"); - }) - .map_err(|err| { - let status_code = err.status().map(|status| status.as_u16()); - if err.is_unauthorized() { - FetchAttemptError::Unauthorized { - status_code, - message: err.to_string(), - } - } else { - FetchAttemptError::Retryable(RetryableFailureKind::Request { status_code }) - } - })?; - - Ok(bundle_from_response(response)) - } -} - -fn bundle_from_response(response: ConfigBundleResponse) -> CloudConfigBundle { - let config_toml = response - .config_toml - .flatten() - .map(|config_toml| *config_toml) - .and_then(|config_toml| config_toml.enterprise_managed.flatten()) - .unwrap_or_default() - .into_iter() - .map(config_fragment_from_delivered) - .collect(); - let requirements_toml = response - .requirements_toml - .flatten() - .map(|requirements_toml| *requirements_toml) - .and_then(|requirements_toml| requirements_toml.enterprise_managed.flatten()) - .unwrap_or_default() - .into_iter() - .map(requirements_fragment_from_delivered) - .collect(); - - CloudConfigBundle { - config_toml: CloudConfigTomlBundle { - enterprise_managed: config_toml, - }, - requirements_toml: CloudRequirementsTomlBundle { - enterprise_managed: requirements_toml, - }, - } -} - -fn config_fragment_from_delivered(fragment: DeliveredTomlFragment) -> CloudConfigFragment { - CloudConfigFragment { - id: fragment.id, - name: fragment.name, - contents: fragment.contents, - } -} - -fn requirements_fragment_from_delivered( - fragment: DeliveredTomlFragment, -) -> CloudRequirementsFragment { - CloudRequirementsFragment { - id: fragment.id, - name: fragment.name, - contents: fragment.contents, - } -} - -#[derive(Clone)] -struct CloudRequirementsService { - auth_manager: Arc, - fetcher: Arc, - cache_path: AbsolutePathBuf, - codex_home: AbsolutePathBuf, - timeout: Duration, -} - -impl CloudRequirementsService { - fn new( - auth_manager: Arc, - fetcher: Arc, - codex_home: PathBuf, - timeout: Duration, - ) -> Self { - let codex_home = AbsolutePathBuf::resolve_path_against_base(codex_home, "/"); - let cache_path = codex_home.join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - Self { - auth_manager, - fetcher, - cache_path, - codex_home, - timeout, - } - } - - async fn fetch_with_timeout( - &self, - ) -> Result, CloudConfigBundleLoadError> { - let _timer = - codex_otel::start_global_timer("codex.cloud_config_bundle.fetch.duration_ms", &[]); - let started_at = Instant::now(); - let load_result = timeout(self.timeout, self.fetch()) - .await - .inspect_err(|_| { - let message = format!( - "Timed out waiting for cloud config bundle after {}s", - self.timeout.as_secs() - ); - tracing::error!("{message}"); - emit_load_metric("startup", "error", /*bundle*/ None); - }) - .map_err(|_| { - CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::Timeout, - /*status_code*/ None, - format!( - "timed out waiting for cloud config bundle after {}s", - self.timeout.as_secs() - ), - ) - })?; - - let result = match load_result { - Ok(result) => result, - Err(err) => { - emit_load_metric("startup", "error", /*bundle*/ None); - return Err(err); - } - }; - - match result.as_ref() { - Some(bundle) => { - tracing::info!( - elapsed_ms = started_at.elapsed().as_millis(), - config_fragments = bundle.config_toml.enterprise_managed.len(), - requirements_fragments = bundle.requirements_toml.enterprise_managed.len(), - "Cloud config bundle load completed" - ); - emit_load_metric("startup", "success", Some(bundle)); - } - None => { - tracing::info!( - elapsed_ms = started_at.elapsed().as_millis(), - "Cloud config bundle load completed (none)" - ); - emit_load_metric("startup", "success", /*bundle*/ None); - } - } - - Ok(result) - } - - async fn fetch(&self) -> Result, CloudConfigBundleLoadError> { - let Some(auth) = self.auth_manager.auth().await else { - return Ok(None); - }; - if !cloud_requirements_eligible_auth(&auth) { - return Ok(None); - } - - let (chatgpt_user_id, account_id) = auth_identity(&auth); - match self - .load_cache(chatgpt_user_id.as_deref(), account_id.as_deref()) - .await - { - Ok(signed_payload) => { - if let Err(err) = validate_bundle(&signed_payload.bundle, &self.codex_home) { - tracing::warn!( - path = %self.cache_path.display(), - error = %err, - "Ignoring invalid cached cloud config bundle" - ); - self.log_cache_load_status(&CacheLoadStatus::CacheInvalidBundle); - } else { - tracing::info!( - path = %self.cache_path.display(), - "Using cached cloud config bundle" - ); - return Ok(optional_bundle(signed_payload.bundle)); - } - } - Err(cache_load_status) => { - self.log_cache_load_status(&cache_load_status); - } - } - - self.fetch_with_retries(auth, "startup").await - } - - async fn fetch_with_retries( - &self, - mut auth: CodexAuth, - trigger: &'static str, - ) -> Result, CloudConfigBundleLoadError> { - let mut attempt = 1; - let mut last_status_code: Option = None; - let mut auth_recovery = self.auth_manager.unauthorized_recovery(); - - while attempt <= CLOUD_REQUIREMENTS_MAX_ATTEMPTS { - let bundle = match self.fetcher.fetch_requirements(&auth).await { - Ok(bundle) => { - emit_fetch_attempt_metric( - trigger, attempt, "success", /*status_code*/ None, - ); - bundle - } - Err(FetchAttemptError::Retryable(status)) => { - let status_code = status.status_code(); - last_status_code = status_code; - emit_fetch_attempt_metric(trigger, attempt, "error", status_code); - if attempt < CLOUD_REQUIREMENTS_MAX_ATTEMPTS { - tracing::warn!( - status = ?status, - attempt, - max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - "Failed to fetch cloud config bundle; retrying" - ); - sleep(backoff(attempt as u64)).await; - } - attempt += 1; - continue; - } - Err(FetchAttemptError::Unauthorized { - status_code, - message, - }) => { - last_status_code = status_code; - emit_fetch_attempt_metric(trigger, attempt, "unauthorized", status_code); - if auth_recovery.has_next() { - tracing::warn!( - attempt, - max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - "Cloud config bundle request was unauthorized; attempting auth recovery" - ); - match auth_recovery.next().await { - Ok(_) => { - let Some(refreshed_auth) = self.auth_manager.auth().await else { - tracing::error!( - "Auth recovery succeeded but no auth is available for cloud config bundle" - ); - emit_fetch_final_metric( - trigger, - "error", - "auth_recovery_missing_auth", - attempt, - status_code, - /*bundle*/ None, - ); - return Err(CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::Auth, - status_code, - CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, - )); - }; - auth = refreshed_auth; - continue; - } - Err(RefreshTokenError::Permanent(failed)) => { - tracing::warn!( - error = %failed, - "Failed to recover from unauthorized cloud config bundle request" - ); - emit_fetch_final_metric( - trigger, - "error", - "auth_recovery_unrecoverable", - attempt, - status_code, - /*bundle*/ None, - ); - return Err(CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::Auth, - status_code, - failed.message, - )); - } - Err(RefreshTokenError::Transient(recovery_err)) => { - if attempt < CLOUD_REQUIREMENTS_MAX_ATTEMPTS { - tracing::warn!( - error = %recovery_err, - attempt, - max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - "Failed to recover from unauthorized cloud config bundle request; retrying" - ); - sleep(backoff(attempt as u64)).await; - } - attempt += 1; - continue; - } - } - } - - tracing::warn!( - error = %message, - "Cloud config bundle request was unauthorized and no auth recovery is available" - ); - emit_fetch_final_metric( - trigger, - "error", - "auth_recovery_unavailable", - attempt, - status_code, - /*bundle*/ None, - ); - return Err(CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::Auth, - status_code, - CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, - )); - } - }; - - if let Err(err) = validate_bundle(&bundle, &self.codex_home) { - emit_fetch_final_metric( - trigger, - "error", - "invalid_bundle", - attempt, - /*status_code*/ None, - /*bundle*/ None, - ); - return Err(err); - } - - let (chatgpt_user_id, account_id) = auth_identity(&auth); - if let Err(err) = self - .save_cache(chatgpt_user_id, account_id, bundle.clone()) - .await - { - tracing::warn!( - error = %err, - "Failed to write cloud config bundle cache" - ); - } - - emit_fetch_final_metric( - trigger, - "success", - "none", - attempt, - /*status_code*/ None, - Some(&bundle), - ); - return Ok(optional_bundle(bundle)); - } - - emit_fetch_final_metric( - trigger, - "error", - "request_retry_exhausted", - CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - last_status_code, - /*bundle*/ None, - ); - tracing::error!( - path = %self.cache_path.display(), - "{CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE}" - ); - Err(CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::RequestFailed, - last_status_code, - CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE, - )) - } - - async fn refresh_cache_in_background(&self) { - loop { - sleep(CLOUD_REQUIREMENTS_CACHE_REFRESH_INTERVAL).await; - match timeout(self.timeout, self.refresh_cache()).await { - Ok(true) => {} - Ok(false) => break, - Err(_) => { - tracing::error!( - "Timed out refreshing cloud config bundle cache from remote; keeping existing cache" - ); - emit_load_metric("refresh", "error", /*bundle*/ None); - } - } - } - } - - async fn refresh_cache(&self) -> bool { - let Some(auth) = self.auth_manager.auth().await else { - return false; - }; - if !cloud_requirements_eligible_auth(&auth) { - return false; - } - - match self.fetch_with_retries(auth, "refresh").await { - Ok(bundle) => emit_load_metric("refresh", "success", bundle.as_ref()), - Err(err) => { - tracing::error!( - path = %self.cache_path.display(), - error = %err, - "Failed to refresh cloud config bundle cache from remote" - ); - emit_load_metric("refresh", "error", /*bundle*/ None); - } - } - true - } - - async fn load_cache( - &self, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - ) -> Result { - let (Some(chatgpt_user_id), Some(account_id)) = (chatgpt_user_id, account_id) else { - return Err(CacheLoadStatus::AuthIdentityIncomplete); - }; - - let bytes = match fs::read(&self.cache_path).await { - Ok(bytes) => bytes, - Err(err) => { - if err.kind() != std::io::ErrorKind::NotFound { - return Err(CacheLoadStatus::CacheReadFailed(err.to_string())); - } - return Err(CacheLoadStatus::CacheFileNotFound); - } - }; - - let cache_file: CloudRequirementsCacheFile = match serde_json::from_slice(&bytes) { - Ok(cache_file) => cache_file, - Err(err) => { - return Err(CacheLoadStatus::CacheParseFailed(err.to_string())); - } - }; - let payload_bytes = match cache_payload_bytes(&cache_file.signed_payload) { - Some(payload_bytes) => payload_bytes, - None => { - return Err(CacheLoadStatus::CacheParseFailed( - "failed to serialize cache payload".to_string(), - )); - } - }; - if !verify_cache_signature(&payload_bytes, &cache_file.signature) { - return Err(CacheLoadStatus::CacheSignatureInvalid); - } - if cache_file.signed_payload.version != CLOUD_CONFIG_BUNDLE_CACHE_VERSION { - return Err(CacheLoadStatus::CacheVersionUnsupported( - cache_file.signed_payload.version, - )); - } - - let (Some(cached_chatgpt_user_id), Some(cached_account_id)) = ( - cache_file.signed_payload.chatgpt_user_id.as_deref(), - cache_file.signed_payload.account_id.as_deref(), - ) else { - return Err(CacheLoadStatus::CacheIdentityIncomplete); - }; - - if cached_chatgpt_user_id != chatgpt_user_id || cached_account_id != account_id { - return Err(CacheLoadStatus::CacheIdentityMismatch); - } - - if cache_file.signed_payload.expires_at <= Utc::now() { - return Err(CacheLoadStatus::CacheExpired); - } - - Ok(cache_file.signed_payload) - } - - fn log_cache_load_status(&self, status: &CacheLoadStatus) { - if matches!(status, CacheLoadStatus::CacheFileNotFound) { - return; - } - - let warn = matches!( - status, - CacheLoadStatus::CacheReadFailed(_) - | CacheLoadStatus::CacheParseFailed(_) - | CacheLoadStatus::CacheSignatureInvalid - ); - - if warn { - tracing::warn!(path = %self.cache_path.display(), "{status}"); - } else { - tracing::info!(path = %self.cache_path.display(), "{status}"); - } - } - - async fn save_cache( - &self, - chatgpt_user_id: Option, - account_id: Option, - bundle: CloudConfigBundle, - ) -> Result<(), CloudRequirementsError> { - let now = Utc::now(); - let expires_at = now - .checked_add_signed( - ChronoDuration::from_std(CLOUD_REQUIREMENTS_CACHE_TTL) - .map_err(|_| CloudRequirementsError::CacheWrite)?, - ) - .ok_or(CloudRequirementsError::CacheWrite)?; - let signed_payload = CloudRequirementsCacheSignedPayload { - version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, - cached_at: now, - expires_at, - chatgpt_user_id, - account_id, - bundle, - }; - let payload_bytes = - cache_payload_bytes(&signed_payload).ok_or(CloudRequirementsError::CacheWrite)?; - let serialized = serde_json::to_vec_pretty(&CloudRequirementsCacheFile { - signature: sign_cache_payload(&payload_bytes) - .ok_or(CloudRequirementsError::CacheWrite)?, - signed_payload, - }) - .map_err(|_| CloudRequirementsError::CacheWrite)?; - - if let Some(parent) = self.cache_path.parent() { - fs::create_dir_all(parent) - .await - .map_err(|_| CloudRequirementsError::CacheWrite)?; - } - - fs::write(&self.cache_path, serialized) - .await - .map_err(|_| CloudRequirementsError::CacheWrite)?; - Ok(()) - } -} - -pub fn cloud_config_bundle_loader( - auth_manager: Arc, - chatgpt_base_url: String, - codex_home: PathBuf, -) -> CloudConfigBundleLoader { - let service = CloudRequirementsService::new( - auth_manager, - Arc::new(BackendRequirementsFetcher::new(chatgpt_base_url)), - codex_home, - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let refresh_service = service.clone(); - let task = tokio::spawn(async move { service.fetch_with_timeout().await }); - let refresh_task = - tokio::spawn(async move { refresh_service.refresh_cache_in_background().await }); - let mut refresher_guard = refresher_task_slot().lock().unwrap_or_else(|err| { - tracing::warn!("cloud config bundle refresher task slot was poisoned"); - err.into_inner() - }); - if let Some(existing_task) = refresher_guard.replace(refresh_task) { - existing_task.abort(); - } - CloudConfigBundleLoader::new(async move { - task.await.map_err(|err| { - tracing::error!(error = %err, "Cloud config bundle task failed"); - CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::Internal, - /*status_code*/ None, - format!("cloud config bundle load failed: {err}"), - ) - })? - }) -} - -pub async fn cloud_config_bundle_loader_for_storage( - codex_home: PathBuf, - enable_codex_api_key_env: bool, - credentials_store_mode: AuthCredentialsStoreMode, - chatgpt_base_url: String, -) -> CloudConfigBundleLoader { - let auth_manager = AuthManager::shared( - codex_home.clone(), - enable_codex_api_key_env, - credentials_store_mode, - Some(chatgpt_base_url.clone()), - ) - .await; - cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home) -} - -fn validate_bundle( - bundle: &CloudConfigBundle, - base_dir: &AbsolutePathBuf, -) -> Result<(), CloudConfigBundleLoadError> { - let bundle_layers = - CloudConfigBundleLayers::from_bundle(bundle.clone(), base_dir).map_err(|err| { - CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::InvalidBundle, - /*status_code*/ None, - format!("invalid cloud config bundle: {err}"), - ) - })?; - let CloudConfigBundleLayers { - enterprise_managed_config: _, - enterprise_managed_requirements, - } = bundle_layers; - - compose_requirements(enterprise_managed_requirements).map_err(|err| { - CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::InvalidBundle, - /*status_code*/ None, - format!("invalid cloud config bundle: {err}"), - ) - })?; - - Ok(()) -} - -fn emit_fetch_attempt_metric( - trigger: &str, - attempt: usize, - outcome: &str, - status_code: Option, -) { - let attempt_tag = attempt.to_string(); - let status_code_tag = status_code_tag(status_code); - emit_metric( - CLOUD_REQUIREMENTS_FETCH_ATTEMPT_METRIC, - vec![ - ("trigger", trigger.to_string()), - ("attempt", attempt_tag), - ("outcome", outcome.to_string()), - ("status_code", status_code_tag), - ], - ); -} - -fn emit_fetch_final_metric( - trigger: &str, - outcome: &str, - reason: &str, - attempt_count: usize, - status_code: Option, - bundle: Option<&CloudConfigBundle>, -) { - let attempt_count_tag = attempt_count.to_string(); - let status_code_tag = status_code_tag(status_code); - emit_metric( - CLOUD_REQUIREMENTS_FETCH_FINAL_METRIC, - vec![ - ("trigger", trigger.to_string()), - ("outcome", outcome.to_string()), - ("reason", reason.to_string()), - ("attempt_count", attempt_count_tag), - ("status_code", status_code_tag), - ("bundle_shape", bundle_shape_tag(bundle)), - ], - ); -} - -fn emit_load_metric(trigger: &str, outcome: &str, bundle: Option<&CloudConfigBundle>) { - emit_metric( - CLOUD_REQUIREMENTS_LOAD_METRIC, - vec![ - ("trigger", trigger.to_string()), - ("outcome", outcome.to_string()), - ("bundle_shape", bundle_shape_tag(bundle)), - ], - ); -} - -fn bundle_shape_tag(bundle: Option<&CloudConfigBundle>) -> String { - let Some(bundle) = bundle else { - return "none".to_string(); - }; - - let mut sources = Vec::new(); - if !bundle.config_toml.enterprise_managed.is_empty() { - sources.push("enterprise_config"); - } - if !bundle.requirements_toml.enterprise_managed.is_empty() { - sources.push("enterprise_requirements"); - } - - if sources.is_empty() { - "empty".to_string() - } else { - sources.sort_unstable(); - sources.join(",") - } -} - -fn status_code_tag(status_code: Option) -> String { - status_code - .map(|status_code| status_code.to_string()) - .unwrap_or_else(|| "none".to_string()) -} - -fn emit_metric(metric_name: &str, tags: Vec<(&str, String)>) { - if let Some(metrics) = codex_otel::global() { - let tag_refs = tags - .iter() - .map(|(key, value)| (*key, value.as_str())) - .collect::>(); - let _ = metrics.counter(metric_name, /*inc*/ 1, &tag_refs); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use base64::Engine; - use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use codex_backend_client::ConfigBundleResponse; - use codex_backend_client::DeliveredTomlFragment; - use codex_config::CloudConfigFragment; - use codex_config::CloudConfigTomlBundle; - use codex_config::CloudRequirementsFragment; - use codex_config::CloudRequirementsTomlBundle; - use codex_config::types::AuthCredentialsStoreMode; - use pretty_assertions::assert_eq; - use serde_json::json; - use std::collections::VecDeque; - use std::future::pending; - use std::path::Path; - use std::sync::atomic::AtomicUsize; - use std::sync::atomic::Ordering; - use tempfile::tempdir; - - fn write_auth_json(codex_home: &Path, value: serde_json::Value) -> std::io::Result<()> { - std::fs::write(codex_home.join("auth.json"), serde_json::to_string(&value)?)?; - Ok(()) - } - - async fn auth_manager_with_api_key() -> Arc { - let tmp = tempdir().expect("tempdir"); - let auth_json = json!({ - "OPENAI_API_KEY": "sk-test-key", - "tokens": null, - "last_refresh": null, - }); - write_auth_json(tmp.path(), auth_json).expect("write auth"); - Arc::new( - AuthManager::new( - tmp.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ) - } - - async fn auth_manager_with_plan_and_identity( - plan_type: &str, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - ) -> Arc { - let tmp = tempdir().expect("tempdir"); - write_auth_json( - tmp.path(), - chatgpt_auth_json( - plan_type, - chatgpt_user_id, - account_id, - "test-access-token", - "test-refresh-token", - ), - ) - .expect("write auth"); - Arc::new( - AuthManager::new( - tmp.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ) - } - - async fn auth_manager_with_plan(plan_type: &str) -> Arc { - auth_manager_with_plan_and_identity(plan_type, Some("user-12345"), Some("account-12345")) - .await - } - - fn chatgpt_auth_json( - plan_type: &str, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - access_token: &str, - refresh_token: &str, - ) -> serde_json::Value { - chatgpt_auth_json_with_last_refresh( - plan_type, - chatgpt_user_id, - account_id, - access_token, - refresh_token, - "2025-01-01T00:00:00Z", - ) - } - - fn chatgpt_auth_json_with_last_refresh( - plan_type: &str, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - access_token: &str, - refresh_token: &str, - last_refresh: &str, - ) -> serde_json::Value { - chatgpt_auth_json_with_mode( - plan_type, - chatgpt_user_id, - account_id, - access_token, - refresh_token, - last_refresh, - /*auth_mode*/ None, - ) - } - - fn chatgpt_auth_json_with_mode( - plan_type: &str, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - access_token: &str, - refresh_token: &str, - last_refresh: &str, - auth_mode: Option<&str>, - ) -> serde_json::Value { - let header = json!({ "alg": "none", "typ": "JWT" }); - let auth_payload = json!({ - "chatgpt_plan_type": plan_type, - "chatgpt_user_id": chatgpt_user_id, - "user_id": chatgpt_user_id, - }); - let payload = json!({ - "email": "user@example.com", - "https://api.openai.com/auth": auth_payload, - }); - let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header")); - let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload")); - let signature_b64 = URL_SAFE_NO_PAD.encode(b"sig"); - let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); - - let mut auth_json = json!({ - "OPENAI_API_KEY": null, - "tokens": { - "id_token": fake_jwt, - "access_token": access_token, - "refresh_token": refresh_token, - "account_id": account_id, - }, - "last_refresh": last_refresh, - }); - if let Some(auth_mode) = auth_mode { - auth_json["auth_mode"] = serde_json::Value::String(auth_mode.to_string()); - } - auth_json - } - - fn test_bundle() -> CloudConfigBundle { - CloudConfigBundle { - config_toml: CloudConfigTomlBundle { - enterprise_managed: vec![test_config_fragment()], - }, - requirements_toml: CloudRequirementsTomlBundle { - enterprise_managed: vec![test_requirements_fragment()], - }, - } - } - - fn test_config_fragment() -> CloudConfigFragment { - CloudConfigFragment { - id: "cfg_1".to_string(), - name: "Base config".to_string(), - contents: "model = \"gpt-5\"".to_string(), - } - } - - fn test_requirements_fragment() -> CloudRequirementsFragment { - CloudRequirementsFragment { - id: "req_1".to_string(), - name: "Base requirements".to_string(), - contents: "allowed_approval_policies = [\"never\"]".to_string(), - } - } - - fn invalid_config_bundle() -> CloudConfigBundle { - CloudConfigBundle { - config_toml: CloudConfigTomlBundle { - enterprise_managed: vec![CloudConfigFragment { - id: "cfg_invalid".to_string(), - name: "Invalid config".to_string(), - contents: "model = [".to_string(), - }], - }, - requirements_toml: CloudRequirementsTomlBundle::default(), - } - } - - fn request_error() -> FetchAttemptError { - FetchAttemptError::Retryable(RetryableFailureKind::Request { status_code: None }) - } - - struct StaticFetcher { - bundle: CloudConfigBundle, - request_count: AtomicUsize, - } - - impl StaticFetcher { - fn new(bundle: CloudConfigBundle) -> Self { - Self { - bundle, - request_count: AtomicUsize::new(0), - } - } - } - - #[async_trait] - impl RequirementsFetcher for StaticFetcher { - async fn fetch_requirements( - &self, - _auth: &CodexAuth, - ) -> Result { - self.request_count.fetch_add(1, Ordering::SeqCst); - Ok(self.bundle.clone()) - } - } - - struct PendingFetcher; - - #[async_trait] - impl RequirementsFetcher for PendingFetcher { - async fn fetch_requirements( - &self, - _auth: &CodexAuth, - ) -> Result { - pending::<()>().await; - Ok(CloudConfigBundle::default()) - } - } - - struct SequenceFetcher { - responses: tokio::sync::Mutex>>, - request_count: AtomicUsize, - } - - impl SequenceFetcher { - fn new(responses: Vec>) -> Self { - Self { - responses: tokio::sync::Mutex::new(VecDeque::from(responses)), - request_count: AtomicUsize::new(0), - } - } - } - - #[async_trait] - impl RequirementsFetcher for SequenceFetcher { - async fn fetch_requirements( - &self, - _auth: &CodexAuth, - ) -> Result { - self.request_count.fetch_add(1, Ordering::SeqCst); - let mut responses = self.responses.lock().await; - responses - .pop_front() - .unwrap_or_else(|| Ok(CloudConfigBundle::default())) - } - } - - struct TokenFetcher { - expected_token: String, - bundle: CloudConfigBundle, - request_count: AtomicUsize, - } - - #[async_trait] - impl RequirementsFetcher for TokenFetcher { - async fn fetch_requirements( - &self, - auth: &CodexAuth, - ) -> Result { - self.request_count.fetch_add(1, Ordering::SeqCst); - if matches!( - auth.get_token().as_deref(), - Ok(token) if token == self.expected_token.as_str() - ) { - Ok(self.bundle.clone()) - } else { - Err(FetchAttemptError::Unauthorized { - status_code: Some(401), - message: "GET /config/bundle failed: 401".to_string(), - }) - } - } - } - - struct UnauthorizedFetcher { - message: String, - request_count: AtomicUsize, - } - - #[async_trait] - impl RequirementsFetcher for UnauthorizedFetcher { - async fn fetch_requirements( - &self, - _auth: &CodexAuth, - ) -> Result { - self.request_count.fetch_add(1, Ordering::SeqCst); - Err(FetchAttemptError::Unauthorized { - status_code: Some(401), - message: self.message.clone(), - }) - } - } - - #[test] - fn bundle_shape_tag_describes_sorted_enterprise_sources() { - assert_eq!(bundle_shape_tag(/*bundle*/ None), "none"); - assert_eq!( - bundle_shape_tag(Some(&CloudConfigBundle::default())), - "empty" - ); - assert_eq!( - bundle_shape_tag(Some(&CloudConfigBundle { - config_toml: CloudConfigTomlBundle { - enterprise_managed: vec![test_config_fragment()], - }, - requirements_toml: CloudRequirementsTomlBundle::default(), - })), - "enterprise_config" - ); - assert_eq!( - bundle_shape_tag(Some(&CloudConfigBundle { - config_toml: CloudConfigTomlBundle::default(), - requirements_toml: CloudRequirementsTomlBundle { - enterprise_managed: vec![test_requirements_fragment()], - }, - })), - "enterprise_requirements" - ); - assert_eq!( - bundle_shape_tag(Some(&CloudConfigBundle { - config_toml: CloudConfigTomlBundle { - enterprise_managed: vec![test_config_fragment()], - }, - requirements_toml: CloudRequirementsTomlBundle { - enterprise_managed: vec![test_requirements_fragment()], - }, - })), - "enterprise_config,enterprise_requirements" - ); - } - - #[tokio::test] - async fn fetch_cloud_requirements_skips_non_chatgpt_auth() { - let fetcher = Arc::new(StaticFetcher::new(test_bundle())); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_api_key().await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(None)); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn fetch_cloud_requirements_skips_non_business_or_enterprise_plan() { - let fetcher = Arc::new(StaticFetcher::new(test_bundle())); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("pro").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(None)); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn fetch_cloud_requirements_skips_team_like_usage_based_plan() { - let fetcher = Arc::new(StaticFetcher::new(test_bundle())); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("self_serve_business_usage_based").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(None)); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn fetch_cloud_requirements_allows_business_plan() { - let bundle = test_bundle(); - let codex_home = tempdir().expect("tempdir"); - let fetcher = Arc::new(StaticFetcher::new(bundle.clone())); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(Some(bundle.clone()))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - assert!( - codex_home - .path() - .join(CLOUD_REQUIREMENTS_CACHE_FILENAME) - .exists() - ); - } - - #[tokio::test] - async fn fetch_requirements_rejects_invalid_remote_bundle_before_cache_write() { - let codex_home = tempdir().expect("tempdir"); - let fetcher = Arc::new(StaticFetcher::new(invalid_config_bundle())); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let err = service - .fetch() - .await - .expect_err("invalid remote bundle should fail closed"); - - assert_eq!(err.code(), CloudConfigBundleLoadErrorCode::InvalidBundle); - assert!(err.to_string().contains("invalid cloud config bundle")); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - assert!( - !codex_home - .path() - .join(CLOUD_REQUIREMENTS_CACHE_FILENAME) - .exists() - ); - } - - #[tokio::test] - async fn fetch_requirements_ignores_invalid_cache_and_refetches() { - let codex_home = tempdir().expect("tempdir"); - let replacement_bundle = test_bundle(); - let fetcher = Arc::new(StaticFetcher::new(replacement_bundle.clone())); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - service - .save_cache( - Some("user-12345".to_string()), - Some("account-12345".to_string()), - invalid_config_bundle(), - ) - .await - .expect("write invalid cache"); - - assert_eq!(service.fetch().await, Ok(Some(replacement_bundle.clone()))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - assert_eq!( - service - .load_cache(Some("user-12345"), Some("account-12345")) - .await - .expect("load refreshed cache") - .bundle, - replacement_bundle - ); - } - - #[tokio::test] - async fn fetch_cloud_requirements_allows_business_like_usage_based_plan() { - let fetcher = Arc::new(StaticFetcher::new(test_bundle())); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise_cbp_usage_based").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_allows_hc_plan_as_enterprise() { - let fetcher = Arc::new(StaticFetcher::new(test_bundle())); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("hc").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_requirements_empty_response_is_success_and_cached() { - let codex_home = tempdir().expect("tempdir"); - let fetcher = Arc::new(StaticFetcher::new(CloudConfigBundle::default())); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(None)); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - assert!( - codex_home - .path() - .join(CLOUD_REQUIREMENTS_CACHE_FILENAME) - .exists() - ); - } - - #[tokio::test] - async fn fetch_cloud_requirements_uses_cache_when_valid() { - let bundle = test_bundle(); - let codex_home = tempdir().expect("tempdir"); - let prime_service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - Arc::new(StaticFetcher::new(bundle.clone())), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let _ = prime_service.fetch().await; - - let fetcher = Arc::new(SequenceFetcher::new(vec![Err(request_error())])); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(Some(bundle))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn fetch_cloud_requirements_ignores_cache_for_different_auth_identity() { - let codex_home = tempdir().expect("tempdir"); - let prime_service = CloudRequirementsService::new( - auth_manager_with_plan_and_identity( - "business", - Some("user-12345"), - Some("account-12345"), - ) - .await, - Arc::new(StaticFetcher::new(test_bundle())), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let _ = prime_service.fetch().await; - - let replacement_bundle = CloudConfigBundle { - config_toml: CloudConfigTomlBundle::default(), - requirements_toml: CloudRequirementsTomlBundle { - enterprise_managed: vec![CloudRequirementsFragment { - id: "req_2".to_string(), - name: "Replacement requirements".to_string(), - contents: "allowed_approval_policies = [\"on-request\"]".to_string(), - }], - }, - }; - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(replacement_bundle.clone())])); - let service = CloudRequirementsService::new( - auth_manager_with_plan_and_identity( - "business", - Some("user-99999"), - Some("account-12345"), - ) - .await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(Some(replacement_bundle))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test(start_paused = true)] - async fn fetch_cloud_requirements_times_out() { - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise").await, - Arc::new(PendingFetcher), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let handle = tokio::spawn(async move { service.fetch_with_timeout().await }); - tokio::time::advance(CLOUD_REQUIREMENTS_TIMEOUT + Duration::from_millis(1)).await; - - let result = handle.await.expect("cloud config bundle task"); - let err = result.expect_err("cloud config bundle timeout should fail closed"); - assert!( - err.to_string() - .contains("timed out waiting for cloud config bundle") - ); - } - - #[tokio::test(start_paused = true)] - async fn fetch_cloud_requirements_retries_until_success() { - let fetcher = Arc::new(SequenceFetcher::new(vec![ - Err(request_error()), - Ok(test_bundle()), - ])); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let handle = tokio::spawn(async move { service.fetch().await }); - tokio::task::yield_now().await; - tokio::time::advance(Duration::from_secs(1)).await; - - assert_eq!(handle.await.expect("bundle task"), Ok(Some(test_bundle()))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn fetch_cloud_requirements_recovers_after_unauthorized_reload() { - let auth_home = tempdir().expect("tempdir"); - write_auth_json( - auth_home.path(), - chatgpt_auth_json_with_last_refresh( - "business", - Some("user-12345"), - Some("account-12345"), - "stale-access-token", - "test-refresh-token", - // Keep auth "fresh" so the first request hits unauthorized recovery - // instead of AuthManager::auth() proactively reloading from disk. - "3025-01-01T00:00:00Z", - ), - ) - .expect("write initial auth"); - let auth_manager = Arc::new( - AuthManager::new( - auth_home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ); - - write_auth_json( - auth_home.path(), - chatgpt_auth_json_with_last_refresh( - "business", - Some("user-12345"), - Some("account-12345"), - "fresh-access-token", - "test-refresh-token", - "3025-01-01T00:00:00Z", - ), - ) - .expect("write refreshed auth"); - let fetcher = Arc::new(TokenFetcher { - expected_token: "fresh-access-token".to_string(), - bundle: test_bundle(), - request_count: AtomicUsize::new(0), - }); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn fetch_cloud_requirements_recovers_after_unauthorized_reload_updates_cache_identity() { - let auth_home = tempdir().expect("tempdir"); - write_auth_json( - auth_home.path(), - chatgpt_auth_json_with_last_refresh( - "business", - Some("user-12345"), - Some("account-12345"), - "stale-access-token", - "test-refresh-token", - "3025-01-01T00:00:00Z", - ), - ) - .expect("write initial auth"); - let auth_manager = Arc::new( - AuthManager::new( - auth_home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ); - - write_auth_json( - auth_home.path(), - chatgpt_auth_json_with_last_refresh( - "business", - Some("user-99999"), - Some("account-12345"), - "fresh-access-token", - "test-refresh-token", - "3025-01-01T00:00:00Z", - ), - ) - .expect("write refreshed auth"); - let fetcher = Arc::new(TokenFetcher { - expected_token: "fresh-access-token".to_string(), - bundle: test_bundle(), - request_count: AtomicUsize::new(0), - }); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); - assert_eq!( - service - .load_cache(Some("user-99999"), Some("account-12345")) - .await - .expect("load cache") - .bundle, - test_bundle() - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn fetch_cloud_requirements_surfaces_auth_recovery_message() { - let auth_home = tempdir().expect("tempdir"); - write_auth_json( - auth_home.path(), - chatgpt_auth_json( - "enterprise", - Some("user-12345"), - Some("account-12345"), - "stale-access-token", - "test-refresh-token", - ), - ) - .expect("write auth"); - let auth_manager = Arc::new( - AuthManager::new( - auth_home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ); - - write_auth_json( - auth_home.path(), - chatgpt_auth_json( - "enterprise", - Some("user-12345"), - Some("account-99999"), - "fresh-access-token", - "test-refresh-token", - ), - ) - .expect("write mismatched auth"); - let fetcher = Arc::new(UnauthorizedFetcher { - message: "GET /config/bundle failed: 401".to_string(), - request_count: AtomicUsize::new(0), - }); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let err = service - .fetch() - .await - .expect_err("cloud config bundle should surface auth recovery errors"); - assert_eq!( - err, - CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::Auth, - Some(401), - "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.", - ) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_unauthorized_without_recovery_uses_generic_message() { - let auth_home = tempdir().expect("tempdir"); - write_auth_json( - auth_home.path(), - chatgpt_auth_json_with_mode( - "enterprise", - Some("user-12345"), - Some("account-12345"), - "test-access-token", - "test-refresh-token", - "2025-01-01T00:00:00Z", - Some("chatgptAuthTokens"), - ), - ) - .expect("write auth"); - let auth_manager = Arc::new( - AuthManager::new( - auth_home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ); - - let fetcher = Arc::new(UnauthorizedFetcher { - message: - "GET https://chatgpt.com/backend-api/wham/config/bundle failed: 401; content-type=text/html; body=nope" - .to_string(), - request_count: AtomicUsize::new(0), - }); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let err = service - .fetch() - .await - .expect_err("cloud config bundle should fail closed"); - assert_eq!( - err, - CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::Auth, - Some(401), - CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, - ) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_does_not_use_cache_when_auth_identity_is_incomplete() { - let codex_home = tempdir().expect("tempdir"); - let prime_service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - Arc::new(StaticFetcher::new(test_bundle())), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let _ = prime_service.fetch().await; - - let replacement_bundle = CloudConfigBundle { - config_toml: CloudConfigTomlBundle::default(), - requirements_toml: CloudRequirementsTomlBundle { - enterprise_managed: vec![CloudRequirementsFragment { - id: "req_2".to_string(), - name: "Replacement requirements".to_string(), - contents: "allowed_approval_policies = [\"on-request\"]".to_string(), - }], - }, - }; - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(replacement_bundle.clone())])); - let service = CloudRequirementsService::new( - auth_manager_with_plan_and_identity( - "business", - /*chatgpt_user_id*/ None, - Some("account-12345"), - ) - .await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(Some(replacement_bundle))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test(start_paused = true)] - async fn fetch_cloud_requirements_stops_after_max_retries() { - let fetcher = Arc::new(SequenceFetcher::new(vec![ - Err(request_error()); - CLOUD_REQUIREMENTS_MAX_ATTEMPTS - ])); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let handle = tokio::spawn(async move { service.fetch().await }); - tokio::task::yield_now().await; - tokio::time::advance(Duration::from_secs(5)).await; - tokio::task::yield_now().await; - - let err = handle - .await - .expect("cloud config bundle task") - .expect_err("cloud config bundle retry exhaustion should fail closed"); - assert_eq!(err.to_string(), CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE); - assert_eq!(err.code(), CloudConfigBundleLoadErrorCode::RequestFailed); - assert_eq!( - fetcher.request_count.load(Ordering::SeqCst), - CLOUD_REQUIREMENTS_MAX_ATTEMPTS - ); - } - - #[tokio::test] - async fn refresh_from_remote_updates_cached_bundle() { - let replacement_bundle = CloudConfigBundle { - config_toml: CloudConfigTomlBundle::default(), - requirements_toml: CloudRequirementsTomlBundle { - enterprise_managed: vec![CloudRequirementsFragment { - id: "req_2".to_string(), - name: "Replacement requirements".to_string(), - contents: "allowed_approval_policies = [\"on-request\"]".to_string(), - }], - }, - }; - let codex_home = tempdir().expect("tempdir"); - let fetcher = Arc::new(SequenceFetcher::new(vec![ - Ok(test_bundle()), - Ok(replacement_bundle.clone()), - ])); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher, - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); - assert!(service.refresh_cache().await); - - let signed_payload = service - .load_cache(Some("user-12345"), Some("account-12345")) - .await - .expect("load cache"); - assert_eq!(signed_payload.bundle, replacement_bundle); - } - - #[test] - fn bundle_response_conversion_preserves_fragment_order() { - let response = ConfigBundleResponse { - config_toml: Some(Some(Box::new(codex_backend_client::DeliveredConfigToml { - enterprise_managed: Some(Some(vec![ - DeliveredTomlFragment::new( - "cfg_high".to_string(), - "High config".to_string(), - "model = \"high\"".to_string(), - ), - DeliveredTomlFragment::new( - "cfg_low".to_string(), - "Low config".to_string(), - "model = \"low\"".to_string(), - ), - ])), - }))), - requirements_toml: Some(Some(Box::new( - codex_backend_client::DeliveredRequirementsToml { - enterprise_managed: Some(Some(vec![DeliveredTomlFragment::new( - "req_high".to_string(), - "High requirements".to_string(), - "allowed_approval_policies = [\"never\"]".to_string(), - )])), - }, - ))), - }; - - assert_eq!( - bundle_from_response(response), - CloudConfigBundle { - config_toml: CloudConfigTomlBundle { - enterprise_managed: vec![ - CloudConfigFragment { - id: "cfg_high".to_string(), - name: "High config".to_string(), - contents: "model = \"high\"".to_string(), - }, - CloudConfigFragment { - id: "cfg_low".to_string(), - name: "Low config".to_string(), - contents: "model = \"low\"".to_string(), - }, - ], - }, - requirements_toml: CloudRequirementsTomlBundle { - enterprise_managed: vec![CloudRequirementsFragment { - id: "req_high".to_string(), - name: "High requirements".to_string(), - contents: "allowed_approval_policies = [\"never\"]".to_string(), - }], - }, - } - ); - } - - #[test] - fn bundle_response_conversion_treats_missing_sections_as_empty() { - assert_eq!( - bundle_from_response(ConfigBundleResponse::new()), - CloudConfigBundle::default() - ); - } -} - -#[cfg(test)] -mod cache_tests { - use super::*; - use codex_config::CloudConfigFragment; - use codex_config::CloudConfigTomlBundle; - use codex_config::CloudRequirementsFragment; - use codex_config::CloudRequirementsTomlBundle; - use pretty_assertions::assert_eq; - use std::path::Path; - use tempfile::tempdir; - - fn test_bundle() -> CloudConfigBundle { - CloudConfigBundle { - config_toml: CloudConfigTomlBundle { - enterprise_managed: vec![CloudConfigFragment { - id: "cfg_1".to_string(), - name: "Base config".to_string(), - contents: "model = \"gpt-5\"".to_string(), - }], - }, - requirements_toml: CloudRequirementsTomlBundle { - enterprise_managed: vec![CloudRequirementsFragment { - id: "req_1".to_string(), - name: "Base requirements".to_string(), - contents: "allowed_approval_policies = [\"never\"]".to_string(), - }], - }, - } - } - - struct NeverFetcher; - - #[async_trait] - impl RequirementsFetcher for NeverFetcher { - async fn fetch_requirements( - &self, - _auth: &CodexAuth, - ) -> Result { - panic!("cache tests should not fetch from remote"); - } - } - - async fn create_test_service(codex_home: &Path) -> CloudRequirementsService { - let auth_home = tempdir().expect("tempdir"); - let auth_manager = Arc::new( - AuthManager::new( - auth_home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ); - CloudRequirementsService::new( - auth_manager, - Arc::new(NeverFetcher), - codex_home.to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ) - } - - fn signed_cache_file( - signed_payload: CloudRequirementsCacheSignedPayload, - ) -> CloudRequirementsCacheFile { - let payload_bytes = cache_payload_bytes(&signed_payload).expect("payload bytes"); - CloudRequirementsCacheFile { - signature: sign_cache_payload(&payload_bytes).expect("signature"), - signed_payload, - } - } - - fn valid_signed_payload() -> CloudRequirementsCacheSignedPayload { - let cached_at = Utc::now(); - CloudRequirementsCacheSignedPayload { - version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, - cached_at, - expires_at: cached_at + ChronoDuration::minutes(30), - chatgpt_user_id: Some("user-12345".to_string()), - account_id: Some("account-12345".to_string()), - bundle: test_bundle(), - } - } - - fn write_cache_file(cache_path: &Path, cache_file: &CloudRequirementsCacheFile) { - std::fs::write( - cache_path, - serde_json::to_vec_pretty(cache_file).expect("serialize cache"), - ) - .expect("write cache"); - } - - #[tokio::test] - async fn save_writes_signed_payload_and_loads_for_matching_identity() { - let codex_home = tempdir().expect("tempdir"); - let service = create_test_service(codex_home.path()).await; - let bundle = test_bundle(); - - service - .save_cache( - Some("user-12345".to_string()), - Some("account-12345".to_string()), - bundle.clone(), - ) - .await - .expect("save cache"); - - let cache_file: CloudRequirementsCacheFile = - serde_json::from_slice(&std::fs::read(&service.cache_path).expect("read cache")) - .expect("parse cache"); - assert!( - cache_file.signed_payload.expires_at - <= cache_file.signed_payload.cached_at + ChronoDuration::minutes(30) - ); - assert!(cache_file.signed_payload.expires_at > cache_file.signed_payload.cached_at); - assert_eq!( - cache_file, - signed_cache_file(CloudRequirementsCacheSignedPayload { - version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, - cached_at: cache_file.signed_payload.cached_at, - expires_at: cache_file.signed_payload.expires_at, - chatgpt_user_id: Some("user-12345".to_string()), - account_id: Some("account-12345".to_string()), - bundle, - }) - ); - - assert_eq!( - service - .load_cache(Some("user-12345"), Some("account-12345")) - .await, - Ok(cache_file.signed_payload) - ); - } - - #[tokio::test] - async fn load_rejects_missing_request_identity_before_reading_cache_file() { - let codex_home = tempdir().expect("tempdir"); - let service = create_test_service(codex_home.path()).await; - - assert_eq!( - service - .load_cache(/*chatgpt_user_id*/ None, Some("account-12345")) - .await, - Err(CacheLoadStatus::AuthIdentityIncomplete) - ); - assert_eq!( - service - .load_cache(Some("user-12345"), /*account_id*/ None) - .await, - Err(CacheLoadStatus::AuthIdentityIncomplete) - ); - } - - #[tokio::test] - async fn load_reports_missing_and_malformed_cache_files() { - let codex_home = tempdir().expect("tempdir"); - let service = create_test_service(codex_home.path()).await; - - assert_eq!( - service - .load_cache(Some("user-12345"), Some("account-12345")) - .await, - Err(CacheLoadStatus::CacheFileNotFound) - ); - - std::fs::write(&service.cache_path, "{").expect("write malformed cache"); - assert!(matches!( - service - .load_cache(Some("user-12345"), Some("account-12345")) - .await, - Err(CacheLoadStatus::CacheParseFailed(_)) - )); - } - - #[tokio::test] - async fn load_rejects_tampered_payload() { - let codex_home = tempdir().expect("tempdir"); - let service = create_test_service(codex_home.path()).await; - let mut cache_file = signed_cache_file(valid_signed_payload()); - cache_file - .signed_payload - .bundle - .requirements_toml - .enterprise_managed[0] - .contents = "allowed_approval_policies = [\"on-request\"]".to_string(); - write_cache_file(&service.cache_path, &cache_file); - - assert_eq!( - service - .load_cache(Some("user-12345"), Some("account-12345")) - .await, - Err(CacheLoadStatus::CacheSignatureInvalid) - ); - } - - #[tokio::test] - async fn load_rejects_cache_for_incomplete_or_different_identity() { - let codex_home = tempdir().expect("tempdir"); - let service = create_test_service(codex_home.path()).await; - let cache_file = signed_cache_file(valid_signed_payload()); - write_cache_file(&service.cache_path, &cache_file); - - assert_eq!( - service - .load_cache(Some("user-99999"), Some("account-12345")) - .await, - Err(CacheLoadStatus::CacheIdentityMismatch) - ); - - let mut signed_payload = valid_signed_payload(); - signed_payload.chatgpt_user_id = None; - write_cache_file(&service.cache_path, &signed_cache_file(signed_payload)); - - assert_eq!( - service - .load_cache(Some("user-12345"), Some("account-12345")) - .await, - Err(CacheLoadStatus::CacheIdentityIncomplete) - ); - } - - #[tokio::test] - async fn load_rejects_expired_cache() { - let codex_home = tempdir().expect("tempdir"); - let service = create_test_service(codex_home.path()).await; - let mut signed_payload = valid_signed_payload(); - signed_payload.expires_at = Utc::now() - ChronoDuration::seconds(1); - write_cache_file(&service.cache_path, &signed_cache_file(signed_payload)); - - assert_eq!( - service - .load_cache(Some("user-12345"), Some("account-12345")) - .await, - Err(CacheLoadStatus::CacheExpired) - ); - } - - #[tokio::test] - async fn load_rejects_unsupported_cache_version() { - let codex_home = tempdir().expect("tempdir"); - let service = create_test_service(codex_home.path()).await; - let mut signed_payload = valid_signed_payload(); - signed_payload.version = 2; - write_cache_file(&service.cache_path, &signed_cache_file(signed_payload)); - - assert_eq!( - service - .load_cache(Some("user-12345"), Some("account-12345")) - .await, - Err(CacheLoadStatus::CacheVersionUnsupported(2)) - ); - } -} +mod backend; +mod bundle_loader; +mod cache; +mod metrics; +mod service; +mod validation; + +pub use bundle_loader::cloud_config_bundle_loader; +pub use bundle_loader::cloud_config_bundle_loader_for_storage; diff --git a/codex-rs/cloud-config/src/metrics.rs b/codex-rs/cloud-config/src/metrics.rs new file mode 100644 index 0000000000..c1c02cce9c --- /dev/null +++ b/codex-rs/cloud-config/src/metrics.rs @@ -0,0 +1,95 @@ +use codex_config::CloudConfigBundle; + +const CLOUD_CONFIG_BUNDLE_FETCH_ATTEMPT_METRIC: &str = "codex.cloud_config_bundle.fetch_attempt"; +const CLOUD_CONFIG_BUNDLE_FETCH_FINAL_METRIC: &str = "codex.cloud_config_bundle.fetch_final"; +const CLOUD_CONFIG_BUNDLE_LOAD_METRIC: &str = "codex.cloud_config_bundle.load"; + +pub(crate) fn emit_fetch_attempt_metric( + trigger: &str, + attempt: usize, + outcome: &str, + status_code: Option, +) { + let attempt_tag = attempt.to_string(); + let status_code_tag = status_code_tag(status_code); + emit_metric( + CLOUD_CONFIG_BUNDLE_FETCH_ATTEMPT_METRIC, + vec![ + ("trigger", trigger.to_string()), + ("attempt", attempt_tag), + ("outcome", outcome.to_string()), + ("status_code", status_code_tag), + ], + ); +} + +pub(crate) fn emit_fetch_final_metric( + trigger: &str, + outcome: &str, + reason: &str, + attempt_count: usize, + status_code: Option, + bundle: Option<&CloudConfigBundle>, +) { + let attempt_count_tag = attempt_count.to_string(); + let status_code_tag = status_code_tag(status_code); + emit_metric( + CLOUD_CONFIG_BUNDLE_FETCH_FINAL_METRIC, + vec![ + ("trigger", trigger.to_string()), + ("outcome", outcome.to_string()), + ("reason", reason.to_string()), + ("attempt_count", attempt_count_tag), + ("status_code", status_code_tag), + ("bundle_shape", bundle_shape_tag(bundle)), + ], + ); +} + +pub(crate) fn emit_load_metric(trigger: &str, outcome: &str, bundle: Option<&CloudConfigBundle>) { + emit_metric( + CLOUD_CONFIG_BUNDLE_LOAD_METRIC, + vec![ + ("trigger", trigger.to_string()), + ("outcome", outcome.to_string()), + ("bundle_shape", bundle_shape_tag(bundle)), + ], + ); +} + +pub(crate) fn bundle_shape_tag(bundle: Option<&CloudConfigBundle>) -> String { + let Some(bundle) = bundle else { + return "none".to_string(); + }; + + let mut sources = Vec::new(); + if !bundle.config_toml.enterprise_managed.is_empty() { + sources.push("enterprise_config"); + } + if !bundle.requirements_toml.enterprise_managed.is_empty() { + sources.push("enterprise_requirements"); + } + + if sources.is_empty() { + "empty".to_string() + } else { + sources.sort_unstable(); + sources.join(",") + } +} + +fn status_code_tag(status_code: Option) -> String { + status_code + .map(|status_code| status_code.to_string()) + .unwrap_or_else(|| "none".to_string()) +} + +fn emit_metric(metric_name: &str, tags: Vec<(&str, String)>) { + if let Some(metrics) = codex_otel::global() { + let tag_refs = tags + .iter() + .map(|(key, value)| (*key, value.as_str())) + .collect::>(); + let _ = metrics.counter(metric_name, /*inc*/ 1, &tag_refs); + } +} diff --git a/codex-rs/cloud-config/src/service.rs b/codex-rs/cloud-config/src/service.rs new file mode 100644 index 0000000000..b0e8d49e27 --- /dev/null +++ b/codex-rs/cloud-config/src/service.rs @@ -0,0 +1,500 @@ +//! Cloud config bundle lifecycle orchestration. +//! +//! Startup loads a single shared bundle from cache or backend, and a background +//! refresher keeps the cache warm for future app starts without changing the +//! already-snapshotted runtime config. + +use crate::backend::BundleClient; +use crate::backend::BundleRequestError; +use crate::backend::RetryableFailureKind; +use crate::cache::CacheLoadStatus; +use crate::cache::CloudConfigBundleCache; +use crate::metrics::emit_fetch_attempt_metric; +use crate::metrics::emit_fetch_final_metric; +use crate::metrics::emit_load_metric; +use crate::validation::validate_bundle; +use codex_config::AbsolutePathBuf; +use codex_config::CloudConfigBundle; +use codex_config::CloudConfigBundleLoadError; +use codex_config::CloudConfigBundleLoadErrorCode; +use codex_core::util::backoff; +use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_login::RefreshTokenError; +use codex_login::UnauthorizedRecovery; +use codex_protocol::account::PlanType; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; +use tokio::time::sleep; +use tokio::time::timeout; + +pub(crate) const CLOUD_CONFIG_BUNDLE_TIMEOUT: Duration = Duration::from_secs(15); +const CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS: usize = 5; +const CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); +const CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE: &str = + "Failed to load cloud config bundle (workspace-managed policies)."; +const CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE: &str = concat!( + "Your authentication session could not be refreshed automatically. ", + "Please log out and sign in again." +); + +fn auth_identity(auth: &CodexAuth) -> (Option, Option) { + (auth.get_chatgpt_user_id(), auth.get_account_id()) +} + +fn cloud_config_eligible_auth(auth: &CodexAuth) -> bool { + let Some(plan_type) = auth.account_plan_type() else { + return false; + }; + auth.uses_codex_backend() + && (plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise)) +} + +fn optional_bundle(bundle: CloudConfigBundle) -> Option { + if bundle.is_empty() { + None + } else { + Some(bundle) + } +} + +enum CachedBundleLookup { + Hit(Option), + Miss, +} + +enum UnauthorizedRecoveryAction { + RetrySameAttempt, + RetryNextAttempt, +} + +pub(crate) struct CloudConfigBundleService { + auth_manager: Arc, + client: Arc, + cache: CloudConfigBundleCache, + codex_home: AbsolutePathBuf, + timeout: Duration, +} + +impl Clone for CloudConfigBundleService { + fn clone(&self) -> Self { + Self { + auth_manager: Arc::clone(&self.auth_manager), + client: Arc::clone(&self.client), + cache: self.cache.clone(), + codex_home: self.codex_home.clone(), + timeout: self.timeout, + } + } +} + +impl CloudConfigBundleService +where + C: BundleClient + 'static, +{ + pub(crate) fn new( + auth_manager: Arc, + client: Arc, + codex_home: PathBuf, + timeout: Duration, + ) -> Self { + let codex_home = AbsolutePathBuf::resolve_path_against_base(codex_home, "/"); + Self { + auth_manager, + client, + cache: CloudConfigBundleCache::new(codex_home.clone()), + codex_home, + timeout, + } + } + + pub(crate) async fn load_startup_bundle_with_timeout( + &self, + ) -> Result, CloudConfigBundleLoadError> { + let _timer = + codex_otel::start_global_timer("codex.cloud_config_bundle.fetch.duration_ms", &[]); + let started_at = Instant::now(); + let load_result = timeout(self.timeout, self.load_startup_bundle()) + .await + .inspect_err(|_| { + let message = format!( + "Timed out waiting for cloud config bundle after {}s", + self.timeout.as_secs() + ); + tracing::error!("{message}"); + emit_load_metric("startup", "error", /*bundle*/ None); + }) + .map_err(|_| { + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Timeout, + /*status_code*/ None, + format!( + "timed out waiting for cloud config bundle after {}s", + self.timeout.as_secs() + ), + ) + })?; + + let result = match load_result { + Ok(result) => result, + Err(err) => { + emit_load_metric("startup", "error", /*bundle*/ None); + return Err(err); + } + }; + + match result.as_ref() { + Some(bundle) => { + tracing::info!( + elapsed_ms = started_at.elapsed().as_millis(), + config_fragments = bundle.config_toml.enterprise_managed.len(), + requirements_fragments = bundle.requirements_toml.enterprise_managed.len(), + "Cloud config bundle load completed" + ); + emit_load_metric("startup", "success", Some(bundle)); + } + None => { + tracing::info!( + elapsed_ms = started_at.elapsed().as_millis(), + "Cloud config bundle load completed (none)" + ); + emit_load_metric("startup", "success", /*bundle*/ None); + } + } + + Ok(result) + } + + async fn load_startup_bundle( + &self, + ) -> Result, CloudConfigBundleLoadError> { + let Some(auth) = self.auth_manager.auth().await else { + return Ok(None); + }; + if !cloud_config_eligible_auth(&auth) { + return Ok(None); + } + + // Startup prefers a valid, identity-matched cache entry. The backend is + // only consulted on cache miss or invalid cache contents. + let (chatgpt_user_id, account_id) = auth_identity(&auth); + match self + .load_valid_cached_bundle(chatgpt_user_id.as_deref(), account_id.as_deref()) + .await + { + CachedBundleLookup::Hit(bundle) => return Ok(bundle), + CachedBundleLookup::Miss => {} + } + + self.fetch_remote_bundle_and_update_cache_with_retries(auth, "startup") + .await + } + + async fn load_valid_cached_bundle( + &self, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + ) -> CachedBundleLookup { + match self.cache.load(chatgpt_user_id, account_id).await { + Ok(signed_payload) => { + if let Err(err) = validate_bundle(&signed_payload.bundle, &self.codex_home) { + tracing::warn!( + path = %self.cache.path().display(), + error = %err, + "Ignoring invalid cached cloud config bundle" + ); + self.cache + .log_load_status(&CacheLoadStatus::CacheInvalidBundle); + CachedBundleLookup::Miss + } else { + tracing::info!( + path = %self.cache.path().display(), + "Using cached cloud config bundle" + ); + CachedBundleLookup::Hit(optional_bundle(signed_payload.bundle)) + } + } + Err(cache_load_status) => { + self.cache.log_load_status(&cache_load_status); + CachedBundleLookup::Miss + } + } + } + + async fn fetch_remote_bundle_and_update_cache_with_retries( + &self, + mut auth: CodexAuth, + trigger: &'static str, + ) -> Result, CloudConfigBundleLoadError> { + let mut attempt = 1; + let mut last_status_code: Option = None; + let mut auth_recovery = self.auth_manager.unauthorized_recovery(); + + while attempt <= CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS { + match self.client.get_bundle(&auth).await { + Ok(bundle) => { + return self + .validate_and_cache_remote_bundle(&auth, trigger, attempt, bundle) + .await; + } + Err(BundleRequestError::Retryable(status)) => { + last_status_code = status.status_code(); + if self + .retry_after_request_failure(trigger, attempt, status) + .await + { + attempt += 1; + continue; + } + } + Err(BundleRequestError::Unauthorized { + status_code, + message, + }) => { + last_status_code = status_code; + match self + .handle_unauthorized( + &mut auth, + &mut auth_recovery, + trigger, + attempt, + status_code, + &message, + ) + .await? + { + UnauthorizedRecoveryAction::RetrySameAttempt => continue, + UnauthorizedRecoveryAction::RetryNextAttempt => { + attempt += 1; + continue; + } + } + } + } + + break; + } + + emit_fetch_final_metric( + trigger, + "error", + "request_retry_exhausted", + CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS, + last_status_code, + /*bundle*/ None, + ); + tracing::error!( + path = %self.cache.path().display(), + "{CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE}" + ); + Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::RequestFailed, + last_status_code, + CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE, + )) + } + + async fn validate_and_cache_remote_bundle( + &self, + auth: &CodexAuth, + trigger: &'static str, + attempt: usize, + bundle: CloudConfigBundle, + ) -> Result, CloudConfigBundleLoadError> { + emit_fetch_attempt_metric(trigger, attempt, "success", /*status_code*/ None); + if let Err(err) = validate_bundle(&bundle, &self.codex_home) { + emit_fetch_final_metric( + trigger, + "error", + "invalid_bundle", + attempt, + /*status_code*/ None, + /*bundle*/ None, + ); + return Err(err); + } + + let (chatgpt_user_id, account_id) = auth_identity(auth); + if let Err(err) = self + .cache + .save(chatgpt_user_id, account_id, bundle.clone()) + .await + { + tracing::warn!( + error = %err, + "Failed to write cloud config bundle cache" + ); + } + + emit_fetch_final_metric( + trigger, + "success", + "none", + attempt, + /*status_code*/ None, + Some(&bundle), + ); + Ok(optional_bundle(bundle)) + } + + async fn retry_after_request_failure( + &self, + trigger: &'static str, + attempt: usize, + status: RetryableFailureKind, + ) -> bool { + let status_code = status.status_code(); + emit_fetch_attempt_metric(trigger, attempt, "error", status_code); + if attempt < CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS { + tracing::warn!( + status = ?status, + attempt, + max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS, + "Failed to fetch cloud config bundle; retrying" + ); + sleep(backoff(attempt as u64)).await; + true + } else { + false + } + } + + async fn handle_unauthorized( + &self, + auth: &mut CodexAuth, + auth_recovery: &mut UnauthorizedRecovery, + trigger: &'static str, + attempt: usize, + status_code: Option, + message: &str, + ) -> Result { + emit_fetch_attempt_metric(trigger, attempt, "unauthorized", status_code); + if auth_recovery.has_next() { + tracing::warn!( + attempt, + max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS, + "Cloud config bundle request was unauthorized; attempting auth recovery" + ); + match auth_recovery.next().await { + Ok(_) => { + let Some(refreshed_auth) = self.auth_manager.auth().await else { + tracing::error!( + "Auth recovery succeeded but no auth is available for cloud config bundle" + ); + emit_fetch_final_metric( + trigger, + "error", + "auth_recovery_missing_auth", + attempt, + status_code, + /*bundle*/ None, + ); + return Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, + status_code, + CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE, + )); + }; + *auth = refreshed_auth; + return Ok(UnauthorizedRecoveryAction::RetrySameAttempt); + } + Err(RefreshTokenError::Permanent(failed)) => { + tracing::warn!( + error = %failed, + "Failed to recover from unauthorized cloud config bundle request" + ); + emit_fetch_final_metric( + trigger, + "error", + "auth_recovery_unrecoverable", + attempt, + status_code, + /*bundle*/ None, + ); + return Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, + status_code, + failed.message, + )); + } + Err(RefreshTokenError::Transient(recovery_err)) => { + if attempt < CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS { + tracing::warn!( + error = %recovery_err, + attempt, + max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS, + "Failed to recover from unauthorized cloud config bundle request; retrying" + ); + sleep(backoff(attempt as u64)).await; + } + return Ok(UnauthorizedRecoveryAction::RetryNextAttempt); + } + } + } + + tracing::warn!( + error = %message, + "Cloud config bundle request was unauthorized and no auth recovery is available" + ); + emit_fetch_final_metric( + trigger, + "error", + "auth_recovery_unavailable", + attempt, + status_code, + /*bundle*/ None, + ); + Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, + status_code, + CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE, + )) + } + + pub(crate) async fn refresh_cache_in_background(&self) { + loop { + sleep(CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL).await; + match timeout(self.timeout, self.refresh_cache_once()).await { + Ok(true) => {} + Ok(false) => break, + Err(_) => { + tracing::error!( + "Timed out refreshing cloud config bundle cache from remote; keeping existing cache" + ); + emit_load_metric("refresh", "error", /*bundle*/ None); + } + } + } + } + + async fn refresh_cache_once(&self) -> bool { + let Some(auth) = self.auth_manager.auth().await else { + return false; + }; + if !cloud_config_eligible_auth(&auth) { + return false; + } + + match self + .fetch_remote_bundle_and_update_cache_with_retries(auth, "refresh") + .await + { + Ok(bundle) => emit_load_metric("refresh", "success", bundle.as_ref()), + Err(err) => { + tracing::error!( + path = %self.cache.path().display(), + error = %err, + "Failed to refresh cloud config bundle cache from remote" + ); + emit_load_metric("refresh", "error", /*bundle*/ None); + } + } + true + } +} + +#[cfg(test)] +#[path = "service_tests.rs"] +mod tests; diff --git a/codex-rs/cloud-config/src/service_tests.rs b/codex-rs/cloud-config/src/service_tests.rs new file mode 100644 index 0000000000..a07443ba0c --- /dev/null +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -0,0 +1,1035 @@ +use super::*; +use crate::backend::BundleClient; +use crate::backend::BundleRequestError; +use crate::backend::RetryableFailureKind; +use crate::backend::bundle_from_response; +use crate::cache::CLOUD_CONFIG_BUNDLE_CACHE_FILENAME; +use crate::cache::CloudConfigBundleCache; +use crate::metrics::bundle_shape_tag; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use codex_backend_client::ConfigBundleResponse; +use codex_backend_client::DeliveredTomlFragment; +use codex_config::AbsolutePathBuf; +use codex_config::CloudConfigFragment; +use codex_config::CloudConfigTomlBundle; +use codex_config::CloudRequirementsFragment; +use codex_config::CloudRequirementsTomlBundle; +use codex_config::types::AuthCredentialsStoreMode; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::VecDeque; +use std::future::pending; +use std::path::Path; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use tempfile::tempdir; + +fn write_auth_json(codex_home: &Path, value: serde_json::Value) -> std::io::Result<()> { + std::fs::write(codex_home.join("auth.json"), serde_json::to_string(&value)?)?; + Ok(()) +} + +fn create_test_cache(codex_home: &Path) -> CloudConfigBundleCache { + CloudConfigBundleCache::new(AbsolutePathBuf::resolve_path_against_base(codex_home, "/")) +} + +async fn auth_manager_with_api_key() -> Arc { + let tmp = tempdir().expect("tempdir"); + let auth_json = json!({ + "OPENAI_API_KEY": "sk-test-key", + "tokens": null, + "last_refresh": null, + }); + write_auth_json(tmp.path(), auth_json).expect("write auth"); + Arc::new( + AuthManager::new( + tmp.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ) +} + +async fn auth_manager_with_plan_and_identity( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, +) -> Arc { + let tmp = tempdir().expect("tempdir"); + write_auth_json( + tmp.path(), + chatgpt_auth_json( + plan_type, + chatgpt_user_id, + account_id, + "test-access-token", + "test-refresh-token", + ), + ) + .expect("write auth"); + Arc::new( + AuthManager::new( + tmp.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ) +} + +async fn auth_manager_with_plan(plan_type: &str) -> Arc { + auth_manager_with_plan_and_identity(plan_type, Some("user-12345"), Some("account-12345")).await +} + +fn chatgpt_auth_json( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + access_token: &str, + refresh_token: &str, +) -> serde_json::Value { + chatgpt_auth_json_with_last_refresh( + plan_type, + chatgpt_user_id, + account_id, + access_token, + refresh_token, + "2025-01-01T00:00:00Z", + ) +} + +fn chatgpt_auth_json_with_last_refresh( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + access_token: &str, + refresh_token: &str, + last_refresh: &str, +) -> serde_json::Value { + chatgpt_auth_json_with_mode( + plan_type, + chatgpt_user_id, + account_id, + access_token, + refresh_token, + last_refresh, + /*auth_mode*/ None, + ) +} + +fn chatgpt_auth_json_with_mode( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + access_token: &str, + refresh_token: &str, + last_refresh: &str, + auth_mode: Option<&str>, +) -> serde_json::Value { + let header = json!({ "alg": "none", "typ": "JWT" }); + let auth_payload = json!({ + "chatgpt_plan_type": plan_type, + "chatgpt_user_id": chatgpt_user_id, + "user_id": chatgpt_user_id, + }); + let payload = json!({ + "email": "user@example.com", + "https://api.openai.com/auth": auth_payload, + }); + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header")); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload")); + let signature_b64 = URL_SAFE_NO_PAD.encode(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let mut auth_json = json!({ + "OPENAI_API_KEY": null, + "tokens": { + "id_token": fake_jwt, + "access_token": access_token, + "refresh_token": refresh_token, + "account_id": account_id, + }, + "last_refresh": last_refresh, + }); + if let Some(auth_mode) = auth_mode { + auth_json["auth_mode"] = serde_json::Value::String(auth_mode.to_string()); + } + auth_json +} + +fn test_bundle() -> CloudConfigBundle { + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![test_config_fragment()], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![test_requirements_fragment()], + }, + } +} + +fn test_config_fragment() -> CloudConfigFragment { + CloudConfigFragment { + id: "cfg_1".to_string(), + name: "Base config".to_string(), + contents: "model = \"gpt-5\"".to_string(), + } +} + +fn test_requirements_fragment() -> CloudRequirementsFragment { + CloudRequirementsFragment { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + } +} + +fn invalid_config_bundle() -> CloudConfigBundle { + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![CloudConfigFragment { + id: "cfg_invalid".to_string(), + name: "Invalid config".to_string(), + contents: "model = [".to_string(), + }], + }, + requirements_toml: CloudRequirementsTomlBundle::default(), + } +} + +fn request_error() -> BundleRequestError { + BundleRequestError::Retryable(RetryableFailureKind::Request { status_code: None }) +} + +struct StaticBundleClient { + bundle: CloudConfigBundle, + request_count: AtomicUsize, +} + +impl StaticBundleClient { + fn new(bundle: CloudConfigBundle) -> Self { + Self { + bundle, + request_count: AtomicUsize::new(0), + } + } +} + +impl BundleClient for StaticBundleClient { + async fn get_bundle(&self, _auth: &CodexAuth) -> Result { + self.request_count.fetch_add(1, Ordering::SeqCst); + Ok(self.bundle.clone()) + } +} + +struct PendingBundleClient; + +impl BundleClient for PendingBundleClient { + async fn get_bundle(&self, _auth: &CodexAuth) -> Result { + pending::<()>().await; + Ok(CloudConfigBundle::default()) + } +} + +struct SequenceBundleClient { + responses: tokio::sync::Mutex>>, + request_count: AtomicUsize, +} + +impl SequenceBundleClient { + fn new(responses: Vec>) -> Self { + Self { + responses: tokio::sync::Mutex::new(VecDeque::from(responses)), + request_count: AtomicUsize::new(0), + } + } +} + +impl BundleClient for SequenceBundleClient { + async fn get_bundle(&self, _auth: &CodexAuth) -> Result { + self.request_count.fetch_add(1, Ordering::SeqCst); + let mut responses = self.responses.lock().await; + responses + .pop_front() + .unwrap_or_else(|| Ok(CloudConfigBundle::default())) + } +} + +struct TokenBundleClient { + expected_token: String, + bundle: CloudConfigBundle, + request_count: AtomicUsize, +} + +impl BundleClient for TokenBundleClient { + async fn get_bundle(&self, auth: &CodexAuth) -> Result { + self.request_count.fetch_add(1, Ordering::SeqCst); + if matches!( + auth.get_token().as_deref(), + Ok(token) if token == self.expected_token.as_str() + ) { + Ok(self.bundle.clone()) + } else { + Err(BundleRequestError::Unauthorized { + status_code: Some(401), + message: "GET /config/bundle failed: 401".to_string(), + }) + } + } +} + +struct UnauthorizedBundleClient { + message: String, + request_count: AtomicUsize, +} + +impl BundleClient for UnauthorizedBundleClient { + async fn get_bundle(&self, _auth: &CodexAuth) -> Result { + self.request_count.fetch_add(1, Ordering::SeqCst); + Err(BundleRequestError::Unauthorized { + status_code: Some(401), + message: self.message.clone(), + }) + } +} + +#[test] +fn bundle_shape_tag_describes_sorted_enterprise_sources() { + assert_eq!(bundle_shape_tag(/*bundle*/ None), "none"); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle::default())), + "empty" + ); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![test_config_fragment()], + }, + requirements_toml: CloudRequirementsTomlBundle::default(), + })), + "enterprise_config" + ); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![test_requirements_fragment()], + }, + })), + "enterprise_requirements" + ); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![test_config_fragment()], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![test_requirements_fragment()], + }, + })), + "enterprise_config,enterprise_requirements" + ); +} + +#[tokio::test] +async fn get_bundle_skips_non_chatgpt_auth() { + let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_api_key().await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn get_bundle_skips_non_business_or_enterprise_plan() { + let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("pro").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn get_bundle_skips_team_like_usage_based_plan() { + let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("self_serve_business_usage_based").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn get_bundle_allows_business_plan_and_writes_cache() { + let bundle = test_bundle(); + let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(StaticBundleClient::new(bundle.clone())); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!( + service.load_startup_bundle().await, + Ok(Some(bundle.clone())) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert!( + codex_home + .path() + .join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME) + .exists() + ); +} + +#[tokio::test] +async fn get_bundle_rejects_invalid_remote_bundle_before_cache_write() { + let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(StaticBundleClient::new(invalid_config_bundle())); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + let err = service + .load_startup_bundle() + .await + .expect_err("invalid remote bundle should fail closed"); + + assert_eq!(err.code(), CloudConfigBundleLoadErrorCode::InvalidBundle); + assert!(err.to_string().contains("invalid cloud config bundle")); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert!( + !codex_home + .path() + .join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME) + .exists() + ); +} + +#[tokio::test] +async fn get_bundle_ignores_invalid_cache_and_refetches() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + cache + .save( + Some("user-12345".to_string()), + Some("account-12345".to_string()), + invalid_config_bundle(), + ) + .await + .expect("write invalid cache"); + let replacement_bundle = test_bundle(); + let fetcher = Arc::new(StaticBundleClient::new(replacement_bundle.clone())); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!( + service.load_startup_bundle().await, + Ok(Some(replacement_bundle.clone())) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert_eq!( + cache + .load(Some("user-12345"), Some("account-12345")) + .await + .expect("load refreshed cache") + .bundle, + replacement_bundle + ); +} + +#[tokio::test] +async fn get_bundle_allows_business_like_usage_based_plan() { + let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("enterprise_cbp_usage_based").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn get_bundle_allows_hc_plan_as_enterprise() { + let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("hc").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn get_bundle_empty_response_is_success_and_cached() { + let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(StaticBundleClient::new(CloudConfigBundle::default())); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("enterprise").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert!( + codex_home + .path() + .join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME) + .exists() + ); +} + +#[tokio::test] +async fn get_bundle_uses_cache_when_valid() { + let bundle = test_bundle(); + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + Arc::new(StaticBundleClient::new(bundle.clone())), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + let _ = prime_service.load_startup_bundle().await; + + let fetcher = Arc::new(SequenceBundleClient::new(vec![Err(request_error())])); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(Some(bundle))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn get_bundle_ignores_cache_for_different_auth_identity() { + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudConfigBundleService::new( + auth_manager_with_plan_and_identity("business", Some("user-12345"), Some("account-12345")) + .await, + Arc::new(StaticBundleClient::new(test_bundle())), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + let _ = prime_service.load_startup_bundle().await; + + let replacement_bundle = CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Replacement requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }], + }, + }; + let fetcher = Arc::new(SequenceBundleClient::new(vec![Ok( + replacement_bundle.clone() + )])); + let service = CloudConfigBundleService::new( + auth_manager_with_plan_and_identity("business", Some("user-99999"), Some("account-12345")) + .await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!( + service.load_startup_bundle().await, + Ok(Some(replacement_bundle)) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test(start_paused = true)] +async fn get_bundle_times_out() { + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("enterprise").await, + Arc::new(PendingBundleClient), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + let handle = tokio::spawn(async move { service.load_startup_bundle_with_timeout().await }); + tokio::time::advance(CLOUD_CONFIG_BUNDLE_TIMEOUT + Duration::from_millis(1)).await; + + let result = handle.await.expect("cloud config bundle task"); + let err = result.expect_err("cloud config bundle timeout should fail closed"); + assert!( + err.to_string() + .contains("timed out waiting for cloud config bundle") + ); +} + +#[tokio::test(start_paused = true)] +async fn get_bundle_retries_until_success() { + let fetcher = Arc::new(SequenceBundleClient::new(vec![ + Err(request_error()), + Ok(test_bundle()), + ])); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + let handle = tokio::spawn(async move { service.load_startup_bundle().await }); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(1)).await; + + assert_eq!(handle.await.expect("bundle task"), Ok(Some(test_bundle()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn get_bundle_recovers_after_unauthorized_reload() { + let auth_home = tempdir().expect("tempdir"); + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + // Keep auth "fresh" so the first request hits unauthorized recovery + // instead of AuthManager::auth() proactively reloading from disk. + "3025-01-01T00:00:00Z", + ), + ) + .expect("write initial auth"); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-12345"), + Some("account-12345"), + "fresh-access-token", + "test-refresh-token", + "3025-01-01T00:00:00Z", + ), + ) + .expect("write refreshed auth"); + let fetcher = Arc::new(TokenBundleClient { + expected_token: "fresh-access-token".to_string(), + bundle: test_bundle(), + request_count: AtomicUsize::new(0), + }); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn get_bundle_recovers_after_unauthorized_reload_updates_cache_identity() { + let auth_home = tempdir().expect("tempdir"); + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + "3025-01-01T00:00:00Z", + ), + ) + .expect("write initial auth"); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-99999"), + Some("account-12345"), + "fresh-access-token", + "test-refresh-token", + "3025-01-01T00:00:00Z", + ), + ) + .expect("write refreshed auth"); + let fetcher = Arc::new(TokenBundleClient { + expected_token: "fresh-access-token".to_string(), + bundle: test_bundle(), + request_count: AtomicUsize::new(0), + }); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle()))); + let cache = create_test_cache(codex_home.path()); + assert_eq!( + cache + .load(Some("user-99999"), Some("account-12345")) + .await + .expect("load cache") + .bundle, + test_bundle() + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn get_bundle_surfaces_auth_recovery_message() { + let auth_home = tempdir().expect("tempdir"); + write_auth_json( + auth_home.path(), + chatgpt_auth_json( + "enterprise", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + ), + ) + .expect("write auth"); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + write_auth_json( + auth_home.path(), + chatgpt_auth_json( + "enterprise", + Some("user-12345"), + Some("account-99999"), + "fresh-access-token", + "test-refresh-token", + ), + ) + .expect("write mismatched auth"); + let fetcher = Arc::new(UnauthorizedBundleClient { + message: "GET /config/bundle failed: 401".to_string(), + request_count: AtomicUsize::new(0), + }); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + let err = service + .load_startup_bundle() + .await + .expect_err("cloud config bundle should surface auth recovery errors"); + assert_eq!( + err, + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, + Some(401), + "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.", + ) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn get_bundle_unauthorized_without_recovery_uses_generic_message() { + let auth_home = tempdir().expect("tempdir"); + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_mode( + "enterprise", + Some("user-12345"), + Some("account-12345"), + "test-access-token", + "test-refresh-token", + "2025-01-01T00:00:00Z", + Some("chatgptAuthTokens"), + ), + ) + .expect("write auth"); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + let fetcher = Arc::new(UnauthorizedBundleClient { + message: + "GET https://chatgpt.com/backend-api/wham/config/bundle failed: 401; content-type=text/html; body=nope" + .to_string(), + request_count: AtomicUsize::new(0), + }); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + let err = service + .load_startup_bundle() + .await + .expect_err("cloud config bundle should fail closed"); + assert_eq!( + err, + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, + Some(401), + CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE, + ) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn get_bundle_does_not_use_cache_when_auth_identity_is_incomplete() { + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + Arc::new(StaticBundleClient::new(test_bundle())), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + let _ = prime_service.load_startup_bundle().await; + + let replacement_bundle = CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Replacement requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }], + }, + }; + let fetcher = Arc::new(SequenceBundleClient::new(vec![Ok( + replacement_bundle.clone() + )])); + let service = CloudConfigBundleService::new( + auth_manager_with_plan_and_identity( + "business", + /*chatgpt_user_id*/ None, + Some("account-12345"), + ) + .await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!( + service.load_startup_bundle().await, + Ok(Some(replacement_bundle)) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test(start_paused = true)] +async fn get_bundle_stops_after_max_retries() { + let fetcher = Arc::new(SequenceBundleClient::new(vec![ + Err(request_error()); + CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS + ])); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("enterprise").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + let handle = tokio::spawn(async move { service.load_startup_bundle().await }); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(5)).await; + tokio::task::yield_now().await; + + let err = handle + .await + .expect("cloud config bundle task") + .expect_err("cloud config bundle retry exhaustion should fail closed"); + assert_eq!(err.to_string(), CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE); + assert_eq!(err.code(), CloudConfigBundleLoadErrorCode::RequestFailed); + assert_eq!( + fetcher.request_count.load(Ordering::SeqCst), + CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS + ); +} + +#[tokio::test] +async fn refresh_from_remote_updates_cached_bundle() { + let replacement_bundle = CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Replacement requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }], + }, + }; + let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(SequenceBundleClient::new(vec![ + Ok(test_bundle()), + Ok(replacement_bundle.clone()), + ])); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + fetcher, + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle()))); + assert!(service.refresh_cache_once().await); + + let cache = create_test_cache(codex_home.path()); + let signed_payload = cache + .load(Some("user-12345"), Some("account-12345")) + .await + .expect("load cache"); + assert_eq!(signed_payload.bundle, replacement_bundle); +} + +#[test] +fn bundle_response_conversion_preserves_fragment_order() { + let response = ConfigBundleResponse { + config_toml: Some(Some(Box::new(codex_backend_client::DeliveredConfigToml { + enterprise_managed: Some(Some(vec![ + DeliveredTomlFragment::new( + "cfg_high".to_string(), + "High config".to_string(), + "model = \"high\"".to_string(), + ), + DeliveredTomlFragment::new( + "cfg_low".to_string(), + "Low config".to_string(), + "model = \"low\"".to_string(), + ), + ])), + }))), + requirements_toml: Some(Some(Box::new( + codex_backend_client::DeliveredRequirementsToml { + enterprise_managed: Some(Some(vec![DeliveredTomlFragment::new( + "req_high".to_string(), + "High requirements".to_string(), + "allowed_approval_policies = [\"never\"]".to_string(), + )])), + }, + ))), + }; + + assert_eq!( + bundle_from_response(response), + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![ + CloudConfigFragment { + id: "cfg_high".to_string(), + name: "High config".to_string(), + contents: "model = \"high\"".to_string(), + }, + CloudConfigFragment { + id: "cfg_low".to_string(), + name: "Low config".to_string(), + contents: "model = \"low\"".to_string(), + }, + ], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_high".to_string(), + name: "High requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + }], + }, + } + ); +} + +#[test] +fn bundle_response_conversion_treats_missing_sections_as_empty() { + assert_eq!( + bundle_from_response(ConfigBundleResponse::new()), + CloudConfigBundle::default() + ); +} diff --git a/codex-rs/cloud-config/src/validation.rs b/codex-rs/cloud-config/src/validation.rs new file mode 100644 index 0000000000..ef5ed06e1a --- /dev/null +++ b/codex-rs/cloud-config/src/validation.rs @@ -0,0 +1,34 @@ +use codex_config::AbsolutePathBuf; +use codex_config::CloudConfigBundle; +use codex_config::CloudConfigBundleLayers; +use codex_config::CloudConfigBundleLoadError; +use codex_config::CloudConfigBundleLoadErrorCode; +use codex_config::compose_requirements; + +pub(crate) fn validate_bundle( + bundle: &CloudConfigBundle, + base_dir: &AbsolutePathBuf, +) -> Result<(), CloudConfigBundleLoadError> { + let bundle_layers = + CloudConfigBundleLayers::from_bundle(bundle.clone(), base_dir).map_err(|err| { + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::InvalidBundle, + /*status_code*/ None, + format!("invalid cloud config bundle: {err}"), + ) + })?; + let CloudConfigBundleLayers { + enterprise_managed_config: _, + enterprise_managed_requirements, + } = bundle_layers; + + compose_requirements(enterprise_managed_requirements).map_err(|err| { + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::InvalidBundle, + /*status_code*/ None, + format!("invalid cloud config bundle: {err}"), + ) + })?; + + Ok(()) +} From 68e2c8ed69ce994a0da02c2dcf8b20a69f43c2b6 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 2 Jun 2026 14:50:16 -0700 Subject: [PATCH 40/59] [codex] Keep hosted tools visible in code-only mode (#25890) ## Why `code_mode_only` moved ordinary runtime tools behind `exec`, but it also hid hosted Responses tools. Hosted `web_search` and `image_generation` do not have a nested `exec` runtime path, so code-only sessions lost those capabilities entirely even when their existing provider, auth, model, and configuration gates passed. ## What changed - Keep hosted Responses tools top-level in `code_mode_only` sessions after their existing gates pass. - Preserve the existing nested-tool behavior for ordinary runtimes and the direct-only behavior for multi-agent v2 tools. - Add planner coverage for `code_mode_only` with default multi-agent v2 settings, hosted live web search, and hosted image generation. ## Verification - Added focused regression coverage in `codex-rs/core/src/tools/spec_plan_tests.rs`. - Left execution to CI per repository workflow. --- codex-rs/core/src/tools/spec_plan.rs | 11 +----- codex-rs/core/src/tools/spec_plan_tests.rs | 37 ++++++++++++++++++- .../tests/suite/model_runtime_selectors.rs | 6 +++ 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 9d5aa8f8ee..48111fb578 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -210,15 +210,7 @@ fn build_model_visible_specs_and_registry( specs.push(spec_for_model_request(turn_context, exposure, spec)); } } - for spec in hosted_specs { - if !is_hidden_by_code_mode_only( - turn_context, - &ToolName::plain(spec.name()), - ToolExposure::Direct, - ) { - specs.push(spec); - } - } + specs.extend(hosted_specs); let registry = ToolRegistry::from_tools(runtimes); let model_visible_specs = merge_into_namespaces(specs) @@ -267,6 +259,7 @@ fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec { }) { specs.push(web_search_tool); } + // TODO: Remove hosted image generation once the standalone extension is ready. if image_generation_tool_enabled(turn_context) && !standalone_image_generation_available(turn_context, context.extension_tool_executors) { diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 1cb36e474d..02a7a21f63 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1167,7 +1167,16 @@ async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() { }) .await; - assert_eq!(plan.visible_names, vec!["exec", "wait", "agents"]); + assert_eq!( + plan.visible_names, + vec![ + "exec", + "wait", + "agents", + // Hosted Responses tools. + "web_search", + ] + ); assert!( !plan .namespace_function_names("agents") @@ -1235,6 +1244,32 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { } ); + let code_mode_only = probe(|turn| { + use_chatgpt_auth(turn); + set_features(turn, &[Feature::CodeModeOnly, Feature::MultiAgentV2]); + set_web_search_mode(turn, WebSearchMode::Live); + turn.model_info.input_modalities = vec![InputModality::Image]; + }) + .await; + assert_eq!( + code_mode_only.visible_names, + vec![ + // Code-mode entrypoints. + codex_code_mode::PUBLIC_TOOL_NAME, + codex_code_mode::WAIT_TOOL_NAME, + // Multi-agent v2 tools. + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "close_agent", + "list_agents", + // Hosted Responses tools. + "web_search", + "image_generation", + ] + ); + let standalone_web_search_without_web_run = probe(|turn| { set_feature(turn, Feature::StandaloneWebSearch, /*enabled*/ true); set_web_search_mode(turn, WebSearchMode::Live); diff --git a/codex-rs/core/tests/suite/model_runtime_selectors.rs b/codex-rs/core/tests/suite/model_runtime_selectors.rs index 06385f6561..c17121b521 100644 --- a/codex-rs/core/tests/suite/model_runtime_selectors.rs +++ b/codex-rs/core/tests/suite/model_runtime_selectors.rs @@ -5,6 +5,7 @@ use codex_login::CodexAuth; use codex_models_manager::manager::RefreshStrategy; use codex_models_manager::manager::SharedModelsManager; use codex_models_manager::model_info::model_info_from_slug; +use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelVisibility; @@ -165,12 +166,17 @@ async fn remote_tool_mode_selector_overrides_feature_flags() -> Result<()> { let mut code_mode_only_model = remote_model("test-tool-mode-code-mode-only"); code_mode_only_model.tool_mode = Some(ToolMode::CodeModeOnly); + code_mode_only_model.input_modalities = vec![InputModality::Text, InputModality::Image]; let code_mode_only_body = response_body_for_remote_model(code_mode_only_model, |_| {}).await?; assert_eq!( tool_names(&code_mode_only_body), vec![ + // Code-mode entrypoints. codex_code_mode::PUBLIC_TOOL_NAME.to_string(), codex_code_mode::WAIT_TOOL_NAME.to_string(), + // Hosted Responses tools. + "web_search".to_string(), + "image_generation".to_string(), ] ); From d55e5a9bde03235fbd796282e839966c5b39ec80 Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 2 Jun 2026 23:55:08 +0200 Subject: [PATCH 41/59] Add remote request permissions integration coverage (#25867) ## Stack 1. #25850 - Key request-permission grants by environment: stores and applies sticky permission grants per environment id. 2. #25858 - Add `environmentId` to `request_permissions`: lets the model target a selected environment and resolves relative permission paths against it. 3. #25862 - Propagate permission approval environment id: carries the selected environment id through approval events, app-server requests, TUI prompts, and delegate forwarding. 4. This PR (#25867) - Add remote request permissions integration coverage: verifies the selected remote environment across request, approval, grant reuse, and exec. This PR is stacked on #25862 and should be reviewed after #25850, #25858, and #25862. ## Why The environment-scoped permission stack needs one end-to-end check that exercises the CCA-shaped path, not only unit-level parsing. This verifies that a model-sent `environmentId` on `request_permissions` reaches the approval event, stores the grant under the selected environment, and is reused by a later tool call in that same environment. ## What Changed - Adds a remote executor integration test for `request_permissions` with `environmentId: remote` and a relative write root. - Asserts the permission event reports the remote environment and cwd, and that the normalized grant resolves under the remote cwd. - Approves the grant, then runs a remote `exec_command` without explicit per-call permissions and verifies it completes without another exec approval and writes only in the remote filesystem. ## Verification - Not run locally per instruction. - `git diff --check` --- codex-rs/core/tests/suite/remote_env.rs | 207 ++++++++++++++++++++++++ 1 file changed, 207 insertions(+) diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 34cd119f66..0862482f9d 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -9,6 +9,7 @@ use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_exec_server::REMOTE_ENVIRONMENT_ID; use codex_exec_server::RemoveOptions; use codex_features::Feature; +use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -22,6 +23,9 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionProfile; +use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::PathBufExt; @@ -345,6 +349,209 @@ async fn exec_command_routes_to_selected_remote_environment() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result<()> { + skip_if_no_network!(Ok(())); + let Some(_remote_env) = get_remote_test_env() else { + return Ok(()); + }; + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + config.approvals_reviewer = ApprovalsReviewer::User; + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::ExecPermissionApprovals) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); + }); + let test = builder.build_with_remote_and_local_env(&server).await?; + + let local_cwd = TempDir::new()?; + let remote_cwd = PathBuf::from(format!( + "/tmp/codex-remote-request-permissions-{}", + SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() + )) + .abs(); + let relative_write_root = "granted"; + let relative_target_path = "granted/request-permissions-output.txt"; + let remote_write_root = remote_cwd.join(relative_write_root); + let remote_target_path = remote_cwd.join(relative_target_path); + let local_write_root = local_cwd.path().join(relative_write_root); + let local_target_path = local_cwd.path().join(relative_target_path); + fs::create_dir(&local_write_root)?; + test.fs() + .create_directory( + &remote_write_root, + CreateDirectoryOptions { recursive: true }, + /*sandbox*/ None, + ) + .await?; + + let expected_permissions = RequestPermissionProfile { + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![remote_write_root.clone()]), + )), + ..RequestPermissionProfile::default() + }; + let approved_response = RequestPermissionsResponse { + permissions: expected_permissions.clone(), + scope: PermissionGrantScope::Turn, + strict_auto_review: false, + }; + let command = format!( + "printf 'remote-request-permissions-ok' > {relative_target_path} && cat {relative_target_path}" + ); + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-request-permissions-remote-1"), + ev_function_call( + "permissions-call", + "request_permissions", + &json!({ + "environment_id": REMOTE_ENVIRONMENT_ID, + "reason": "Allow writing inside the selected remote environment", + "permissions": { + "file_system": { + "write": [relative_write_root], + }, + }, + }) + .to_string(), + ), + ev_completed("resp-request-permissions-remote-1"), + ]), + sse(vec![ + ev_response_created("resp-request-permissions-remote-2"), + ev_function_call( + "exec-call", + "exec_command", + &json!({ + "shell": "/bin/sh", + "cmd": command, + "login": false, + "yield_time_ms": 1_000, + "environment_id": REMOTE_ENVIRONMENT_ID, + }) + .to_string(), + ), + ev_completed("resp-request-permissions-remote-2"), + ]), + sse(vec![ + ev_response_created("resp-request-permissions-remote-3"), + ev_assistant_message("msg-request-permissions-remote-1", "done"), + ev_completed("resp-request-permissions-remote-3"), + ]), + ], + ) + .await; + + submit_turn_with_approval_and_environments( + &test, + "request permissions, then write in the remote environment", + vec![ + TurnEnvironmentSelection { + environment_id: LOCAL_ENVIRONMENT_ID.to_string(), + cwd: local_cwd.path().abs(), + }, + TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: remote_cwd.clone(), + }, + ], + ) + .await?; + + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::RequestPermissions(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + let EventMsg::RequestPermissions(request) = event else { + panic!("expected remote request_permissions before completion: {event:?}"); + }; + assert_eq!(request.call_id, "permissions-call"); + assert_eq!( + request.environment_id.as_deref(), + Some(REMOTE_ENVIRONMENT_ID) + ); + assert_eq!(request.cwd.as_ref(), Some(&remote_cwd)); + assert_eq!(request.permissions, expected_permissions); + + test.codex + .submit(Op::RequestPermissionsResponse { + id: "permissions-call".to_string(), + response: approved_response.clone(), + }) + .await?; + + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + match event { + EventMsg::TurnComplete(_) => {} + EventMsg::ExecApprovalRequest(approval) => { + panic!("remote request_permissions grant should preapprove exec: {approval:?}"); + } + other => panic!("unexpected event: {other:?}"), + } + + let permissions_output: RequestPermissionsResponse = serde_json::from_str( + &response_mock + .function_call_output_text("permissions-call") + .expect("expected request_permissions output"), + )?; + assert_eq!(permissions_output, approved_response); + let exec_output = response_mock + .function_call_output_text("exec-call") + .expect("expected exec output"); + assert!( + exec_output.contains("remote-request-permissions-ok"), + "unexpected exec output: {exec_output}", + ); + assert_eq!( + test.fs() + .read_file_text(&remote_target_path, /*sandbox*/ None) + .await?, + "remote-request-permissions-ok" + ); + assert!( + !local_target_path.exists(), + "remote exec should not write through the local environment" + ); + + test.fs() + .remove( + &remote_cwd, + RemoveOptions { + recursive: true, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result<()> { skip_if_no_network!(Ok(())); From c6d76750e81af8a88dcda078916b4207b354bd83 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 2 Jun 2026 15:05:04 -0700 Subject: [PATCH 42/59] config: remove dead profile sandbox fallback (#25943) ## Why `profile_sandbox_mode` was left over from the old selected legacy profile path. Production now always derives permissions without that value, and legacy profile contents are ignored, so keeping a parameter that is always `None` makes `derive_permission_profile` look like it still supports a fallback that no longer exists. ## What Changed - Removed the `profile_sandbox_mode` argument from `ConfigToml::derive_permission_profile`. - Updated the production caller and legacy sandbox-policy test helper to match. - Dropped the stale unselected legacy-profile sandbox test that only protected the removed fallback shape. ## Verification - `just test -p codex-config` - `just test -p codex-core 'config::'` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/25943). * #25926 * __->__ #25943 --- codex-rs/config/src/config_toml.rs | 7 ++-- codex-rs/core/src/config/config_tests.rs | 42 ------------------------ codex-rs/core/src/config/mod.rs | 1 - 3 files changed, 2 insertions(+), 48 deletions(-) diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 0765819fb7..0311482cf2 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -724,16 +724,13 @@ impl ConfigToml { pub async fn derive_permission_profile( &self, sandbox_mode_override: Option, - profile_sandbox_mode: Option, windows_sandbox_level: WindowsSandboxLevel, active_project: Option<&ProjectConfig>, permission_profile_constraint: Option<&crate::Constrained>, ) -> PermissionProfile { - let sandbox_mode_was_explicit = sandbox_mode_override.is_some() - || profile_sandbox_mode.is_some() - || self.sandbox_mode.is_some(); + let sandbox_mode_was_explicit = + sandbox_mode_override.is_some() || self.sandbox_mode.is_some(); let resolved_sandbox_mode = sandbox_mode_override - .or(profile_sandbox_mode) .or(self.sandbox_mode) .or(if sandbox_mode_was_explicit { None diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 795057963e..192e449ff8 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -32,7 +32,6 @@ use codex_config::permissions_toml::NetworkToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionsToml; use codex_config::permissions_toml::WorkspaceRootsToml; -use codex_config::profile_toml::ConfigProfile; use codex_config::types::AppToolApproval; use codex_config::types::ApprovalsReviewer; use codex_config::types::BundledSkillsConfig; @@ -165,7 +164,6 @@ fn http_mcp(url: &str) -> McpServerConfig { async fn derive_legacy_sandbox_policy_for_test( cfg: &ConfigToml, sandbox_mode_override: Option, - profile_sandbox_mode: Option, windows_sandbox_level: WindowsSandboxLevel, active_project: Option<&ProjectConfig>, permission_profile_constraint: Option<&Constrained>, @@ -173,7 +171,6 @@ async fn derive_legacy_sandbox_policy_for_test( let permission_profile = cfg .derive_permission_profile( sandbox_mode_override, - profile_sandbox_mode, windows_sandbox_level, active_project, permission_profile_constraint, @@ -3530,7 +3527,6 @@ network_access = false # This should be ignored. let resolution = derive_legacy_sandbox_policy_for_test( &sandbox_full_access_cfg, sandbox_mode_override, - /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, /*active_project*/ None, /*permission_profile_constraint*/ None, @@ -3551,7 +3547,6 @@ network_access = true # This should be ignored. let resolution = derive_legacy_sandbox_policy_for_test( &sandbox_read_only_cfg, sandbox_mode_override, - /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, /*active_project*/ None, /*permission_profile_constraint*/ None, @@ -3583,7 +3578,6 @@ trust_level = "trusted" let resolution = derive_legacy_sandbox_policy_for_test( &sandbox_workspace_write_cfg, sandbox_mode_override, - /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, /*active_project*/ None, /*permission_profile_constraint*/ None, @@ -3623,7 +3617,6 @@ exclude_slash_tmp = true let resolution = derive_legacy_sandbox_policy_for_test( &sandbox_workspace_write_cfg, sandbox_mode_override, - /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, /*active_project*/ None, /*permission_profile_constraint*/ None, @@ -4998,38 +4991,6 @@ model = "gpt-project-local" Ok(()) } -#[tokio::test] -async fn unselected_profile_sandbox_mode_is_ignored() -> std::io::Result<()> { - let codex_home = TempDir::new()?; - let mut profiles = HashMap::new(); - profiles.insert( - "work".to_string(), - ConfigProfile { - sandbox_mode: Some(SandboxMode::DangerFullAccess), - ..Default::default() - }, - ); - let cfg = ConfigToml { - profiles, - sandbox_mode: Some(SandboxMode::ReadOnly), - ..Default::default() - }; - - let config = Config::load_from_base_config_with_overrides( - cfg, - ConfigOverrides::default(), - codex_home.abs(), - ) - .await?; - - assert_eq!( - config.legacy_sandbox_policy(), - SandboxPolicy::new_read_only_policy() - ); - - Ok(()) -} - #[tokio::test] async fn feature_table_overrides_legacy_flags() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -8587,7 +8548,6 @@ trust_level = "untrusted" let resolution = derive_legacy_sandbox_policy_for_test( &cfg, /*sandbox_mode_override*/ None, - /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, Some(&active_project), /*permission_profile_constraint*/ None, @@ -8644,7 +8604,6 @@ async fn derive_sandbox_policy_falls_back_to_read_only_for_implicit_defaults() - let resolution = derive_legacy_sandbox_policy_for_test( &cfg, /*sandbox_mode_override*/ None, - /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, Some(&active_project), Some(&constrained), @@ -8697,7 +8656,6 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb let resolution = derive_legacy_sandbox_policy_for_test( &cfg, /*sandbox_mode_override*/ None, - /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, Some(&active_project), Some(&constrained), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 9506819d03..fd71cea987 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2936,7 +2936,6 @@ impl Config { let mut permission_profile = cfg .derive_permission_profile( sandbox_mode, - /*profile_sandbox_mode*/ None, windows_sandbox_level, Some(&active_project), Some(&constrained_permission_profile), From f752b25fc43d5e79a4f1cd1878a51702e7fea977 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 2 Jun 2026 15:12:07 -0700 Subject: [PATCH 43/59] Revert "Use environment secrets for Azure signing" (#25948) Reverts openai/codex#24859 --- .github/workflows/rust-release-windows.yml | 16 +++++++++++++--- .github/workflows/rust-release.yml | 1 + 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 0d8cc29697..fc14088ef1 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -6,6 +6,19 @@ on: release-lto: required: true type: string + secrets: + AZURE_TRUSTED_SIGNING_CLIENT_ID: + required: true + AZURE_TRUSTED_SIGNING_TENANT_ID: + required: true + AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID: + required: true + AZURE_TRUSTED_SIGNING_ENDPOINT: + required: true + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: + required: true + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: + required: true # Cargo's libgit2 transport has been flaky when fetching git dependencies with # nested submodules. Prefer the system git CLI across every Cargo invocation. @@ -151,9 +164,6 @@ jobs: - build-windows-binaries name: Build - ${{ matrix.runner }} - ${{ matrix.target }} runs-on: ${{ matrix.runs_on }} - environment: - name: azure-artifact-signing - deployment: false timeout-minutes: 90 permissions: contents: read diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 6b0814dae5..364fe78902 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -865,6 +865,7 @@ jobs: uses: ./.github/workflows/rust-release-windows.yml with: release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} + secrets: inherit argument-comment-lint-release-assets: if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} From bec21c71142ed44d8eb7d7ca5719c87d961044d0 Mon Sep 17 00:00:00 2001 From: Won Park Date: Tue, 2 Jun 2026 15:27:52 -0700 Subject: [PATCH 44/59] Expose standalone image generation in code mode (#25923) ## Why Standalone image generation remained top-level-only in code-mode sessions. ## What changed - Change imagegen exposure from `DirectModelOnly` to `Direct`. - Keep direct-mode access while enabling nested code-mode access. - Add a focused regression test for the exposure contract. ## Validation - `just test -p codex-image-generation-extension` --- ...ge_generation.rs => imagegen_extension.rs} | 152 ++++++++++++++---- codex-rs/app-server/tests/suite/v2/mod.rs | 2 +- codex-rs/ext/image-generation/src/tool.rs | 4 +- 3 files changed, 125 insertions(+), 33 deletions(-) rename codex-rs/app-server/tests/suite/v2/{image_generation.rs => imagegen_extension.rs} (70%) diff --git a/codex-rs/app-server/tests/suite/v2/image_generation.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs similarity index 70% rename from codex-rs/app-server/tests/suite/v2/image_generation.rs rename to codex-rs/app-server/tests/suite/v2/imagegen_extension.rs index e1f4cfbc9a..924c6c58ba 100644 --- a/codex-rs/app-server/tests/suite/v2/image_generation.rs +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -30,6 +30,12 @@ use wiremock::matchers::path; const RESULT: &str = "cG5n"; +#[derive(Clone, Copy)] +enum ImagegenTestMode { + Direct, + CodeModeOnly, +} + // macOS and Windows Bazel CI can spend tens of seconds starting app-server // subprocesses or processing test RPCs under load. #[cfg(any(target_os = "macos", windows))] @@ -69,7 +75,7 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> .await; let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri())?; + create_config_toml(codex_home.path(), &server.uri(), ImagegenTestMode::Direct)?; write_chatgpt_auth( codex_home.path(), ChatGptAuthFixture::new("access-chatgpt"), @@ -79,34 +85,7 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> let mut mcp = TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let thread_req = mcp - .send_thread_start_request(ThreadStartParams::default()) - .await?; - let thread_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), - ) - .await??; - let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; - - let turn_req = mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id, - client_user_message_id: None, - input: vec![V2UserInput::Text { - text: "Generate an image".to_string(), - text_elements: Vec::new(), - }], - ..Default::default() - }) - .await?; - let turn_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), - ) - .await??; - let _turn: TurnStartResponse = to_response::(turn_resp)?; + start_image_generation_turn(&mut mcp).await?; let completed = timeout( DEFAULT_READ_TIMEOUT, @@ -157,6 +136,110 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> Ok(()) } +#[cfg_attr(windows, ignore = "covered by Linux and macOS CI")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn standalone_image_generation_is_callable_from_code_mode_only() -> Result<()> { + let call_id = "code-mode-image-run-1"; + let server = responses::start_mock_server().await; + mount_image_response(&server).await; + + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_custom_tool_call( + call_id, + "exec", + r#" +const result = await tools.image_gen__imagegen({ + action: "generate", + prompt: "paint a blue whale", +}); +image(result); +"#, + ), + responses::ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-2"), + ]), + ], + ) + .await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + ImagegenTestMode::CodeModeOnly, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + start_image_generation_turn(&mut mcp).await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + assert!(requests[0].body_contains_text("image_gen__imagegen")); + let output = requests[1].custom_tool_call_output(call_id); + assert_eq!( + output["output"][1], + json!({ + "type": "input_image", + "image_url": format!("data:image/png;base64,{RESULT}"), + "detail": "high", + }) + ); + assert_eq!(output["output"].as_array().map(Vec::len), Some(2)); + + Ok(()) +} + +async fn start_image_generation_turn(mcp: &mut TestAppServer) -> Result<()> { + let thread_req = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "Generate an image".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _turn: TurnStartResponse = to_response::(turn_resp)?; + + Ok(()) +} + async fn wait_for_image_generation_completed( mcp: &mut TestAppServer, ) -> Result { @@ -187,7 +270,15 @@ async fn mount_image_response(server: &MockServer) { .await; } -fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { +fn create_config_toml( + codex_home: &Path, + server_uri: &str, + mode: ImagegenTestMode, +) -> std::io::Result<()> { + let code_mode_only = match mode { + ImagegenTestMode::Direct => "", + ImagegenTestMode::CodeModeOnly => "code_mode_only = true", + }; std::fs::write( codex_home.join("config.toml"), format!( @@ -200,6 +291,7 @@ chatgpt_base_url = "{server_uri}" [features] imagegenext = true +{code_mode_only} [model_providers.openai-custom] name = "OpenAI" diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 950cccebb5..9aef40aade 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -17,7 +17,7 @@ mod experimental_feature_list; mod external_agent_config; mod fs; mod hooks_list; -mod image_generation; +mod imagegen_extension; mod initialize; mod marketplace_add; mod marketplace_remove; diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index 41d407d912..e4e8e13135 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -77,9 +77,9 @@ impl ToolExecutor for ImageGenerationTool { imagegen_tool_spec() } - /// Keeps this model-facing tool out of the nested code-mode tool surface. + /// Exposes image generation directly and through the nested code-mode tool surface. fn exposure(&self) -> ToolExposure { - ToolExposure::DirectModelOnly + ToolExposure::Direct } /// Executes the selected image operation and returns the completed image result. From 34dc08c21429c613611f661e1ad160d4fbf7e5ec Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 2 Jun 2026 15:41:53 -0700 Subject: [PATCH 45/59] [codex] Publish Python runtime wheels with Python SDK releases (#25906) ## Summary - stop publishing Python runtime wheels as a side effect of Rust releases - publish runtime wheels from the Python SDK release workflow, either explicitly before updating the SDK pin or immediately before a `python-v*` SDK release - resolve the runtime release from the requested version or the SDK package's exact `openai-codex-cli-bin` pin - build two musllinux-tagged wheels from the Rust-release Linux package archives alongside the six existing runtime wheels - validate SDK beta tags before any PyPI write ## Release configuration - update the `openai-codex-cli-bin` PyPI trusted publisher to trust `.github/workflows/python-sdk-release.yml` and the `publish-python-runtime` job ## Pin update flow - run the `python-sdk-release` workflow manually with the new runtime version before opening or updating the SDK pin PR - after the pin lands, a `python-v*` SDK tag republishes with `skip-existing: true` before publishing the SDK package ## Validation - ran `just fmt` - validated the edited workflow YAML - validated the embedded `publish-python-runtime` Bash with `bash -n` - validated manual `0.136.0 -> rust-v0.136.0` mapping - validated tag-driven `python-v0.1.0b3 -> 0.132.0 -> rust-v0.132.0` mapping - validated rejection of an invalid SDK tag before publication - confirmed `rust-v0.136.0` contains the two required Linux package archives - CI will provide the full test signal --- .github/workflows/python-sdk-release.yml | 153 ++++++++++++++++++++++- .github/workflows/rust-release.yml | 69 ---------- 2 files changed, 147 insertions(+), 75 deletions(-) diff --git a/.github/workflows/python-sdk-release.yml b/.github/workflows/python-sdk-release.yml index 96870db171..2db8b4b53e 100644 --- a/.github/workflows/python-sdk-release.yml +++ b/.github/workflows/python-sdk-release.yml @@ -4,15 +4,161 @@ on: push: tags: - "python-v*" + workflow_dispatch: + inputs: + runtime_version: + description: "Runtime version to publish before updating the SDK pin, for example 0.136.0 or 0.136.0a2." + required: true + type: string concurrency: group: ${{ github.workflow }} cancel-in-progress: false jobs: - build-python-sdk: + # Publish the platform-specific Python runtime wheels before building the SDK + # package that pins them, or explicitly before updating the SDK runtime pin. + # PyPI project configuration must trust this workflow and job for publishing. + publish-python-runtime: if: github.repository == 'openai/codex' + name: publish-python-runtime + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write # Required for PyPI trusted publishing. + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Validate SDK tag and resolve Python runtime release + id: python_runtime + shell: bash + env: + REQUESTED_RUNTIME_VERSION: ${{ inputs.runtime_version }} + run: | + set -euo pipefail + python3 - <<'PY' + import os + import re + import tomllib + from pathlib import Path + + event_name = os.environ["GITHUB_EVENT_NAME"] + if event_name == "workflow_dispatch": + python_version = os.environ["REQUESTED_RUNTIME_VERSION"] + elif event_name == "push": + sdk_version = os.environ["GITHUB_REF_NAME"].removeprefix("python-v") + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+b[0-9]+", sdk_version): + raise SystemExit( + "Python SDK release tags must identify a beta release, " + "for example python-v0.1.0b1." + ) + + pyproject = tomllib.loads(Path("sdk/python/pyproject.toml").read_text()) + prefix = "openai-codex-cli-bin==" + versions = [ + dependency.removeprefix(prefix) + for dependency in pyproject["project"]["dependencies"] + if dependency.startswith(prefix) + ] + if len(versions) != 1: + raise SystemExit(f"Expected exactly one pinned {prefix} dependency, found {versions}") + python_version = versions[0] + else: + raise SystemExit(f"Unsupported workflow event: {event_name}") + + if match := re.fullmatch(r"([0-9]+\.[0-9]+\.[0-9]+)a([0-9]+)", python_version): + release_version = f"{match.group(1)}-alpha.{match.group(2)}" + elif re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", python_version): + release_version = python_version + else: + raise SystemExit( + "Python runtime version must be stable or a numbered alpha, " + f"for example 0.136.0 or 0.136.0a2; found {python_version}" + ) + + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: + print(f"python_version={python_version}", file=output) + print(f"release_tag=rust-v{release_version}", file=output) + PY + + - name: Download Python runtime release artifacts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PYTHON_RUNTIME_VERSION: ${{ steps.python_runtime.outputs.python_version }} + RELEASE_TAG: ${{ steps.python_runtime.outputs.release_tag }} + run: | + set -euo pipefail + mkdir -p dist/python-runtime dist/python-runtime-packages + gh release download "$RELEASE_TAG" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "openai_codex_cli_bin-${PYTHON_RUNTIME_VERSION}-*.whl" \ + --dir dist/python-runtime + gh release download "$RELEASE_TAG" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-package-*-unknown-linux-musl.tar.gz" \ + --dir dist/python-runtime-packages + + shopt -s nullglob + wheels=(dist/python-runtime/*.whl) + if [[ "${#wheels[@]}" -ne 6 ]]; then + echo "Expected 6 Python runtime wheels for ${PYTHON_RUNTIME_VERSION}, found ${#wheels[@]}." + exit 1 + fi + packages=(dist/python-runtime-packages/*.tar.gz) + if [[ "${#packages[@]}" -ne 2 ]]; then + echo "Expected 2 Linux package archives for ${PYTHON_RUNTIME_VERSION}, found ${#packages[@]}." + exit 1 + fi + + - name: Build musllinux Python runtime wheels + env: + RELEASE_TAG: ${{ steps.python_runtime.outputs.release_tag }} + run: | + set -euo pipefail + + python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build + + while read -r target platform_tag; do + stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${target}-${platform_tag}" + python3 sdk/python/scripts/update_sdk_artifacts.py \ + stage-runtime \ + "$stage_dir" \ + "dist/python-runtime-packages/codex-package-${target}.tar.gz" \ + --codex-version "$RELEASE_TAG" \ + --platform-tag "$platform_tag" + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build \ + --wheel \ + --outdir dist/python-runtime \ + "$stage_dir" + done <<'EOF' + aarch64-unknown-linux-musl musllinux_1_1_aarch64 + x86_64-unknown-linux-musl musllinux_1_1_x86_64 + EOF + + shopt -s nullglob + wheels=(dist/python-runtime/*.whl) + if [[ "${#wheels[@]}" -ne 8 ]]; then + echo "Expected 8 Python runtime wheels, found ${#wheels[@]}." + exit 1 + fi + ls -lh dist/python-runtime + + - name: Publish Python runtime wheels to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + packages-dir: dist/python-runtime + skip-existing: true + + build-python-sdk: + if: github.event_name == 'push' && github.repository == 'openai/codex' name: build-python-sdk + needs: publish-python-runtime runs-on: ubuntu-latest permissions: contents: read @@ -29,11 +175,6 @@ jobs: set -euo pipefail sdk_version="${GITHUB_REF_NAME#python-v}" - if [[ ! "${sdk_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+b[0-9]+$ ]]; then - echo "Python SDK release tags must identify a beta release, for example python-v0.1.0b1." - exit 1 - fi - # Build in a glibc Linux image so release type generation installs # the pinned manylinux runtime wheel. docker run --rm \ diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 364fe78902..ec1b902e2a 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -929,7 +929,6 @@ jobs: sign_macos: ${{ steps.release_mode.outputs.sign_macos }} should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }} npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }} - should_publish_python_runtime: ${{ steps.python_runtime_publish_settings.outputs.should_publish }} steps: - name: Checkout repository @@ -1154,27 +1153,6 @@ jobs: echo "npm_tag=" >> "$GITHUB_OUTPUT" fi - - name: Determine Python runtime publish settings - id: python_runtime_publish_settings - env: - VERSION: ${{ steps.release_name.outputs.name }} - run: | - set -euo pipefail - version="${VERSION}" - - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - else - echo "should_publish=false" >> "$GITHUB_OUTPUT" - fi - - name: Setup pnpm if: ${{ env.SIGN_MACOS == 'true' }} uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 @@ -1439,53 +1417,6 @@ jobs: exit "${publish_status}" done - # Publish the platform-specific Python runtime wheels using PyPI trusted publishing. - # PyPI project configuration must trust this workflow and job. Keep this - # non-blocking while the Python runtime publishing path is new; failures still - # need release follow-up, but should not invalidate the Rust release itself. - publish-python-runtime: - # Publish to PyPI for stable releases and alpha pre-releases with numeric suffixes. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_python_runtime == 'true' - }} - name: publish-python-runtime - needs: release - runs-on: ubuntu-latest - continue-on-error: true - environment: pypi - permissions: - id-token: write # Required for PyPI trusted publishing. - contents: read - - steps: - - name: Download Python runtime wheels from release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} - run: | - set -euo pipefail - python_version="$RELEASE_VERSION" - python_version="${python_version/-alpha./a}" - python_version="${python_version/-beta./b}" - python_version="${python_version/-rc./rc}" - - mkdir -p dist/python-runtime - gh release download "$RELEASE_TAG" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "openai_codex_cli_bin-${python_version}-*.whl" \ - --dir dist/python-runtime - ls -lh dist/python-runtime - - - name: Publish Python runtime wheels to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 - with: - packages-dir: dist/python-runtime - skip-existing: true - deploy-dev-website: name: Trigger developers.openai.com deploy needs: release From 2d385e166c2dff131ca21294216ce61eb62fd3c2 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 3 Jun 2026 01:10:26 +0200 Subject: [PATCH 46/59] feat: add skills extension scaffold (#25953) ## Disclaimer This is only here for iteration purpose! Do not make any code rely on this ## Why Skills still live behind `codex-core` discovery and injection paths, but the extension system needs an authority-aware home before that logic can move. This adds that boundary without changing current skills behavior, and keeps host, executor, and remote skills distinct so future list/read/search flows do not collapse back to ambient local paths. ## What changed - Add the `codex-skills-extension` workspace/Bazel crate under `ext/skills`. - Define the initial catalog, authority, provider, and turn-state types for authority-bound skill packages and resources. - Register placeholder thread/config/prompt/turn lifecycle contributors plus host, executor, and remote provider aggregation points. - Capture the remaining extraction work as TODOs, including the missing extension API hooks needed for per-turn catalog construction and typed skill injection. - Keep plugins outside the runtime skills model: plugin-installed skills are treated as materialized host-owned skill sources once available. ## Verification - Not run locally. --- codex-rs/Cargo.lock | 9 ++ codex-rs/Cargo.toml | 1 + codex-rs/ext/skills/BUILD.bazel | 6 + codex-rs/ext/skills/Cargo.toml | 19 +++ codex-rs/ext/skills/src/catalog.rs | 86 +++++++++++ codex-rs/ext/skills/src/extension.rs | 140 ++++++++++++++++++ codex-rs/ext/skills/src/lib.rs | 7 + codex-rs/ext/skills/src/provider.rs | 63 ++++++++ codex-rs/ext/skills/src/providers/executor.rs | 56 +++++++ codex-rs/ext/skills/src/providers/host.rs | 65 ++++++++ codex-rs/ext/skills/src/providers/mod.rs | 50 +++++++ codex-rs/ext/skills/src/providers/remote.rs | 58 ++++++++ codex-rs/ext/skills/src/state.rs | 24 +++ 13 files changed, 584 insertions(+) create mode 100644 codex-rs/ext/skills/BUILD.bazel create mode 100644 codex-rs/ext/skills/Cargo.toml create mode 100644 codex-rs/ext/skills/src/catalog.rs create mode 100644 codex-rs/ext/skills/src/extension.rs create mode 100644 codex-rs/ext/skills/src/lib.rs create mode 100644 codex-rs/ext/skills/src/provider.rs create mode 100644 codex-rs/ext/skills/src/providers/executor.rs create mode 100644 codex-rs/ext/skills/src/providers/host.rs create mode 100644 codex-rs/ext/skills/src/providers/mod.rs create mode 100644 codex-rs/ext/skills/src/providers/remote.rs create mode 100644 codex-rs/ext/skills/src/state.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 33392db931..94dac45699 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3715,6 +3715,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "codex-skills-extension" +version = "0.0.0" +dependencies = [ + "async-trait", + "codex-core", + "codex-extension-api", +] + [[package]] name = "codex-state" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index cc20f8a3a8..482951fb92 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -48,6 +48,7 @@ members = [ "ext/guardian", "ext/image-generation", "ext/memories", + "ext/skills", "ext/web-search", "external-agent-migration", "external-agent-sessions", diff --git a/codex-rs/ext/skills/BUILD.bazel b/codex-rs/ext/skills/BUILD.bazel new file mode 100644 index 0000000000..81410ca1f6 --- /dev/null +++ b/codex-rs/ext/skills/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "skills", + crate_name = "codex_skills_extension", +) diff --git a/codex-rs/ext/skills/Cargo.toml b/codex-rs/ext/skills/Cargo.toml new file mode 100644 index 0000000000..55671df6c9 --- /dev/null +++ b/codex-rs/ext/skills/Cargo.toml @@ -0,0 +1,19 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-skills-extension" +version.workspace = true + +[lib] +name = "codex_skills_extension" +path = "src/lib.rs" +test = false +doctest = false + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +codex-core = { workspace = true } +codex-extension-api = { workspace = true } diff --git a/codex-rs/ext/skills/src/catalog.rs b/codex-rs/ext/skills/src/catalog.rs new file mode 100644 index 0000000000..86ae327d45 --- /dev/null +++ b/codex-rs/ext/skills/src/catalog.rs @@ -0,0 +1,86 @@ +/// Source authority that owns a skill package and must be used to read it. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum SkillSourceKind { + Host, + Executor, + Remote, +} + +/// Opaque authority identity for list/read routing. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct SkillAuthority { + pub kind: SkillSourceKind, + pub id: String, +} + +impl SkillAuthority { + pub fn new(kind: SkillSourceKind, id: impl Into) -> Self { + Self { + kind, + id: id.into(), + } + } +} + +/// Opaque package id. Callers should not parse local paths out of this value. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct SkillPackageId(pub String); + +/// Opaque resource id inside a skill package. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct SkillResourceId(pub String); + +/// Metadata shown in the always-visible skills catalog. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillCatalogEntry { + pub id: SkillPackageId, + pub authority: SkillAuthority, + pub name: String, + pub description: String, + pub entrypoint: SkillResourceId, +} + +/// Merged catalog for one turn. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SkillCatalog { + pub entries: Vec, + pub warnings: Vec, +} + +impl SkillCatalog { + pub fn extend(&mut self, other: SkillCatalog) { + // TODO(skills-extension): dedupe by authority-bound id first, then + // apply name precedence/conflict rules for user-facing mention + // resolution. Names are not stable identities. + self.entries.extend(other.entries); + self.warnings.extend(other.warnings); + } +} + +/// Contents returned after resolving a skill resource through its owner. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillReadResult { + pub resource: SkillResourceId, + pub contents: String, +} + +/// Search results for a package whose files are not readable through ordinary +/// executor filesystem access. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SkillSearchResult { + pub matches: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillSearchMatch { + pub resource: SkillResourceId, + pub title: String, + pub snippet: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillProviderError { + pub message: String, +} + +pub type SkillProviderResult = Result; diff --git a/codex-rs/ext/skills/src/extension.rs b/codex-rs/ext/skills/src/extension.rs new file mode 100644 index 0000000000..bf91b60de1 --- /dev/null +++ b/codex-rs/ext/skills/src/extension.rs @@ -0,0 +1,140 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use codex_core::config::Config; +use codex_extension_api::ConfigContributor; +use codex_extension_api::ContextContributor; +use codex_extension_api::ExtensionData; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::PromptFragment; +use codex_extension_api::ThreadLifecycleContributor; +use codex_extension_api::ThreadStartInput; +use codex_extension_api::TurnLifecycleContributor; +use codex_extension_api::TurnStartInput; + +use crate::provider::SkillListQuery; +use crate::providers::SkillProviders; +use crate::state::SkillsExtensionConfig; +use crate::state::SkillsTurnState; + +#[derive(Clone, Debug, Default)] +struct SkillsExtension { + providers: SkillProviders, +} + +#[async_trait::async_trait] +impl ThreadLifecycleContributor for SkillsExtension { + async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { + // TODO(skills-extension): this is only the thread-level config snapshot. + // Skills are loaded per turn today because cwd, plugin roots, config + // layers, and the primary environment filesystem can change between + // turns. The real migration needs a turn-preparation hook before model + // input construction, not just thread startup. + input + .thread_store + .insert(SkillsExtensionConfig::from_config(input.config)); + } +} + +impl ConfigContributor for SkillsExtension { + fn on_config_changed( + &self, + _session_store: &ExtensionData, + thread_store: &ExtensionData, + _previous_config: &Config, + new_config: &Config, + ) { + // TODO(skills-extension): update any cached/listing state that depends + // on skill config overrides, bundled skills, or include_instructions. + thread_store.insert(SkillsExtensionConfig::from_config(new_config)); + } +} + +impl ContextContributor for SkillsExtension { + fn contribute<'a>( + &'a self, + _session_store: &'a ExtensionData, + thread_store: &'a ExtensionData, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let Some(config) = thread_store.get::() else { + return Vec::new(); + }; + if !config.include_instructions { + return Vec::new(); + } + + // TODO(skills-extension): render the available-skills developer + // block from the merged per-turn SkillCatalog. This should + // preserve the existing bounded metadata budget, root aliasing, + // warning behavior, and telemetry side effects. + // + // TODO(skills-extension): avoid using raw PromptFragment strings + // for final skills context if the extension API grows typed + // contextual fragments. Existing skill blocks are typed so resume + // and history filtering can recognize them reliably. + // + // TODO(skills-extension): ContextContributor currently cannot see + // the turn_store, so it cannot read the per-turn catalog seeded by + // the turn provider path below. This is the main extension-api gap + // to close before skills can move out of codex-core. + Vec::new() + }) + } +} + +#[async_trait::async_trait] +impl TurnLifecycleContributor for SkillsExtension { + async fn on_turn_start(&self, input: TurnStartInput<'_>) { + // TODO(skills-extension): replace this lifecycle callback with a real + // turn-input contributor in codex-extension-api. This placeholder only + // demonstrates where provider aggregation belongs; it cannot resolve + // real skills because this hook does not receive cwd, executor + // selections, effective plugins/materialized plugin skill roots, + // connector slug counts, user input, cancellation, analytics, or a + // response-item output channel. + let query = SkillListQuery::placeholder_for_turn(input.turn_id); + let catalog = self + .providers + .list_for_turn(query) + .await + .unwrap_or_default(); + + input.turn_store.insert(SkillsTurnState { + catalog, + entrypoints_injected: false, + }); + + // TODO(skills-extension): after catalog resolution, collect explicit + // skill mentions from structured UserInput and text mentions. + // + // TODO(skills-extension): inject selected entrypoints as typed + // contextual user fragments, preserving ... history + // recognition and bounded body size limits. + // + // TODO(skills-extension): move explicit $skill mention resolution, + // SKILL.md reads, skill body injection, and MCP dependency prompting + // out of codex-core's turn assembly once that hook exists. + } +} + +/// Installs the skills extension contributor sketch. +/// +/// TODO(skills-extension): pass host capabilities here rather than letting the +/// extension depend on Session. The final extension needs capability objects for +/// loading skill roots, emitting warnings, tracking analytics, prompting for MCP +/// dependency install, refreshing MCP servers, and serving app-server catalog +/// requests. +/// +/// TODO(skills-extension): plugin handling should stay outside the runtime +/// skills model. Plugins are bundle/install units; once installed or refreshed, +/// their skill descriptors/roots should be handed to this extension just like +/// any other host-owned skill source. +pub fn install(registry: &mut ExtensionRegistryBuilder) { + let extension = Arc::new(SkillsExtension::default()); + registry.thread_lifecycle_contributor(extension.clone()); + registry.config_contributor(extension.clone()); + registry.prompt_contributor(extension.clone()); + registry.turn_lifecycle_contributor(extension); +} diff --git a/codex-rs/ext/skills/src/lib.rs b/codex-rs/ext/skills/src/lib.rs new file mode 100644 index 0000000000..6e8a0700a1 --- /dev/null +++ b/codex-rs/ext/skills/src/lib.rs @@ -0,0 +1,7 @@ +pub mod catalog; +mod extension; +pub mod provider; +mod providers; +mod state; + +pub use extension::install; diff --git a/codex-rs/ext/skills/src/provider.rs b/codex-rs/ext/skills/src/provider.rs new file mode 100644 index 0000000000..730adb51ba --- /dev/null +++ b/codex-rs/ext/skills/src/provider.rs @@ -0,0 +1,63 @@ +use std::future::Future; + +use crate::catalog::SkillAuthority; +use crate::catalog::SkillCatalog; +use crate::catalog::SkillPackageId; +use crate::catalog::SkillProviderResult; +use crate::catalog::SkillReadResult; +use crate::catalog::SkillResourceId; +use crate::catalog::SkillSearchResult; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillListQuery { + pub turn_id: String, + pub executor_authorities: Vec, + pub include_host_skills: bool, + pub include_remote_skills: bool, +} + +impl SkillListQuery { + pub(crate) fn placeholder_for_turn(turn_id: &str) -> Self { + Self { + turn_id: turn_id.to_string(), + executor_authorities: Vec::new(), + include_host_skills: true, + include_remote_skills: true, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillReadRequest { + pub authority: SkillAuthority, + pub resource: SkillResourceId, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillSearchRequest { + pub authority: SkillAuthority, + pub package: SkillPackageId, + pub query: String, +} + +/// Source-specific skill catalog and resource access. +/// +/// Implementations must preserve authority boundaries: a resource listed by a +/// provider must be read or searched through the same provider/authority rather +/// than converted into an ambient local path. +pub trait SkillProvider: Send + Sync { + fn list( + &self, + query: SkillListQuery, + ) -> impl Future> + Send; + + fn read( + &self, + request: SkillReadRequest, + ) -> impl Future> + Send; + + fn search( + &self, + request: SkillSearchRequest, + ) -> impl Future> + Send; +} diff --git a/codex-rs/ext/skills/src/providers/executor.rs b/codex-rs/ext/skills/src/providers/executor.rs new file mode 100644 index 0000000000..f9f399eebc --- /dev/null +++ b/codex-rs/ext/skills/src/providers/executor.rs @@ -0,0 +1,56 @@ +use std::future; + +use crate::catalog::SkillCatalog; +use crate::catalog::SkillProviderResult; +use crate::catalog::SkillReadResult; +use crate::catalog::SkillSearchResult; +use crate::provider::SkillListQuery; +use crate::provider::SkillProvider; +use crate::provider::SkillReadRequest; +use crate::provider::SkillSearchRequest; + +#[derive(Clone, Debug, Default)] +pub(crate) struct ExecutorSkillProvider; + +impl SkillProvider for ExecutorSkillProvider { + fn list( + &self, + _query: SkillListQuery, + ) -> impl Future> + Send { + future::ready(Ok(SkillCatalog::default())) + // TODO(skills-extension): list repo/workspace skills from each + // executor authority selected for the turn. + // + // TODO(skills-extension): if the executor exposes filesystem reads, + // preserve the existing SKILL.md discovery semantics. If CCA/no-FS + // applies, query an executor catalog/read capability instead. + // + // TODO(skills-extension): include the executor/environment id in skill + // identity so two executors with the same path/name do not collide. + } + + fn read( + &self, + request: SkillReadRequest, + ) -> impl Future> + Send { + future::ready(Err(crate::catalog::SkillProviderError { + message: format!( + "executor skill resource `{}` is not implemented", + request.resource.0 + ), + })) + // TODO(skills-extension): route reads back to the executor authority + // that listed the resource. Do not mint local paths from remote or + // non-filesystem executor resources. + } + + fn search( + &self, + _request: SkillSearchRequest, + ) -> impl Future> + Send { + future::ready(Ok(SkillSearchResult::default())) + // TODO(skills-extension): support search for executor skills only when + // the executor offers a catalog/search API. For ordinary filesystem + // executors, the model can keep using regular file reads/search tools. + } +} diff --git a/codex-rs/ext/skills/src/providers/host.rs b/codex-rs/ext/skills/src/providers/host.rs new file mode 100644 index 0000000000..fc4317f859 --- /dev/null +++ b/codex-rs/ext/skills/src/providers/host.rs @@ -0,0 +1,65 @@ +use std::future; + +use crate::catalog::SkillCatalog; +use crate::catalog::SkillProviderResult; +use crate::catalog::SkillReadResult; +use crate::catalog::SkillSearchResult; +use crate::provider::SkillListQuery; +use crate::provider::SkillProvider; +use crate::provider::SkillReadRequest; +use crate::provider::SkillSearchRequest; + +#[derive(Clone, Debug, Default)] +pub(crate) struct HostSkillProvider; + +impl SkillProvider for HostSkillProvider { + fn list( + &self, + _query: SkillListQuery, + ) -> impl Future> + Send { + future::ready(Ok(SkillCatalog::default())) + // TODO(skills-extension): list bundled/system/user/plugin-installed + // skills owned by the Codex host. This is the source for skills that + // are not tied to a particular executor authority. + // + // TODO(skills-extension): plugins should be treated as packaging and + // installation only. After a plugin is downloaded, cached, refreshed, + // or installed, its skill roots/descriptors should enter this provider + // and then use the normal skills catalog/read/injection code. + // + // TODO(skills-extension): remote skills that are materialized locally + // by plugin install or explicit download should also hand off here + // rather than remain remote-provider entries at runtime. + // + // TODO(skills-extension): keep current bundled system skill install or + // replace it with embedded host assets so CCA/no-FS hosts do not depend + // on local writable skill cache directories. + } + + fn read( + &self, + request: SkillReadRequest, + ) -> impl Future> + Send { + future::ready(Err(crate::catalog::SkillProviderError { + message: format!( + "host skill resource `{}` is not implemented", + request.resource.0 + ), + })) + // TODO(skills-extension): read host-owned entrypoints and supporting + // resources by opaque id, not by assuming a local filesystem path. + // + // TODO(skills-extension): for plugin-installed skills, route reads + // through the materialized plugin cache/root that produced the catalog + // entry, while keeping the public id opaque and authority-bound. + } + + fn search( + &self, + _request: SkillSearchRequest, + ) -> impl Future> + Send { + future::ready(Ok(SkillSearchResult::default())) + // TODO(skills-extension): decide whether host skills need search, or + // whether direct read by opaque resource id is enough. + } +} diff --git a/codex-rs/ext/skills/src/providers/mod.rs b/codex-rs/ext/skills/src/providers/mod.rs new file mode 100644 index 0000000000..fcf2788480 --- /dev/null +++ b/codex-rs/ext/skills/src/providers/mod.rs @@ -0,0 +1,50 @@ +mod executor; +mod host; +mod remote; + +use crate::catalog::SkillCatalog; +use crate::catalog::SkillProviderResult; +use crate::provider::SkillListQuery; +use crate::provider::SkillProvider; + +use executor::ExecutorSkillProvider; +use host::HostSkillProvider; +use remote::RemoteSkillProvider; + +#[derive(Clone, Debug, Default)] +pub(crate) struct SkillProviders { + host: HostSkillProvider, + executor: ExecutorSkillProvider, + remote: RemoteSkillProvider, +} + +impl SkillProviders { + pub(crate) async fn list_for_turn( + &self, + query: SkillListQuery, + ) -> SkillProviderResult { + let mut catalog = SkillCatalog::default(); + + if query.include_host_skills { + catalog.extend(self.host.list(query.clone()).await?); + } + + if !query.executor_authorities.is_empty() { + catalog.extend(self.executor.list(query.clone()).await?); + } + + if query.include_remote_skills { + catalog.extend(self.remote.list(query).await?); + } + + // TODO(skills-extension): apply final merged-catalog policy here: + // source precedence, duplicate name handling, disabled-skill rules, + // product/session-source filtering, and telemetry for omitted entries. + // + // TODO(skills-extension): treat plugin-installed skills as ordinary + // host catalog entries by this point. Plugin identity may remain useful + // for display, auth, and uninstall flows, but mention resolution and + // entrypoint injection should not special-case plugin packaging. + Ok(catalog) + } +} diff --git a/codex-rs/ext/skills/src/providers/remote.rs b/codex-rs/ext/skills/src/providers/remote.rs new file mode 100644 index 0000000000..22dd3368a5 --- /dev/null +++ b/codex-rs/ext/skills/src/providers/remote.rs @@ -0,0 +1,58 @@ +use std::future; + +use crate::catalog::SkillCatalog; +use crate::catalog::SkillProviderResult; +use crate::catalog::SkillReadResult; +use crate::catalog::SkillSearchResult; +use crate::provider::SkillListQuery; +use crate::provider::SkillProvider; +use crate::provider::SkillReadRequest; +use crate::provider::SkillSearchRequest; + +#[derive(Clone, Debug, Default)] +pub(crate) struct RemoteSkillProvider; + +impl SkillProvider for RemoteSkillProvider { + fn list( + &self, + _query: SkillListQuery, + ) -> impl Future> + Send { + future::ready(Ok(SkillCatalog::default())) + // TODO(skills-extension): list org/account/backend skills from a + // remote catalog only when they are not installed/materialized into the + // host. These skills should use opaque ids and backend authority, not + // paths. + // + // TODO(skills-extension): if a remote skill is downloaded or installed + // as part of a plugin-like bundle, hand it to HostSkillProvider for + // runtime listing/read instead of keeping a separate remote runtime + // path. + // + // TODO(skills-extension): decide how org policy and local enable/disable + // rules combine when the backend supplies a managed skill catalog. + } + + fn read( + &self, + request: SkillReadRequest, + ) -> impl Future> + Send { + future::ready(Err(crate::catalog::SkillProviderError { + message: format!( + "remote skill resource `{}` is not implemented", + request.resource.0 + ), + })) + // TODO(skills-extension): read remote skill entrypoints and supporting + // files through authenticated backend APIs. + } + + fn search( + &self, + _request: SkillSearchRequest, + ) -> impl Future> + Send { + future::ready(Ok(SkillSearchResult::default())) + // TODO(skills-extension): expose model-facing skills/search or resource + // APIs for large remote packages so the model can progressively + // disclose supporting files without filesystem access. + } +} diff --git a/codex-rs/ext/skills/src/state.rs b/codex-rs/ext/skills/src/state.rs new file mode 100644 index 0000000000..d6c355ed34 --- /dev/null +++ b/codex-rs/ext/skills/src/state.rs @@ -0,0 +1,24 @@ +use codex_core::config::Config; + +use crate::catalog::SkillCatalog; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SkillsExtensionConfig { + pub(crate) include_instructions: bool, + pub(crate) bundled_skills_enabled: bool, +} + +impl SkillsExtensionConfig { + pub(crate) fn from_config(config: &Config) -> Self { + Self { + include_instructions: config.include_skill_instructions, + bundled_skills_enabled: config.bundled_skills_enabled(), + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct SkillsTurnState { + pub(crate) catalog: SkillCatalog, + pub(crate) entrypoints_injected: bool, +} From 6471f8b31aba50c87d5616cc0a203160a80b5322 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 2 Jun 2026 16:22:32 -0700 Subject: [PATCH 47/59] [codex] Fix Windows BuildBuddy Bazel wrapper execution (#25915) ## Why #25156 moved Bazel CI launches into a shared Python wrapper. On Windows, launching Bazel with `os.execvp` can split the spaced `--test_env=PATH=...` argument and fail to propagate the eventual Bazel exit status, allowing jobs to pass without running tests. This reapplies the wrapper after #25909 with a Windows-safe launch path. ## What changed Use a waited `subprocess.run` launch on Windows while preserving `os.execvp` on Unix. Add a process-level regression test for spaced arguments and child exit status, and run it on Windows Bazel shard 1. ## Experiment To confirm Bazel was actually invoking tests, patch `87b61d0be6` temporarily added an intentionally failing `codex-core` unit test. Bazel failed on that sentinel on all three major platforms: - [Linux Bazel test](https://github.com/openai/codex/actions/runs/26841132773/job/79151062486) - [macOS Bazel test](https://github.com/openai/codex/actions/runs/26841132773/job/79151062362) - [Windows Bazel test shard 1/4](https://github.com/openai/codex/actions/runs/26841132773/job/79151062155) The sentinel was removed after collecting this evidence. Windows Bazel [clippy](https://github.com/openai/codex/actions/runs/26841132773/job/79151062914) and [release verification](https://github.com/openai/codex/actions/runs/26841132773/job/79151062739) also passed. ## Validation After removing the sentinel, `just test -p codex-core` no longer reported it. The local run retained two unrelated environment-specific failures. --- .bazelrc | 46 ++-- .github/scripts/run-bazel-ci.sh | 126 ++++------- .github/scripts/run-bazel-query-ci.sh | 56 +---- .github/scripts/run_bazel_with_buildbuddy.py | 147 ++++++++++++ .github/scripts/rusty_v8_bazel.py | 63 +++--- .../scripts/test_run_bazel_with_buildbuddy.py | 214 ++++++++++++++++++ .github/scripts/test_rusty_v8_bazel.py | 49 ++-- .github/workflows/bazel.yml | 13 +- .github/workflows/rusty-v8-release.yml | 5 +- .github/workflows/v8-canary.yml | 7 +- codex-rs/docs/bazel.md | 114 +++++++++- justfile | 11 +- scripts/list-bazel-clippy-targets.sh | 24 +- 13 files changed, 657 insertions(+), 218 deletions(-) create mode 100755 .github/scripts/run_bazel_with_buildbuddy.py create mode 100644 .github/scripts/test_run_bazel_with_buildbuddy.py diff --git a/.bazelrc b/.bazelrc index 6357eb83e4..e39a3aff22 100644 --- a/.bazelrc +++ b/.bazelrc @@ -38,24 +38,50 @@ common:windows --test_env=WINDIR common --test_env=RUST_MIN_STACK=8388608 # 8 MiB common --test_output=errors -common --bes_results_url=https://app.buildbuddy.io/invocation/ -common --bes_backend=grpcs://remote.buildbuddy.io -common --remote_cache=grpcs://remote.buildbuddy.io -common --remote_download_toplevel common --nobuild_runfile_links +# These settings tune BuildBuddy/RBE behavior but do not contact a remote +# service unless a `buildbuddy-*` configuration below supplies an endpoint. +common --remote_download_toplevel common --remote_timeout=3600 common --noexperimental_throttle_remote_action_building common --experimental_remote_execution_keepalive common --grpc_keepalive_time=30s -common --experimental_remote_downloader=grpcs://remote.buildbuddy.io + +# Opt-in remote configurations selected by +# `.github/scripts/run_bazel_with_buildbuddy.py`. Plain Bazel commands do not +# contact BuildBuddy unless a user selects one of these configurations. +# Use the generic host for cache, BES, and downloads without remote execution. +common:buildbuddy-generic --bes_backend=grpcs://remote.buildbuddy.io +common:buildbuddy-generic --bes_results_url=https://app.buildbuddy.io/invocation/ +common:buildbuddy-generic --remote_cache=grpcs://remote.buildbuddy.io +common:buildbuddy-generic --experimental_remote_downloader=grpcs://remote.buildbuddy.io + +# Add remote execution on the generic host. +common:buildbuddy-generic-rbe --config=buildbuddy-generic +common:buildbuddy-generic-rbe --config=remote +common:buildbuddy-generic-rbe --remote_executor=grpcs://remote.buildbuddy.io + +# Use the OpenAI tenant for cache, BES, and downloads without remote execution. +common:buildbuddy-openai --bes_backend=grpcs://openai.buildbuddy.io +common:buildbuddy-openai --bes_results_url=https://openai.buildbuddy.io/invocation/ +common:buildbuddy-openai --remote_cache=grpcs://openai.buildbuddy.io +common:buildbuddy-openai --experimental_remote_downloader=grpcs://openai.buildbuddy.io + +# Add remote execution on the OpenAI tenant. +common:buildbuddy-openai-rbe --config=buildbuddy-openai +common:buildbuddy-openai-rbe --config=remote +common:buildbuddy-openai-rbe --remote_executor=grpcs://openai.buildbuddy.io # This limits both in-flight executions and concurrent downloads. Even with high number # of jobs execution will still be limited by CPU cores, so this just pays a bit of # memory in exchange for higher download concurrency. common --jobs=30 +# Shared remote execution policy. The endpoint-bearing `buildbuddy-*-rbe` +# configurations include this group; CI configs override TestRunner below +# when tests must remain local on their runner. +common:remote --strategy=remote common:remote --extra_execution_platforms=//:rbe -common:remote --remote_executor=grpcs://remote.buildbuddy.io common:remote --jobs=800 # TODO(team): Evaluate if this actually helps, zbarsky is not sure, everything seems bottlenecked on `core` either way. # Enable pipelined compilation since we are not bound by local CPU count. @@ -146,15 +172,11 @@ common:ci-windows --repo_contents_cache=D:/a/.cache/bazel-repo-contents-cache # Linux crossbuilds don't work until we untangle the libc constraint mess. common:ci-linux --config=ci-bazel common:ci-linux --build_metadata=TAG_os=linux -common:ci-linux --config=remote -common:ci-linux --strategy=remote common:ci-linux --platforms=//:rbe # On mac, we can run all the build actions remotely but test actions locally. common:ci-macos --config=ci-bazel common:ci-macos --build_metadata=TAG_os=macos -common:ci-macos --config=remote -common:ci-macos --strategy=remote common:ci-macos --strategy=TestRunner=darwin-sandbox,local # On Windows, use Linux remote execution for build actions but keep test actions @@ -162,9 +184,7 @@ common:ci-macos --strategy=TestRunner=darwin-sandbox,local # still run against Windows binaries. common:ci-windows-cross --config=ci-windows common:ci-windows-cross --build_metadata=TAG_windows_cross_compile=true -common:ci-windows-cross --config=remote common:ci-windows-cross --host_platform=//:rbe -common:ci-windows-cross --strategy=remote common:ci-windows-cross --strategy=TestRunner=local common:ci-windows-cross --local_test_jobs=4 common:ci-windows-cross --test_env=RUST_TEST_THREADS=1 @@ -180,8 +200,6 @@ common:ci-windows-cross --extra_toolchains=//:windows_gnullvm_tests_on_msvc_host common:ci-v8 --config=ci common:ci-v8 --build_metadata=TAG_workflow=v8 common:ci-v8 --build_metadata=TAG_os=linux -common:ci-v8 --config=remote -common:ci-v8 --strategy=remote # Source-built Bazel V8 artifacts use the in-process sandbox by default. This # does not affect Cargo's default prebuilt rusty_v8 path. diff --git a/.github/scripts/run-bazel-ci.sh b/.github/scripts/run-bazel-ci.sh index f98e4d8cb9..89f937a998 100755 --- a/.github/scripts/run-bazel-ci.sh +++ b/.github/scripts/run-bazel-ci.sh @@ -53,11 +53,20 @@ fi run_bazel() { if [[ "${RUNNER_OS:-}" == "Windows" ]]; then - MSYS2_ARG_CONV_EXCL='*' bazel "$@" + MSYS2_ARG_CONV_EXCL='*' "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@" return fi - bazel "$@" + "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@" +} + +run_bazel_with_startup_args() { + if (( ${#bazel_startup_args[@]} > 0 )); then + run_bazel "${bazel_startup_args[@]}" "$@" + return + fi + + run_bazel "$@" } ci_config=ci-linux @@ -77,23 +86,16 @@ esac print_bazel_test_log_tails() { local console_log="$1" local testlogs_dir - local -a bazel_info_cmd=(bazel) + local -a bazel_info_args=(info) - - if (( ${#bazel_startup_args[@]} > 0 )); then - bazel_info_cmd+=("${bazel_startup_args[@]}") - fi - - # `bazel info` needs the same CI config as the failed test invocation so - # platform-specific output roots match. On Windows, omitting `ci-windows` - # would point at `local_windows-fastbuild` even when the test ran with the - # MSVC host platform under `local_windows_msvc-fastbuild`. if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then - bazel_info_args+=( - "--config=${ci_config}" - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - ) + # `bazel info` needs the same CI config as the failed test invocation so + # platform-specific output roots match. On Windows, omitting `ci-windows` + # would point at `local_windows-fastbuild` even when the test ran with the + # MSVC host platform under `local_windows_msvc-fastbuild`. + bazel_info_args+=("--config=${ci_config}") fi + # Only pass flags that affect Bazel's output-root selection or repository # lookup. Test/build-only flags such as execution logs or remote download # mode can make `bazel info` fail, which would hide the real test log path. @@ -105,7 +107,7 @@ print_bazel_test_log_tails() { esac done - testlogs_dir="$(run_bazel "${bazel_info_cmd[@]:1}" \ + testlogs_dir="$(run_bazel_with_startup_args \ --noexperimental_remote_repo_contents_cache \ "${bazel_info_args[@]}" \ bazel-testlogs 2>/dev/null || echo bazel-testlogs)" @@ -254,8 +256,9 @@ if [[ ${#bazel_args[@]} -eq 0 || ${#bazel_targets[@]} -eq 0 ]]; then fi if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then - # Fork PRs do not receive the BuildBuddy secret needed for the remote - # cross-compile config. Preserve the previous local Windows build shape. + # Windows cross-compilation depends on authenticated RBE. Preserve the local + # Windows build shape when credentials are unavailable. + ci_config=ci-windows windows_msvc_host_platform=1 fi @@ -297,9 +300,9 @@ if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -n "${BUI fi if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then - # The Windows cross-compile config depends on remote execution. Fork PRs do - # not receive the BuildBuddy secret, so fall back to the existing local build - # shape and keep its lower concurrency cap. + # The Windows cross-compile config depends on authenticated remote + # execution. When credentials are unavailable, keep the local build shape + # and its lower concurrency cap. post_config_bazel_args+=(--jobs=8) fi @@ -377,70 +380,31 @@ fi bazel_console_log="$(mktemp)" trap 'rm -f "$bazel_console_log"' EXIT -bazel_cmd=(bazel) -if (( ${#bazel_startup_args[@]} > 0 )); then - bazel_cmd+=("${bazel_startup_args[@]}") -fi - +bazel_run_args=( + "${bazel_args[@]}" +) if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then echo "BuildBuddy API key is available; using remote Bazel configuration." - # Work around Bazel 9 remote repo contents cache / overlay materialization failures - # seen in CI (for example "is not a symlink" or permission errors while - # materializing external repos such as rules_perl). We still use BuildBuddy for - # remote execution/cache; this only disables the startup-level repo contents cache. - bazel_run_args=( - "${bazel_args[@]}" - "--config=${ci_config}" - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - ) - if (( ${#post_config_bazel_args[@]} > 0 )); then - bazel_run_args+=("${post_config_bazel_args[@]}") - fi - set +e - run_bazel "${bazel_cmd[@]:1}" \ - --noexperimental_remote_repo_contents_cache \ - "${bazel_run_args[@]}" \ - -- \ - "${bazel_targets[@]}" \ - 2>&1 | tee "$bazel_console_log" - bazel_status=${PIPESTATUS[0]} - set -e + bazel_run_args+=("--config=${ci_config}") else echo "BuildBuddy API key is not available; using local Bazel configuration." - # Keep fork/community PRs on Bazel but disable remote services that are - # configured in .bazelrc and require auth. - # - # Flag docs: - # - Command-line reference: https://bazel.build/reference/command-line-reference - # - Remote caching overview: https://bazel.build/remote/caching - # - Remote execution overview: https://bazel.build/remote/rbe - # - Build Event Protocol overview: https://bazel.build/remote/bep - # - # --noexperimental_remote_repo_contents_cache: - # disable remote repo contents cache enabled in .bazelrc startup options. - # https://bazel.build/reference/command-line-reference#startup_options-flag--experimental_remote_repo_contents_cache - # --remote_cache= and --remote_executor=: - # clear remote cache/execution endpoints configured in .bazelrc. - # https://bazel.build/reference/command-line-reference#common_options-flag--remote_cache - # https://bazel.build/reference/command-line-reference#common_options-flag--remote_executor - bazel_run_args=( - "${bazel_args[@]}" - --remote_cache= - --remote_executor= - ) - if (( ${#post_config_bazel_args[@]} > 0 )); then - bazel_run_args+=("${post_config_bazel_args[@]}") - fi - set +e - run_bazel "${bazel_cmd[@]:1}" \ - --noexperimental_remote_repo_contents_cache \ - "${bazel_run_args[@]}" \ - -- \ - "${bazel_targets[@]}" \ - 2>&1 | tee "$bazel_console_log" - bazel_status=${PIPESTATUS[0]} - set -e fi +if (( ${#post_config_bazel_args[@]} > 0 )); then + bazel_run_args+=("${post_config_bazel_args[@]}") +fi +set +e +# Work around Bazel 9 remote repo contents cache / overlay materialization +# failures seen in CI (for example "is not a symlink" or permission errors +# while materializing external repos such as rules_perl). This only disables +# the startup-level repo contents cache; keyed runs still use BuildBuddy. +run_bazel_with_startup_args \ + --noexperimental_remote_repo_contents_cache \ + "${bazel_run_args[@]}" \ + -- \ + "${bazel_targets[@]}" \ + 2>&1 | tee "$bazel_console_log" +bazel_status=${PIPESTATUS[0]} +set -e if [[ ${bazel_status:-0} -ne 0 ]]; then if [[ $print_failed_bazel_action_summary -eq 1 ]]; then diff --git a/.github/scripts/run-bazel-query-ci.sh b/.github/scripts/run-bazel-query-ci.sh index dd03b67169..f5d4f56f49 100755 --- a/.github/scripts/run-bazel-query-ci.sh +++ b/.github/scripts/run-bazel-query-ci.sh @@ -2,48 +2,17 @@ set -euo pipefail -# Run Bazel queries with the same CI startup settings as the main build/test -# invocation so target-discovery queries can reuse the same Bazel server. +# Run target-discovery queries with the same startup settings as the main +# build/test invocation so they can reuse the same Bazel server. Queries only +# enumerate labels, so they intentionally do not select CI or remote configs. -query_args=() -windows_cross_compile=0 -while [[ $# -gt 0 ]]; do - case "$1" in - --windows-cross-compile) - windows_cross_compile=1 - shift - ;; - --) - shift - break - ;; - *) - query_args+=("$1") - shift - ;; - esac -done - -if [[ $# -ne 1 ]]; then - echo "Usage: $0 [--windows-cross-compile] [...] -- " >&2 +if [[ $# -lt 2 || "${@: -2:1}" != "--" ]]; then + echo "Usage: $0 [...] -- " >&2 exit 1 fi -query_expression="$1" - -ci_config=ci-linux -case "${RUNNER_OS:-}" in - macOS) - ci_config=ci-macos - ;; - Windows) - if [[ $windows_cross_compile -eq 1 ]]; then - ci_config=ci-windows-cross - else - ci_config=ci-windows - fi - ;; -esac +query_args=("${@:1:$#-2}") +query_expression="${@: -1}" bazel_startup_args=() if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then @@ -60,12 +29,6 @@ run_bazel() { } bazel_query_args=(--noexperimental_remote_repo_contents_cache query) -if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then - bazel_query_args+=( - "--config=${ci_config}" - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - ) -fi if [[ -n "${BAZEL_REPO_CONTENTS_CACHE:-}" ]]; then bazel_query_args+=("--repo_contents_cache=${BAZEL_REPO_CONTENTS_CACHE}") @@ -75,7 +38,10 @@ if [[ -n "${BAZEL_REPOSITORY_CACHE:-}" ]]; then bazel_query_args+=("--repository_cache=${BAZEL_REPOSITORY_CACHE}") fi -bazel_query_args+=("${query_args[@]}" "$query_expression") +if (( ${#query_args[@]} > 0 )); then + bazel_query_args+=("${query_args[@]}") +fi +bazel_query_args+=("$query_expression") if (( ${#bazel_startup_args[@]} > 0 )); then run_bazel "${bazel_startup_args[@]}" "${bazel_query_args[@]}" diff --git a/.github/scripts/run_bazel_with_buildbuddy.py b/.github/scripts/run_bazel_with_buildbuddy.py new file mode 100755 index 0000000000..f9f329d686 --- /dev/null +++ b/.github/scripts/run_bazel_with_buildbuddy.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 + +import json +import os +import subprocess +import sys +from collections.abc import Mapping +from collections.abc import Sequence +from pathlib import Path + + +OPENAI_REPOSITORY = "openai/codex" +# Remote configurations select cache/BES/download endpoints. Their -rbe forms +# also select the matching remote executor endpoint. +GENERIC_REMOTE_CONFIG = "buildbuddy-generic" +OPENAI_REMOTE_CONFIG = "buildbuddy-openai" +# These CI configurations require remote build execution. The wrapper supplies +# an RBE configuration, which also includes the common `remote` settings. +REMOTE_EXECUTION_CONFIGS = { + "--config=ci-linux", + "--config=ci-macos", + "--config=ci-v8", + "--config=ci-windows-cross", +} +# Only authenticated workflow runs executing trusted upstream code may use the +# OpenAI BuildBuddy host. A pull request event without proof that its head is +# in the upstream repository fails closed to the generic host. +def is_trusted_upstream_run(env: Mapping[str, str]) -> bool: + # `GITHUB_REPOSITORY` is easy to set locally. Requiring GitHub's workflow + # marker prevents a local command from opting itself into the OpenAI host. + if ( + env.get("GITHUB_ACTIONS") != "true" + or env.get("GITHUB_REPOSITORY") != OPENAI_REPOSITORY + ): + return False + # Non-PR workflow runs in `openai/codex` execute upstream refs, so they are + # trusted. Fork code reaches these workflows only through pull requests. + if env.get("GITHUB_EVENT_NAME") != "pull_request": + return True + + event_path = env.get("GITHUB_EVENT_PATH") + if not event_path: + return False + try: + event = json.loads(Path(event_path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False + + try: + return event["pull_request"]["head"]["repo"]["fork"] is False + except (KeyError, TypeError): + return False + + +def uses_openai_host(env: Mapping[str, str]) -> bool: + return bool(env.get("BUILDBUDDY_API_KEY")) and is_trusted_upstream_run(env) + + +def uses_remote_execution(args: Sequence[str]) -> bool: + try: + separator_idx = args.index("--") + except ValueError: + separator_idx = len(args) + return any(arg in REMOTE_EXECUTION_CONFIGS for arg in args[:separator_idx]) + + +def remote_config(args: Sequence[str], env: Mapping[str, str]) -> str | None: + if not env.get("BUILDBUDDY_API_KEY"): + return None + + config = OPENAI_REMOTE_CONFIG if uses_openai_host(env) else GENERIC_REMOTE_CONFIG + if uses_remote_execution(args): + config += "-rbe" + return config + + +def bazel_args_without_remote_execution(args: Sequence[str]) -> list[str]: + # Remote CI configs require BuildBuddy credentials. Removing them preserves + # the local fallback used for fork pull requests. + try: + separator_idx = args.index("--") + except ValueError: + separator_idx = len(args) + return [ + *(arg for arg in args[:separator_idx] if arg not in REMOTE_EXECUTION_CONFIGS), + *args[separator_idx:], + ] + + +def bazel_args_with_remote_config( + args: Sequence[str], env: Mapping[str, str] +) -> list[str]: + config = remote_config(args, env) + if config is None: + return bazel_args_without_remote_execution(args) + + # `remote_config()` returns a configuration only when this key is present. + api_key = env["BUILDBUDDY_API_KEY"] + remote_args = [ + f"--config={config}", + f"--remote_header=x-buildbuddy-api-key={api_key}", + ] + + # Insert immediately after the Bazel command. This keeps wrapper-added + # options out of positional payloads and lets later CI configs override + # shared RBE defaults such as the Windows cross-compilation exec platforms. + insertion_idx = next( + (idx + 1 for idx, arg in enumerate(args) if not arg.startswith("-")), + len(args), + ) + return [*args[:insertion_idx], *remote_args, *args[insertion_idx:]] + + +def bazel_command(*args: str, env: Mapping[str, str] | None = None) -> list[str]: + env = os.environ if env is None else env + bazel = env.get("CODEX_BAZEL_BIN", "bazel") + return [bazel, *bazel_args_with_remote_config(args, env)] + + +def main() -> None: + config = remote_config(sys.argv[1:], os.environ) + if config is None: + print( + "BuildBuddy key unavailable; using local Bazel configuration.", + file=sys.stderr, + ) + else: + host_description = ( + "OpenAI tenant" if uses_openai_host(os.environ) else "generic" + ) + print( + f"Using {host_description} BuildBuddy configuration: {config}.", + file=sys.stderr, + ) + + command = bazel_command(*sys.argv[1:]) + if os.name == "nt": + # Windows CRT exec can split arguments containing spaces and lose the + # eventual child exit status. Wait for Bazel and propagate its status. + result = subprocess.run(command, check=False) + raise SystemExit(result.returncode) + + os.execvp(command[0], command) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/rusty_v8_bazel.py b/.github/scripts/rusty_v8_bazel.py index 2f46daf45e..329d3f6c54 100644 --- a/.github/scripts/rusty_v8_bazel.py +++ b/.github/scripts/rusty_v8_bazel.py @@ -5,7 +5,6 @@ from __future__ import annotations import argparse import gzip import hashlib -import os import re import shutil import subprocess @@ -13,6 +12,7 @@ import sys import tomllib from pathlib import Path +from run_bazel_with_buildbuddy import bazel_command from rusty_v8_module_bazel import ( RustyV8ChecksumError, check_module_bazel, @@ -29,33 +29,22 @@ SANDBOX_ARTIFACT_PROFILE = "ptrcomp_sandbox_release" ARTIFACT_BAZEL_CONFIGS = ["rusty-v8-upstream-libcxx"] -def bazel_remote_args() -> list[str]: - buildbuddy_api_key = os.environ.get("BUILDBUDDY_API_KEY") - if not buildbuddy_api_key: - return [] - return [f"--remote_header=x-buildbuddy-api-key={buildbuddy_api_key}"] - - def bazel_execroot() -> Path: - result = subprocess.run( - ["bazel", "info", "execution_root"], + output = subprocess.check_output( + bazel_command("info", "execution_root"), cwd=ROOT, - check=True, - capture_output=True, text=True, ) - return Path(result.stdout.strip()) + return Path(output.strip()) def bazel_output_base() -> Path: - result = subprocess.run( - ["bazel", "info", "output_base"], + output = subprocess.check_output( + bazel_command("info", "output_base"), cwd=ROOT, - check=True, - capture_output=True, text=True, ) - return Path(result.stdout.strip()) + return Path(output.strip()) def bazel_output_path(path: str) -> Path: @@ -72,24 +61,22 @@ def bazel_output_files( ) -> list[Path]: expression = "set(" + " ".join(labels) + ")" bazel_configs = bazel_configs or [] - result = subprocess.run( - [ - "bazel", + output = subprocess.check_output( + bazel_command( "cquery", "-c", compilation_mode, f"--platforms=@llvm//platforms:{platform}", *[f"--config={config}" for config in bazel_configs], - *bazel_remote_args(), "--output=files", expression, - ], + ), cwd=ROOT, - check=True, - capture_output=True, text=True, ) - return [bazel_output_path(line.strip()) for line in result.stdout.splitlines() if line.strip()] + return [ + bazel_output_path(line.strip()) for line in output.splitlines() if line.strip() + ] def bazel_build( @@ -102,17 +89,15 @@ def bazel_build( bazel_configs = bazel_configs or [] download_args = ["--remote_download_toplevel"] if download_toplevel else [] subprocess.run( - [ - "bazel", + bazel_command( "build", "-c", compilation_mode, f"--platforms=@llvm//platforms:{platform}", *[f"--config={config}" for config in bazel_configs], - *bazel_remote_args(), *download_args, *labels, - ], + ), cwd=ROOT, check=True, ) @@ -172,7 +157,7 @@ def resolved_v8_crate_version() -> str: matches = sorted( set( re.findall( - r'https://static\.crates\.io/crates/v8/v8-([0-9]+\.[0-9]+\.[0-9]+)\.crate', + r"https://static\.crates\.io/crates/v8/v8-([0-9]+\.[0-9]+\.[0-9]+)\.crate", module_bazel, ) ) @@ -234,13 +219,17 @@ def stage_artifacts( output_dir: Path, sandbox: bool, ) -> None: - missing_paths = [str(path) for path in [lib_path, binding_path] if not path.exists()] + missing_paths = [ + str(path) for path in [lib_path, binding_path] if not path.exists() + ] if missing_paths: raise SystemExit(f"missing release outputs for {target}: {missing_paths}") output_dir.mkdir(parents=True, exist_ok=True) artifact_profile = SANDBOX_ARTIFACT_PROFILE if sandbox else RELEASE_ARTIFACT_PROFILE - staged_library = output_dir / staged_archive_name(target, lib_path, artifact_profile) + staged_library = output_dir / staged_archive_name( + target, lib_path, artifact_profile + ) staged_binding = output_dir / staged_binding_name(target, artifact_profile) with lib_path.open("rb") as src, staged_library.open("wb") as dst: @@ -270,7 +259,9 @@ def stage_artifacts( def upstream_release_pair_paths(source_root: Path, target: str) -> tuple[Path, Path]: - lib_name = "rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a" + lib_name = ( + "rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a" + ) gn_out = source_root / "target" / target / "release" / "gn_out" return gn_out / "obj" / lib_name, gn_out / "src_binding.rs" @@ -338,7 +329,9 @@ def parse_args() -> argparse.Namespace: stage_upstream_release_pair_parser = subparsers.add_parser( "stage-upstream-release-pair" ) - stage_upstream_release_pair_parser.add_argument("--source-root", type=Path, required=True) + stage_upstream_release_pair_parser.add_argument( + "--source-root", type=Path, required=True + ) stage_upstream_release_pair_parser.add_argument("--target", required=True) stage_upstream_release_pair_parser.add_argument("--output-dir", required=True) stage_upstream_release_pair_parser.add_argument("--sandbox", action="store_true") diff --git a/.github/scripts/test_run_bazel_with_buildbuddy.py b/.github/scripts/test_run_bazel_with_buildbuddy.py new file mode 100644 index 0000000000..0f594b7947 --- /dev/null +++ b/.github/scripts/test_run_bazel_with_buildbuddy.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 + +import json +import os +import subprocess +import sys +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +import run_bazel_with_buildbuddy + + +class RunBazelWithBuildBuddyTest(unittest.TestCase): + def github_env( + self, + temp_dir: str, + *, + repository: str = "openai/codex", + fork: bool = False, + event_name: str = "pull_request", + ) -> dict[str, str]: + event_path = Path(temp_dir) / "event.json" + event_path.write_text( + json.dumps({"pull_request": {"head": {"repo": {"fork": fork}}}}), + encoding="utf-8", + ) + return { + "BUILDBUDDY_API_KEY": "token", + "GITHUB_ACTIONS": "true", + "GITHUB_EVENT_NAME": event_name, + "GITHUB_EVENT_PATH": str(event_path), + "GITHUB_REPOSITORY": repository, + } + + def test_keyless_invocation_drops_remote_ci_configuration(self) -> None: + self.assertIsNone( + run_bazel_with_buildbuddy.remote_config( + ["build", "--config=ci-linux", "//codex-rs/cli:codex"], + {}, + ) + ) + self.assertEqual( + run_bazel_with_buildbuddy.bazel_args_with_remote_config( + ["build", "--config=ci-linux", "--", "//codex-rs/cli:codex"], + {}, + ), + ["build", "--", "//codex-rs/cli:codex"], + ) + + def test_program_arguments_after_separator_do_not_select_or_lose_rbe(self) -> None: + args = ["run", "//codex-rs/cli:codex", "--", "--config=remote"] + + self.assertEqual( + run_bazel_with_buildbuddy.bazel_args_with_remote_config(args, {}), + args, + ) + self.assertEqual( + run_bazel_with_buildbuddy.remote_config( + args, {"BUILDBUDDY_API_KEY": "fork-token"} + ), + "buildbuddy-generic", + ) + + def test_upstream_push_selects_openai_rbe_before_target_separator(self) -> None: + with TemporaryDirectory() as temp_dir: + env = self.github_env(temp_dir, event_name="push") + + self.assertEqual( + run_bazel_with_buildbuddy.bazel_args_with_remote_config( + ["build", "--config=ci-linux", "--", "//codex-rs/cli:codex"], + env, + ), + [ + "build", + "--config=buildbuddy-openai-rbe", + "--remote_header=x-buildbuddy-api-key=token", + "--config=ci-linux", + "--", + "//codex-rs/cli:codex", + ], + ) + + def test_windows_cross_ci_configuration_follows_remote_configuration(self) -> None: + env = {"BUILDBUDDY_API_KEY": "fork-token"} + + self.assertEqual( + run_bazel_with_buildbuddy.bazel_args_with_remote_config( + ["build", "--config=ci-windows-cross", "//codex-rs/cli:codex"], + env, + ), + [ + "build", + "--config=buildbuddy-generic-rbe", + "--remote_header=x-buildbuddy-api-key=fork-token", + "--config=ci-windows-cross", + "//codex-rs/cli:codex", + ], + ) + + def test_query_remote_configuration_is_inserted_before_expression(self) -> None: + expression = 'kind("rust_library rule", //codex-rs/...)' + env = {"BUILDBUDDY_API_KEY": "fork-token"} + + for command in ("query", "cquery", "aquery"): + with self.subTest(command=command): + self.assertEqual( + run_bazel_with_buildbuddy.bazel_args_with_remote_config( + [ + command, + "--config=ci-windows-cross", + "--output=label", + expression, + ], + env, + ), + [ + command, + "--config=buildbuddy-generic-rbe", + "--remote_header=x-buildbuddy-api-key=fork-token", + "--config=ci-windows-cross", + "--output=label", + expression, + ], + ) + + def test_same_repository_pull_request_selects_openai_host(self) -> None: + with TemporaryDirectory() as temp_dir: + self.assertEqual( + run_bazel_with_buildbuddy.remote_config( + ["build", "--config=ci-v8"], self.github_env(temp_dir) + ), + "buildbuddy-openai-rbe", + ) + + def test_fork_pull_request_cannot_select_openai_host(self) -> None: + with TemporaryDirectory() as temp_dir: + env = self.github_env(temp_dir, fork=True) + + self.assertEqual( + run_bazel_with_buildbuddy.remote_config( + ["build", "--config=ci-v8"], env + ), + "buildbuddy-generic-rbe", + ) + + def test_run_in_fork_repository_cannot_select_openai_host(self) -> None: + with TemporaryDirectory() as temp_dir: + env = self.github_env(temp_dir, repository="contributor/codex") + + self.assertEqual( + run_bazel_with_buildbuddy.remote_config( + ["build", "--config=ci-v8"], env + ), + "buildbuddy-generic-rbe", + ) + + def test_pull_request_without_readable_event_payload_fails_closed(self) -> None: + for event_path in (None, "missing-event.json"): + env = { + "BUILDBUDDY_API_KEY": "token", + "GITHUB_ACTIONS": "true", + "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_REPOSITORY": "openai/codex", + } + if event_path is not None: + env["GITHUB_EVENT_PATH"] = event_path + + with self.subTest(event_path=event_path): + self.assertEqual( + run_bazel_with_buildbuddy.remote_config(["build"], env), + "buildbuddy-generic", + ) + + def test_bazel_command_uses_configured_binary_locally(self) -> None: + self.assertEqual( + run_bazel_with_buildbuddy.bazel_command( + "info", + "execution_root", + env={"CODEX_BAZEL_BIN": "fake-bazel"}, + ), + ["fake-bazel", "info", "execution_root"], + ) + + def test_main_preserves_spaced_argument_and_child_exit_status(self) -> None: + spaced_arg = ( + r"--test_env=PATH=C:\Program Files\PowerShell\7;C:\Program Files\Git\bin" + ) + child_code = ( + f"import sys; sys.exit(37 if sys.argv[1] == {spaced_arg!r} else 91)" + ) + env = os.environ.copy() + env["CODEX_BAZEL_BIN"] = sys.executable + env.pop("BUILDBUDDY_API_KEY", None) + + result = subprocess.run( + [ + sys.executable, + str(Path(run_bazel_with_buildbuddy.__file__)), + "-c", + child_code, + spaced_arg, + ], + env=env, + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(result.returncode, 37, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test_rusty_v8_bazel.py b/.github/scripts/test_rusty_v8_bazel.py index 19690dbece..0b5c03f436 100644 --- a/.github/scripts/test_rusty_v8_bazel.py +++ b/.github/scripts/test_rusty_v8_bazel.py @@ -88,24 +88,49 @@ class RustyV8BazelTest(unittest.TestCase): ), ) - def test_bazel_remote_args_include_buildbuddy_header_when_present(self) -> None: - with patch.dict(environ, {"BUILDBUDDY_API_KEY": "token"}, clear=False): + def test_bazel_commands_use_shared_buildbuddy_remote_config_library(self) -> None: + with patch.dict(environ, {}, clear=True): self.assertEqual( - ["--remote_header=x-buildbuddy-api-key=token"], - rusty_v8_bazel.bazel_remote_args(), + [ + "bazel", + "build", + "//third_party/v8:release", + ], + rusty_v8_bazel.bazel_command( + "build", + "--config=ci-v8", + "//third_party/v8:release", + ), + ) + with patch.dict(environ, {"BUILDBUDDY_API_KEY": "token"}, clear=True): + self.assertEqual( + [ + "bazel", + "build", + "--config=buildbuddy-generic-rbe", + "--remote_header=x-buildbuddy-api-key=token", + "--config=ci-v8", + "//third_party/v8:release", + ], + rusty_v8_bazel.bazel_command( + "build", + "--config=ci-v8", + "//third_party/v8:release", + ), ) - with patch.dict(environ, {}, clear=True): - self.assertEqual([], rusty_v8_bazel.bazel_remote_args()) - - def test_release_pair_labels_and_staged_names_distinguish_sandbox_artifacts(self) -> None: + def test_release_pair_labels_and_staged_names_distinguish_sandbox_artifacts( + self, + ) -> None: self.assertEqual( "//third_party/v8:rusty_v8_release_pair_x86_64_unknown_linux_musl", rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl"), ) self.assertEqual( "//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_unknown_linux_musl", - rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl", sandbox=True), + rusty_v8_bazel.release_pair_label( + "x86_64-unknown-linux-musl", sandbox=True + ), ) self.assertEqual( "//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_apple_darwin", @@ -205,11 +230,7 @@ class RustyV8BazelTest(unittest.TestCase): with TemporaryDirectory() as source_dir, TemporaryDirectory() as output_dir: source_root = Path(source_dir) gn_out = ( - source_root - / "target" - / "x86_64-pc-windows-msvc" - / "release" - / "gn_out" + source_root / "target" / "x86_64-pc-windows-msvc" / "release" / "gn_out" ) (gn_out / "obj").mkdir(parents=True) (gn_out / "obj" / "rusty_v8.lib").write_bytes(b"archive") diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 153ace0fc9..c6dfd60231 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -15,6 +15,7 @@ concurrency: # See https://docs.github.com/en/actions/using-jobs/using-concurrency and https://docs.github.com/en/actions/learn-github-actions/contexts for more info. group: concurrency-group::${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}${{ github.ref_name == 'main' && format('::{0}', github.run_id) || ''}} cancel-in-progress: ${{ github.ref_name != 'main' }} + jobs: test: # PRs use the sharded Windows cross-compiled test jobs below. Post-merge @@ -55,12 +56,17 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false + - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 + if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu' + with: + tool: just + - name: Check rusty_v8 MODULE.bazel checksums if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu' shell: bash run: | python3 .github/scripts/rusty_v8_bazel.py check-module-bazel - python3 -m unittest discover -s .github/scripts -p test_rusty_v8_bazel.py + just test-github-scripts - name: Prepare Bazel CI id: prepare_bazel @@ -152,6 +158,11 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false + - name: Test BuildBuddy Bazel wrapper + if: matrix.shard == 1 + shell: pwsh + run: python .github/scripts/test_run_bazel_with_buildbuddy.py + - name: Prepare Bazel CI id: prepare_bazel uses: ./.github/actions/prepare-bazel-ci diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index 4b5320dcb9..d6fb73e96d 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -191,11 +191,10 @@ jobs: bazel_args+=(--config=v8-release-compat) fi - bazel \ + ./.github/scripts/run_bazel_with_buildbuddy.py \ --noexperimental_remote_repo_contents_cache \ "${bazel_args[@]}" \ - "--config=${{ matrix.bazel_config }}" \ - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" + "--config=${{ matrix.bazel_config }}" - name: Stage release pair env: diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 71ce5d7865..0ad9f850d1 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -5,6 +5,7 @@ on: paths: - ".bazelrc" - ".github/actions/setup-bazel-ci/**" + - ".github/scripts/run_bazel_with_buildbuddy.py" - ".github/scripts/rusty_v8_bazel.py" - ".github/scripts/rusty_v8_module_bazel.py" - ".github/workflows/rusty-v8-release.yml" @@ -23,6 +24,7 @@ on: paths: - ".bazelrc" - ".github/actions/setup-bazel-ci/**" + - ".github/scripts/run_bazel_with_buildbuddy.py" - ".github/scripts/rusty_v8_bazel.py" - ".github/scripts/rusty_v8_module_bazel.py" - ".github/workflows/rusty-v8-release.yml" @@ -203,11 +205,10 @@ jobs: bazel_args+=(--config=v8-release-compat) fi - bazel \ + ./.github/scripts/run_bazel_with_buildbuddy.py \ --noexperimental_remote_repo_contents_cache \ "${bazel_args[@]}" \ - "--config=${{ matrix.bazel_config }}" \ - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" + "--config=${{ matrix.bazel_config }}" - name: Stage release pair env: diff --git a/codex-rs/docs/bazel.md b/codex-rs/docs/bazel.md index a124688a24..085c15992f 100644 --- a/codex-rs/docs/bazel.md +++ b/codex-rs/docs/bazel.md @@ -4,7 +4,7 @@ This repository uses Bazel to build the Rust workspace under `codex-rs`. Cargo remains the source of truth for crates and features, while Bazel provides hermetic builds, toolchains, and cross-platform artifacts. -As of 1/9/2026, this setup is still experimental as we stabilize it. +As of 6/1/2026, this setup is still experimental as we stabilize it. ## High-level layout @@ -20,6 +20,118 @@ As of 1/9/2026, this setup is still experimental as we stabilize it. makes some adjustments if the crate needs additional compile-time or runtime data, or other customizations. +## Running Bazel locally + +The repository root `justfile` exposes the common Bazel entry points: + +```bash +just bazel-test +just bazel-clippy +``` + +Ordinary local `bazel` and `just` invocations run locally. BuildBuddy cache, +build event upload, downloads, and remote execution are opt-in configurations. + +## BuildBuddy + +Codex uses BuildBuddy for a shared Bazel cache and remoted builds and tests. To use it +to speed up your builds and tests you'll need to provide an API key and select a +configuration. + +### BuildBuddy API key + +If you're an OpenAI employee, log in to https://openai.buildbuddy.io and use Google sign-in. + +Create a BuildBuddy API key as described in BuildBuddy's [Authentication Guide][bb-auth-guide], +then add it to `~/.bazelrc`: + +```bazelrc +# Local machine only; this file contains a BuildBuddy credential. +common --remote_header=x-buildbuddy-api-key= +``` + +Keeping the credential outside the workspace reduces the risk of accidentally +committing it. + +If you need different API keys for different projects, put the API key in +`%workspace%/user.bazelrc` instead. The checked-in `.bazelrc` optionally imports +that file, and `.gitignore` excludes it. Do not commit or share a file containing +the credential. + +[bb-auth-guide]: https://www.buildbuddy.io/docs/guide-auth/#managing-keys + +### Selecting a remote build configuration + +OpenAI employees should default to the OpenAI host with remote execution unless +they have a reason to choose another configuration. Add the following configuration +to `%workspace%/user.bazelrc`: + +```bazelrc +common --config=buildbuddy-openai-rbe +``` + +OpenAI employees who don't want remote execution can use `buildbuddy-openai`. External users +should use `buildbuddy-generic-rbe` or `buildbuddy-generic`. See below for details on these +configurations. + +### All remote configurations + +GitHub Actions routes Bazel build and output-resolution commands through +`.github/scripts/run_bazel_with_buildbuddy.py`. Higher-level helpers such as +`.github/scripts/run-bazel-ci.sh` and `.github/scripts/rusty_v8_bazel.py` +delegate remote configuration selection to that wrapper. The wrapper reads the +GitHub Actions repository and event payload rather than relying on workflow +files to duplicate tenant-selection logic. + +Loading-phase target-discovery `bazel query` commands run locally because they +only enumerate labels and do not need remote caches or execution. + +The `Cache/BES` host is also used for remote downloads. + +| Invocation/config | Key Required | Cache/BES | Build exec | Test exec | +| --- | --- | --- | --- | --- | +| `bazel ...` | No | None | Local | Local | +| `bazel ... --config=buildbuddy-generic` | Yes | `remote.buildbuddy.io` | Local | Local | +| `bazel ... --config=buildbuddy-generic-rbe` | Yes | `remote.buildbuddy.io` | Remote | Remote | +| `bazel ... --config=buildbuddy-openai` | Yes | `openai.buildbuddy.io` | Local | Local | +| `bazel ... --config=buildbuddy-openai-rbe` | Yes | `openai.buildbuddy.io` | Remote | Remote | + +Without an API key, the wrapper removes remote CI configurations and runs +locally. With a key, workflows choose the host as follows: + +| Run | Key | Uses OpenAI BuildBuddy Host | +| --- | --- | --- | +| Push to `main` in `openai/codex` | Yes | Yes | +| `workflow_dispatch` in `openai/codex` | Yes | Yes | +| Same-repository pull request in `openai/codex` | Yes | Yes | +| Fork pull request into `openai/codex` | No | No; local | +| Push or `workflow_dispatch` in a fork with a key | Yes | No; generic host | +| Pull request run in a fork repository with a key | Yes | No; generic host | + +CI configurations determine whether builds and tests execute remotely: + +| CI config | Remote config | Build exec | Test exec | +| --- | --- | --- | --- | +| `ci-linux` | `*-rbe` | Remote host | Remote host | +| `ci-v8` | `*-rbe` | Remote host | Remote host | +| `ci-macos` | `*-rbe` | Remote host | Local | +| `ci-windows-cross` | `*-rbe` | Remote host | Local | +| `ci-windows` | non-RBE | Local | Local | +| Keyless CI fallback | none | Local | Local | + +To exercise the generic remote configuration with your key: + +```bash +BUILDBUDDY_API_KEY=... GITHUB_REPOSITORY=my-fork/codex \ + ./.github/scripts/run_bazel_with_buildbuddy.py \ + build --config=ci-linux //codex-rs/cli:codex +``` + +The wrapper selects the OpenAI host only inside GitHub Actions for a trusted +run in `openai/codex`. A missing or malformed pull request event +payload fails closed to the generic host. For local OpenAI host access, use +the `user.bazelrc` configuration above. + ## Evolving the setup When you add or change Rust dependencies, update the Cargo.toml/Cargo.lock as normal. diff --git a/justfile b/justfile index 34b5115ee0..fe7e7349b3 100644 --- a/justfile +++ b/justfile @@ -83,6 +83,12 @@ test *args: $env:RUST_MIN_STACK = "{{ rust_min_stack }}"; cargo nextest run --no-fail-fast @($args | Select-Object -Skip 1) just bench-smoke +# Run from the repository root so scripts that resolve paths from `cwd` see +# the same layout they use in GitHub Actions. +[no-cd] +test-github-scripts: + {{ python }} -m unittest discover -s {{ justfile_directory() }}/.github/scripts -p 'test_*.py' + # Run explicit workspace benchmark targets. bench *args: cargo bench --workspace --bench '*' {args} @@ -129,11 +135,8 @@ bazel-clippy: bazel-argument-comment-lint: bazel build --config=argument-comment-lint -- $({{ justfile_directory() }}/tools/argument-comment-lint/list-bazel-targets.sh) -bazel-remote-test: - bazel test --test_tag_filters=-argument-comment-lint //... --config=remote --platforms=//:rbe --keep_going - build-for-release: - bazel build //codex-rs/cli:release_binaries --config=remote + bazel build //codex-rs/cli:release_binaries # Run the MCP server mcp-server-run *args: diff --git a/scripts/list-bazel-clippy-targets.sh b/scripts/list-bazel-clippy-targets.sh index b76fc2a830..d12a256d00 100755 --- a/scripts/list-bazel-clippy-targets.sh +++ b/scripts/list-bazel-clippy-targets.sh @@ -20,23 +20,13 @@ while [[ $# -gt 0 ]]; do done # Resolve the dynamic targets before printing anything so callers do not -# continue with a partial list if `bazel query` fails. Reuse the same CI Bazel -# server settings as the subsequent build so Windows jobs do not cold-start a -# second Bazel server just for target discovery. -if [[ $windows_cross_compile -eq 1 ]]; then - manual_rust_test_targets="$( - ./.github/scripts/run-bazel-query-ci.sh \ - --windows-cross-compile \ - --output=label \ - -- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))' - )" -else - manual_rust_test_targets="$( - ./.github/scripts/run-bazel-query-ci.sh \ - --output=label \ - -- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))' - )" -fi +# continue with a partial list if `bazel query` fails. Target discovery is +# local on all platforms. +manual_rust_test_targets="$( + ./.github/scripts/run-bazel-query-ci.sh \ + --output=label \ + -- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))' +)" if [[ "${RUNNER_OS:-}" != "Windows" ]]; then # Non-Windows clippy jobs lint the native test binaries; the # Windows-cross binaries exist only for the fast Windows test leg. From a28b32a8357b4bd67aa3ade5bb9a4e2ddb5cae1a Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 2 Jun 2026 16:26:36 -0700 Subject: [PATCH 48/59] config: express implicit sandbox defaults as permission profiles (#25926) ## Why `PermissionProfile` is becoming the default way to represent Codex permissions, but the implicit default behavior should stay the same for now: - trusted projects use `:workspace` - untrusted projects also use `:workspace` - roots without a trust decision use `:read-only` - unsandboxed Windows falls back to `:read-only` This keeps the existing sandbox semantics while making silent config defaults observable as built-in permission profiles instead of treating the legacy `SandboxPolicy` projection as the primary shape. ## What Changed - Refactored legacy sandbox derivation to resolve the configured sandbox mode once, then apply the implicit project fallback only when no sandbox mode was configured. - Preserved the existing trust-decision fallback: trusted and untrusted projects default to workspace-write where supported. - Added empty-config coverage asserting that an untrusted project resolves to the built-in active permission profile (`:workspace` outside unsandboxed Windows). ## Verification - `just fmt` - `just test -p codex-core 'config::'` - `just test -p codex-config` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/25926). * __->__ #25926 --- codex-rs/config/src/config_toml.rs | 26 ++++------- codex-rs/core/src/config/config_tests.rs | 59 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 16 deletions(-) diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 0311482cf2..7c060cadb7 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -728,29 +728,23 @@ impl ConfigToml { active_project: Option<&ProjectConfig>, permission_profile_constraint: Option<&crate::Constrained>, ) -> PermissionProfile { - let sandbox_mode_was_explicit = - sandbox_mode_override.is_some() || self.sandbox_mode.is_some(); - let resolved_sandbox_mode = sandbox_mode_override - .or(self.sandbox_mode) - .or(if sandbox_mode_was_explicit { - None - } else { + let configured_sandbox_mode = sandbox_mode_override.or(self.sandbox_mode); + let resolved_sandbox_mode = configured_sandbox_mode + .or_else(|| { // If no sandbox_mode is set but this directory has a trust decision, // default to workspace-write except on unsandboxed Windows where we // default to read-only. - active_project.and_then(|p| { - if p.is_trusted() || p.is_untrusted() { + active_project + .filter(|project| project.is_trusted() || project.is_untrusted()) + .map(|_| { if cfg!(target_os = "windows") && windows_sandbox_level == WindowsSandboxLevel::Disabled { - Some(SandboxMode::ReadOnly) + SandboxMode::ReadOnly } else { - Some(SandboxMode::WorkspaceWrite) + SandboxMode::WorkspaceWrite } - } else { - None - } - }) + }) }) .unwrap_or_default(); let effective_sandbox_mode = if cfg!(target_os = "windows") @@ -788,7 +782,7 @@ impl ConfigToml { }, SandboxMode::DangerFullAccess => PermissionProfile::Disabled, }; - if !sandbox_mode_was_explicit + if configured_sandbox_mode.is_none() && let Some(constraint) = permission_profile_constraint && let Err(err) = constraint.can_set(&permission_profile) { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 192e449ff8..8bc33bbb45 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -2548,6 +2548,65 @@ async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std:: Ok(()) } +#[tokio::test] +async fn empty_config_defaults_to_builtin_profile_for_untrusted_project() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let project_key = cwd.path().to_string_lossy().to_string(); + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + projects: Some(HashMap::from([( + project_key, + ProjectConfig { + trust_level: Some(TrustLevel::Untrusted), + }, + )])), + ..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_eq!( + config + .permissions + .active_permission_profile() + .as_ref() + .map(|active| active.id.as_str()), + Some(if cfg!(target_os = "windows") { + BUILT_IN_PERMISSION_PROFILE_READ_ONLY + } else { + BUILT_IN_PERMISSION_PROFILE_WORKSPACE + }) + ); + assert!( + policy.can_read_path_with_cwd(cwd.path(), cwd.path()), + "expected untrusted project fallback to allow reads, policy: {policy:?}" + ); + if cfg!(target_os = "windows") { + assert!( + !policy.can_write_path_with_cwd(cwd.path(), cwd.path()), + "expected untrusted project fallback to stay read-only without Windows sandbox support, policy: {policy:?}" + ); + } else { + assert!( + policy.can_write_path_with_cwd(cwd.path(), cwd.path()), + "expected untrusted project fallback to use :workspace, policy: {policy:?}" + ); + assert!( + !policy.can_write_path_with_cwd(&cwd.path().join(".codex"), cwd.path()), + "expected :workspace metadata carveouts, policy: {policy:?}" + ); + } + Ok(()) +} + #[tokio::test] async fn implicit_builtin_workspace_profile_preserves_sandbox_workspace_write_settings() -> std::io::Result<()> { From 271d5cecf2e71f9ea171653306cc9ce7c6ec3f19 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 3 Jun 2026 01:33:31 +0200 Subject: [PATCH 49/59] feat: add extension turn-input contributors (#25959) ## Disclaimer Do not use for now ## Why Extensions can already contribute prompt fragments and request same-turn item injection, but there was no host-owned hook for contributing structured `ResponseItem`s while Codex is assembling a new turn's initial model input. This change adds that seam so extensions can attach turn-local input that depends on the submitted user input and resolved turn environments without routing through prompt text or late injection. ## What changed - add `TurnInputContributor` to `codex_extension_api` and export the new `TurnInputContext` / `TurnInputEnvironment` types it receives - teach `ExtensionRegistry` to register and expose turn-input contributors alongside the existing extension hooks - call registered turn-input contributors from `core/src/session/turn.rs` while building the initial injected input for a turn, then append their returned `ResponseItem`s after the skill and plugin injections --- codex-rs/core/src/session/turn.rs | 53 +++++++++++++++++++ .../ext/extension-api/src/contributors.rs | 23 ++++++++ .../src/contributors/turn_input.rs | 25 +++++++++ codex-rs/ext/extension-api/src/lib.rs | 4 ++ codex-rs/ext/extension-api/src/registry.rs | 15 ++++++ 5 files changed, 120 insertions(+) create mode 100644 codex-rs/ext/extension-api/src/contributors/turn_input.rs diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 498e65c7b9..c6a3155b74 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -69,6 +69,8 @@ use codex_analytics::InvocationType; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_async_utils::OrCancelExt; +use codex_extension_api::TurnInputContext; +use codex_extension_api::TurnInputEnvironment; use codex_features::Feature; use codex_git_utils::get_git_repo_root; use codex_git_utils::get_git_repo_root_with_fs; @@ -513,6 +515,9 @@ async fn build_skills_and_plugins( }; let skills_outcome = turn_context.turn_skills.outcome.as_ref(); let connector_slug_counts = build_connector_slug_counts(&available_connectors); + let extension_injection_items = + build_extension_turn_input_items(sess, turn_context, &user_input, cancellation_token) + .await?; let skill_name_counts_lower = build_skill_name_counts(&skills_outcome.skills, &skills_outcome.disabled_paths).1; let mentioned_skills = collect_explicit_skill_mentions( @@ -588,9 +593,57 @@ async fn build_skills_and_plugins( let mut injection_items = skill_items; injection_items.extend(plugin_items); + injection_items.extend(extension_injection_items); Some((injection_items, explicitly_enabled_connectors)) } +async fn build_extension_turn_input_items( + sess: &Arc, + turn_context: &TurnContext, + user_input: &[UserInput], + cancellation_token: &CancellationToken, +) -> Option> { + let contributors = sess.services.extensions.turn_input_contributors().to_vec(); + if contributors.is_empty() { + return Some(Vec::new()); + } + + let environments = turn_context + .environments + .turn_environments + .iter() + .enumerate() + .map(|(index, environment)| TurnInputEnvironment { + environment_id: environment.environment_id.clone(), + cwd: environment.cwd.as_path().to_path_buf(), + is_primary: index == 0, + }) + .collect::>(); + + let input = TurnInputContext { + turn_id: turn_context.sub_id.to_string(), + user_input: user_input.to_vec(), + environments, + }; + + let mut items = Vec::new(); + for contributor in contributors { + let contributed_items = contributor + .contribute( + input.clone(), + &sess.services.session_extension_data, + &sess.services.thread_extension_data, + turn_context.extension_data.as_ref(), + ) + .or_cancel(cancellation_token) + .await + .ok()?; + items.extend(contributed_items); + } + + Some(items) +} + async fn track_turn_resolved_config_analytics( sess: &Session, turn_context: &TurnContext, diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index 71007e3817..98bc395a2f 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -2,6 +2,7 @@ use std::future::Future; use std::sync::Arc; use codex_protocol::items::TurnItem; +use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::TokenUsageInfo; use codex_tools::ToolCall; @@ -12,6 +13,7 @@ use crate::ExtensionData; mod prompt; mod thread_lifecycle; mod tool_lifecycle; +mod turn_input; mod turn_lifecycle; pub use prompt::PromptFragment; @@ -25,6 +27,8 @@ pub use tool_lifecycle::ToolCallSource; pub use tool_lifecycle::ToolFinishInput; pub use tool_lifecycle::ToolLifecycleFuture; pub use tool_lifecycle::ToolStartInput; +pub use turn_input::TurnInputContext; +pub use turn_input::TurnInputEnvironment; pub use turn_lifecycle::TurnAbortInput; pub use turn_lifecycle::TurnErrorInput; pub use turn_lifecycle::TurnStartInput; @@ -85,6 +89,25 @@ pub trait TurnLifecycleContributor: Send + Sync { async fn on_turn_error(&self, _input: TurnErrorInput<'_>) {} } +/// WARNING: DO NOT USE YET +/// Extension contribution that can add turn-local model input. +/// +/// Implementations should resolve only the model-visible input they own and +/// must preserve authority boundaries for external resources. Expensive or +/// host-specific dependencies belong on the extension value installed by the +/// host, not in this input. +#[async_trait::async_trait] +pub trait TurnInputContributor: Send + Sync { + /// Returns additional model input items for one submitted turn. + async fn contribute( + &self, + input: TurnInputContext, + session_store: &ExtensionData, + thread_store: &ExtensionData, + turn_store: &ExtensionData, + ) -> Vec; +} + /// Contributor for host-owned configuration changes. /// /// Implementations should treat the supplied values as immutable before/after diff --git a/codex-rs/ext/extension-api/src/contributors/turn_input.rs b/codex-rs/ext/extension-api/src/contributors/turn_input.rs new file mode 100644 index 0000000000..9e4723440d --- /dev/null +++ b/codex-rs/ext/extension-api/src/contributors/turn_input.rs @@ -0,0 +1,25 @@ +use std::path::PathBuf; + +use codex_protocol::user_input::UserInput; + +/// Host-owned turn environment summary visible to turn-input contributors. +#[derive(Debug, Clone)] +pub struct TurnInputEnvironment { + /// Stable host environment id used to route executor-scoped capabilities. + pub environment_id: String, + /// Effective working directory for this turn in the environment. + pub cwd: PathBuf, + /// Whether this is the primary environment for the turn. + pub is_primary: bool, +} + +/// Turn facts supplied before the host records turn-local model input items. +#[derive(Debug, Clone)] +pub struct TurnInputContext { + /// Stable host-owned turn identifier. + pub turn_id: String, + /// User input submitted for this turn. + pub user_input: Vec, + /// Resolved turn environments, in host priority order. + pub environments: Vec, +} diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index 4705581c8a..870e331fbb 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -10,6 +10,7 @@ pub use capabilities::NoopExtensionEventSink; pub use capabilities::NoopResponseItemInjector; pub use capabilities::ResponseItemInjectionFuture; pub use capabilities::ResponseItemInjector; +pub use codex_protocol::models::ResponseItem; pub use codex_tools::ConversationHistory; pub use codex_tools::ExtensionTurnItem; pub use codex_tools::FunctionCallError; @@ -46,6 +47,9 @@ pub use contributors::ToolLifecycleFuture; pub use contributors::ToolStartInput; pub use contributors::TurnAbortInput; pub use contributors::TurnErrorInput; +pub use contributors::TurnInputContext; +pub use contributors::TurnInputContributor; +pub use contributors::TurnInputEnvironment; pub use contributors::TurnItemContributor; pub use contributors::TurnLifecycleContributor; pub use contributors::TurnStartInput; diff --git a/codex-rs/ext/extension-api/src/registry.rs b/codex-rs/ext/extension-api/src/registry.rs index a359726257..0849387110 100644 --- a/codex-rs/ext/extension-api/src/registry.rs +++ b/codex-rs/ext/extension-api/src/registry.rs @@ -12,6 +12,7 @@ use crate::ThreadLifecycleContributor; use crate::TokenUsageContributor; use crate::ToolContributor; use crate::ToolLifecycleContributor; +use crate::TurnInputContributor; use crate::TurnItemContributor; use crate::TurnLifecycleContributor; @@ -23,6 +24,7 @@ pub struct ExtensionRegistryBuilder { config_contributors: Vec>>, token_usage_contributors: Vec>, context_contributors: Vec>, + turn_input_contributors: Vec>, tool_contributors: Vec>, tool_lifecycle_contributors: Vec>, turn_item_contributors: Vec>, @@ -39,6 +41,7 @@ impl Default for ExtensionRegistryBuilder { token_usage_contributors: Vec::new(), approval_review_contributors: Vec::new(), context_contributors: Vec::new(), + turn_input_contributors: Vec::new(), tool_contributors: Vec::new(), tool_lifecycle_contributors: Vec::new(), turn_item_contributors: Vec::new(), @@ -98,6 +101,11 @@ impl ExtensionRegistryBuilder { self.context_contributors.push(contributor); } + /// Registers one turn-input contributor. + pub fn turn_input_contributor(&mut self, contributor: Arc) { + self.turn_input_contributors.push(contributor); + } + /// Registers one native tool contributor. pub fn tool_contributor(&mut self, contributor: Arc) { self.tool_contributors.push(contributor); @@ -123,6 +131,7 @@ impl ExtensionRegistryBuilder { token_usage_contributors: self.token_usage_contributors, approval_review_contributors: self.approval_review_contributors, context_contributors: self.context_contributors, + turn_input_contributors: self.turn_input_contributors, tool_contributors: self.tool_contributors, tool_lifecycle_contributors: self.tool_lifecycle_contributors, turn_item_contributors: self.turn_item_contributors, @@ -138,6 +147,7 @@ pub struct ExtensionRegistry { config_contributors: Vec>>, token_usage_contributors: Vec>, context_contributors: Vec>, + turn_input_contributors: Vec>, tool_contributors: Vec>, tool_lifecycle_contributors: Vec>, turn_item_contributors: Vec>, @@ -195,6 +205,11 @@ impl ExtensionRegistry { &self.context_contributors } + /// Returns the registered turn-input contributors. + pub fn turn_input_contributors(&self) -> &[Arc] { + &self.turn_input_contributors + } + /// Returns the registered native tool contributors. pub fn tool_contributors(&self) -> &[Arc] { &self.tool_contributors From 1fd2a6d328017c58f4863b76f430d4000cbfd1b5 Mon Sep 17 00:00:00 2001 From: joeflorencio-openai Date: Tue, 2 Jun 2026 16:41:48 -0700 Subject: [PATCH 50/59] Allow EDU accounts to fetch cloud config bundles (#25963) ## Summary Allow EDU ChatGPT workspaces to fetch cloud config bundles. The existing cloud config eligibility gate only allowed business-like and enterprise plans, which meant EDU admins could configure managed policies in the UI but the Codex client would skip fetching them. This keeps individual/pro and team-like usage-based plans excluded, and adds service-level coverage for both `edu` and `education` plan aliases. ## Validation - `just fmt` - `just test -p codex-cloud-config` - Built the Codex app locally, created a new EDU ChatGPT workspace, and verified config bundles can be fetched and are properly applied. --- codex-rs/cloud-config/src/service.rs | 3 +- codex-rs/cloud-config/src/service_tests.rs | 97 +++++++++------------- 2 files changed, 43 insertions(+), 57 deletions(-) diff --git a/codex-rs/cloud-config/src/service.rs b/codex-rs/cloud-config/src/service.rs index b0e8d49e27..eed94c0a10 100644 --- a/codex-rs/cloud-config/src/service.rs +++ b/codex-rs/cloud-config/src/service.rs @@ -49,7 +49,8 @@ fn cloud_config_eligible_auth(auth: &CodexAuth) -> bool { return false; }; auth.uses_codex_backend() - && (plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise)) + && (plan_type.is_business_like() + || matches!(plan_type, PlanType::Enterprise | PlanType::Edu)) } fn optional_bundle(bundle: CloudConfigBundle) -> Option { diff --git a/codex-rs/cloud-config/src/service_tests.rs b/codex-rs/cloud-config/src/service_tests.rs index a07443ba0c..abedca4b07 100644 --- a/codex-rs/cloud-config/src/service_tests.rs +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -351,7 +351,7 @@ async fn get_bundle_skips_non_chatgpt_auth() { } #[tokio::test] -async fn get_bundle_skips_non_business_or_enterprise_plan() { +async fn get_bundle_skips_individual_plan() { let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); let codex_home = tempdir().expect("tempdir"); let service = CloudConfigBundleService::new( @@ -365,6 +365,46 @@ async fn get_bundle_skips_non_business_or_enterprise_plan() { assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); } +#[tokio::test] +async fn get_bundle_allows_eligible_workspace_plans_and_writes_cache() { + for plan_type in [ + "business", + "enterprise_cbp_usage_based", + "enterprise", + "hc", + "edu", + "education", + ] { + let bundle = test_bundle(); + let fetcher = Arc::new(StaticBundleClient::new(bundle.clone())); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan(plan_type).await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!( + service.load_startup_bundle().await, + Ok(Some(bundle)), + "plan_type: {plan_type}" + ); + assert_eq!( + fetcher.request_count.load(Ordering::SeqCst), + 1, + "plan_type: {plan_type}" + ); + assert!( + codex_home + .path() + .join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME) + .exists(), + "plan_type: {plan_type}" + ); + } +} + #[tokio::test] async fn get_bundle_skips_team_like_usage_based_plan() { let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); @@ -380,31 +420,6 @@ async fn get_bundle_skips_team_like_usage_based_plan() { assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); } -#[tokio::test] -async fn get_bundle_allows_business_plan_and_writes_cache() { - let bundle = test_bundle(); - let codex_home = tempdir().expect("tempdir"); - let fetcher = Arc::new(StaticBundleClient::new(bundle.clone())); - let service = CloudConfigBundleService::new( - auth_manager_with_plan("business").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_CONFIG_BUNDLE_TIMEOUT, - ); - - assert_eq!( - service.load_startup_bundle().await, - Ok(Some(bundle.clone())) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - assert!( - codex_home - .path() - .join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME) - .exists() - ); -} - #[tokio::test] async fn get_bundle_rejects_invalid_remote_bundle_before_cache_write() { let codex_home = tempdir().expect("tempdir"); @@ -468,36 +483,6 @@ async fn get_bundle_ignores_invalid_cache_and_refetches() { ); } -#[tokio::test] -async fn get_bundle_allows_business_like_usage_based_plan() { - let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); - let codex_home = tempdir().expect("tempdir"); - let service = CloudConfigBundleService::new( - auth_manager_with_plan("enterprise_cbp_usage_based").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_CONFIG_BUNDLE_TIMEOUT, - ); - - assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle()))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); -} - -#[tokio::test] -async fn get_bundle_allows_hc_plan_as_enterprise() { - let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); - let codex_home = tempdir().expect("tempdir"); - let service = CloudConfigBundleService::new( - auth_manager_with_plan("hc").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_CONFIG_BUNDLE_TIMEOUT, - ); - - assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle()))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); -} - #[tokio::test] async fn get_bundle_empty_response_is_success_and_cached() { let codex_home = tempdir().expect("tempdir"); From 98a62a62ce7e1e6e6100c6e888e9d2bd794378e2 Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Tue, 2 Jun 2026 17:01:02 -0700 Subject: [PATCH 51/59] feat(app-server): add remote control client management RPCs (#25785) ## Why Remote-control clients need to list and revoke controller-device grants without enabling or enrolling the local relay. These are signed-in account-management operations, so coupling them to websocket, pairing, enrollment, or persisted relay state would prevent clients from managing stale grants from the picker. Related enhancement request: N/A. This adds the Codex app-server surface for the planned upstream environment-scoped revoke endpoint. ## What Changed - Added experimental app-server v2 RPCs: - `remoteControl/client/list` - `remoteControl/client/revoke` - Added picker-oriented protocol types and standard generated schema fixtures. The list response intentionally omits backend account id, enrollment status, and location fields. - Added `app-server-transport/src/transport/remote_control/clients.rs` for environment-scoped GET and DELETE requests. It builds escaped URL path segments, forwards optional pagination query fields, sends ChatGPT auth plus `chatgpt-account-id`, converts RFC3339 `last_seen_at` values to Unix seconds, accepts `204 No Content` revoke responses, and retries once after a `401`. - Extracted shared ChatGPT auth loading and recovery into `app-server-transport/src/transport/remote_control/auth.rs` so websocket, pairing, and client management use the same account-auth boundary. - Retained the configured remote-control base URL on `RemoteControlHandle` and resolve management URLs lazily, preserving deferred validation while relay startup is disabled. - Registered list as `global_shared_read("remote-control-clients")` and revoke as `global("remote-control-clients")`. ## Verification - Added transport coverage proving list and revoke work while relay state is disabled, IDs are escaped, picker-only fields are returned, timestamps are converted, revoke accepts `204`, auth headers are forwarded, `401` retries exactly once, `403` is not retried, and malformed list payloads retain decode context. - Added an app-server integration test proving both JSON-RPC methods work before relay enablement and successful revoke returns `{}`. - Regenerated and validated experimental and standard app-server schema fixtures. --- codex-rs/app-server-protocol/src/export.rs | 38 +- .../src/protocol/common.rs | 35 ++ .../src/protocol/v2/remote_control.rs | 60 +++ .../src/protocol/v2/remote_control_tests.rs | 50 +++ .../src/transport/remote_control/auth.rs | 105 +++++ .../src/transport/remote_control/clients.rs | 307 +++++++++++++++ .../src/transport/remote_control/enroll.rs | 7 +- .../src/transport/remote_control/mod.rs | 37 +- .../src/transport/remote_control/protocol.rs | 73 ++-- .../src/transport/remote_control/tests.rs | 2 + .../remote_control/tests/clients_tests.rs | 359 ++++++++++++++++++ .../src/transport/remote_control/websocket.rs | 96 +---- codex-rs/app-server/README.md | 2 + codex-rs/app-server/src/message_processor.rs | 10 + .../remote_control_processor.rs | 34 ++ .../remote_control_processor_tests.rs | 31 ++ .../tests/common/test_app_server.rs | 21 + .../tests/suite/v2/remote_control.rs | 144 +++++++ 18 files changed, 1281 insertions(+), 130 deletions(-) create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/remote_control_tests.rs create mode 100644 codex-rs/app-server-transport/src/transport/remote_control/auth.rs create mode 100644 codex-rs/app-server-transport/src/transport/remote_control/clients.rs create mode 100644 codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index b9c44fe9a6..20f923f327 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -39,6 +39,8 @@ use ts_rs::TS; pub(crate) const GENERATED_TS_HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"]; const JSON_V1_ALLOWLIST: &[&str] = &["InitializeParams", "InitializeResponse"]; +const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] = + &["RemoteControlClient", "RemoteControlClientsListOrder"]; const SPECIAL_DEFINITIONS: &[&str] = &[ "ClientNotification", "ClientRequest", @@ -554,6 +556,7 @@ fn experimental_method_types() -> HashSet { let mut type_names = HashSet::new(); collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_PARAM_TYPES, &mut type_names); collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_RESPONSE_TYPES, &mut type_names); + collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES, &mut type_names); type_names } @@ -2132,6 +2135,14 @@ mod tests { fixture_tree.contains_key(Path::new("v2/MockExperimentalMethodResponse.ts")), false ); + assert_eq!( + fixture_tree.contains_key(Path::new("v2/RemoteControlClient.ts")), + false + ); + assert_eq!( + fixture_tree.contains_key(Path::new("v2/RemoteControlClientsListOrder.ts")), + false + ); let mut undefined_offenders = Vec::new(); let mut optional_nullable_offenders = BTreeSet::new(); @@ -2847,6 +2858,11 @@ permissionProfile?: string | null}; flat_v2_bundle_json.contains("MockExperimentalMethodResponse"), false ); + assert_eq!(flat_v2_bundle_json.contains("RemoteControlClient"), false); + assert_eq!( + flat_v2_bundle_json.contains("RemoteControlClientsListOrder"), + false + ); assert_eq!(flat_v2_bundle_json.contains("#/definitions/v2/"), false); assert_eq!( flat_v2_bundle_json.contains("\"title\": \"CodexAppServerProtocolV2\""), @@ -2920,22 +2936,42 @@ permissionProfile?: string | null}; .exists(), false ); + assert_eq!( + output_dir + .join("v2") + .join("RemoteControlClient.json") + .exists(), + false + ); + assert_eq!( + output_dir + .join("v2") + .join("RemoteControlClientsListOrder.json") + .exists(), + false + ); let _cleanup = fs::remove_dir_all(&output_dir); Ok(()) } #[test] - fn generate_json_includes_remote_control_pairing_start_with_experimental_api() -> Result<()> { + fn generate_json_includes_remote_control_methods_with_experimental_api() -> Result<()> { let output_dir = std::env::temp_dir().join(format!("codex_schema_{}", Uuid::now_v7())); fs::create_dir(&output_dir)?; generate_json_with_experimental(&output_dir, /*experimental_api*/ true)?; let client_request_json = fs::read_to_string(output_dir.join("ClientRequest.json"))?; assert!(client_request_json.contains("remoteControl/pairing/start")); + assert!(client_request_json.contains("remoteControl/client/list")); + assert!(client_request_json.contains("remoteControl/client/revoke")); for schema in [ "RemoteControlPairingStartParams.json", "RemoteControlPairingStartResponse.json", + "RemoteControlClientsListParams.json", + "RemoteControlClientsListResponse.json", + "RemoteControlClientsRevokeParams.json", + "RemoteControlClientsRevokeResponse.json", ] { assert!(output_dir.join("v2").join(schema).exists()); } diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 961a4a227c..5a60d8d002 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -849,6 +849,18 @@ client_request_definitions! { serialization: global("remote-control-pairing"), response: v2::RemoteControlPairingStartResponse, }, + #[experimental("remoteControl/client/list")] + RemoteControlClientsList => "remoteControl/client/list" { + params: v2::RemoteControlClientsListParams, + serialization: global_shared_read("remote-control-clients"), + response: v2::RemoteControlClientsListResponse, + }, + #[experimental("remoteControl/client/revoke")] + RemoteControlClientsRevoke => "remoteControl/client/revoke" { + params: v2::RemoteControlClientsRevokeParams, + serialization: global("remote-control-clients"), + response: v2::RemoteControlClientsRevokeResponse, + }, #[experimental("collaborationMode/list")] /// Lists collaboration mode presets. CollaborationModeList => "collaborationMode/list" { @@ -1994,6 +2006,29 @@ mod tests { "remote-control-pairing" )) ); + let remote_control_clients_list = ClientRequest::RemoteControlClientsList { + request_id: request_id(), + params: v2::RemoteControlClientsListParams::default(), + }; + assert_eq!( + remote_control_clients_list.serialization_scope(), + Some(ClientRequestSerializationScope::GlobalSharedRead( + "remote-control-clients" + )) + ); + let remote_control_clients_revoke = ClientRequest::RemoteControlClientsRevoke { + request_id: request_id(), + params: v2::RemoteControlClientsRevokeParams { + environment_id: "environment-id".to_string(), + client_id: "client-id".to_string(), + }, + }; + assert_eq!( + remote_control_clients_revoke.serialization_scope(), + Some(ClientRequestSerializationScope::Global( + "remote-control-clients" + )) + ); } #[test] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs index e5c39a7773..f5f004f784 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs @@ -62,6 +62,62 @@ pub struct RemoteControlPairingStartResponse { pub expires_at: i64, } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlClientsListParams { + pub environment_id: String, + #[ts(optional = nullable)] + pub cursor: Option, + #[ts(optional = nullable)] + pub limit: Option, + #[ts(optional = nullable)] + pub order: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum RemoteControlClientsListOrder { + Asc, + Desc, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlClientsListResponse { + pub data: Vec, + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlClient { + pub client_id: String, + pub display_name: Option, + pub device_type: Option, + pub platform: Option, + pub os_version: Option, + pub device_model: Option, + pub app_version: Option, + pub last_seen_at: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlClientsRevokeParams { + pub environment_id: String, + pub client_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlClientsRevokeResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase", export_to = "v2/")] @@ -105,3 +161,7 @@ impl From for RemoteControlDisableRespon } } } + +#[cfg(test)] +#[path = "remote_control_tests.rs"] +mod tests; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/remote_control_tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/remote_control_tests.rs new file mode 100644 index 0000000000..ff6b26d236 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/remote_control_tests.rs @@ -0,0 +1,50 @@ +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn remote_control_clients_list_params_serialize_nullable_optional_fields() { + assert_eq!( + serde_json::to_value(RemoteControlClientsListParams { + environment_id: "env-123".to_string(), + cursor: None, + limit: None, + order: None, + }) + .expect("params should serialize"), + json!({ + "environmentId": "env-123", + "cursor": null, + "limit": null, + "order": null, + }) + ); +} + +#[test] +fn remote_control_clients_list_params_deserialize_camel_case_fields() { + assert_eq!( + serde_json::from_value::(json!({ + "environmentId": "env-123", + "cursor": "cursor-123", + "limit": 10, + "order": "asc", + })) + .expect("params should deserialize"), + RemoteControlClientsListParams { + environment_id: "env-123".to_string(), + cursor: Some("cursor-123".to_string()), + limit: Some(10), + order: Some(RemoteControlClientsListOrder::Asc), + } + ); +} + +#[test] +fn remote_control_clients_revoke_response_serializes_as_empty_object() { + assert_eq!( + serde_json::to_value(RemoteControlClientsRevokeResponse {}) + .expect("response should serialize"), + json!({}) + ); +} diff --git a/codex-rs/app-server-transport/src/transport/remote_control/auth.rs b/codex-rs/app-server-transport/src/transport/remote_control/auth.rs new file mode 100644 index 0000000000..dbd231027e --- /dev/null +++ b/codex-rs/app-server-transport/src/transport/remote_control/auth.rs @@ -0,0 +1,105 @@ +use codex_api::SharedAuthProvider; +use codex_login::AuthManager; +use codex_login::UnauthorizedRecovery; +use std::io; +use std::io::ErrorKind; +use std::sync::Arc; +use tokio::sync::watch; +use tracing::info; +use tracing::warn; + +pub(super) struct RemoteControlConnectionAuth { + pub(super) auth_provider: SharedAuthProvider, + pub(super) account_id: String, +} + +pub(super) async fn load_remote_control_auth( + auth_manager: &Arc, +) -> io::Result { + let mut reloaded = false; + let auth = loop { + let Some(auth) = auth_manager.auth().await else { + if reloaded { + return Err(io::Error::new( + ErrorKind::PermissionDenied, + "remote control requires ChatGPT authentication", + )); + } + auth_manager.reload().await; + reloaded = true; + continue; + }; + if !auth.uses_codex_backend() { + break auth; + } + if auth.get_account_id().is_none() && !reloaded { + auth_manager.reload().await; + reloaded = true; + continue; + } + break auth; + }; + + if !auth.uses_codex_backend() { + return Err(io::Error::new( + ErrorKind::PermissionDenied, + "remote control requires ChatGPT authentication; API key auth is not supported", + )); + } + + Ok(RemoteControlConnectionAuth { + auth_provider: codex_model_provider::auth_provider_from_auth(&auth), + account_id: auth.get_account_id().ok_or_else(|| { + io::Error::new( + ErrorKind::WouldBlock, + "remote control enrollment is waiting for a ChatGPT account id", + ) + })?, + }) +} + +pub(super) async fn recover_remote_control_auth( + auth_recovery: &mut UnauthorizedRecovery, + auth_change_rx: &mut watch::Receiver, +) -> bool { + if !auth_recovery.has_next() { + return false; + } + + let mode = auth_recovery.mode_name(); + let step = auth_recovery.step_name(); + let auth_change_revision_before_recovery = *auth_change_rx.borrow(); + match auth_recovery.next().await { + Ok(step_result) => { + if step_result.auth_state_changed() == Some(true) { + mark_recovery_auth_change_seen( + auth_change_rx, + auth_change_revision_before_recovery, + ); + } + info!( + "remote control auth recovery succeeded: mode={mode}, step={step}, auth_state_changed={:?}", + step_result.auth_state_changed() + ); + true + } + Err(err) => { + warn!("remote control auth recovery failed: mode={mode}, step={step}: {err}"); + false + } + } +} + +pub(super) fn mark_recovery_auth_change_seen( + auth_change_rx: &mut watch::Receiver, + auth_change_revision_before_recovery: u64, +) { + let auth_change_revision_after_recovery = *auth_change_rx.borrow(); + if auth_change_revision_after_recovery == auth_change_revision_before_recovery.wrapping_add(1) { + // Recovery updated the same watch that wakes the outer reconnect + // loop. Mark only that single revision seen; if more revisions + // arrived while recovery was in flight, leave them pending so the + // reconnect loop still reacts to the later external auth change. + auth_change_rx.borrow_and_update(); + } +} diff --git a/codex-rs/app-server-transport/src/transport/remote_control/clients.rs b/codex-rs/app-server-transport/src/transport/remote_control/clients.rs new file mode 100644 index 0000000000..25316c6090 --- /dev/null +++ b/codex-rs/app-server-transport/src/transport/remote_control/clients.rs @@ -0,0 +1,307 @@ +use super::auth::RemoteControlConnectionAuth; +use super::auth::load_remote_control_auth; +use super::auth::recover_remote_control_auth; +use super::enroll::REMOTE_CONTROL_ACCOUNT_ID_HEADER; +use super::enroll::format_headers; +use super::enroll::preview_remote_control_response_body; +use super::protocol::normalize_remote_control_base_url; +use axum::http::HeaderMap; +use codex_app_server_protocol::RemoteControlClient; +use codex_app_server_protocol::RemoteControlClientsListOrder; +use codex_app_server_protocol::RemoteControlClientsListParams; +use codex_app_server_protocol::RemoteControlClientsListResponse; +use codex_app_server_protocol::RemoteControlClientsRevokeParams; +use codex_app_server_protocol::RemoteControlClientsRevokeResponse; +use codex_login::AuthManager; +use codex_login::default_client::build_reqwest_client; +use serde::Deserialize; +use std::io; +use std::io::ErrorKind; +use std::sync::Arc; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; +use url::Url; + +const REMOTE_CONTROL_CLIENT_MANAGEMENT_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(30); + +#[derive(Debug, Deserialize)] +struct ListRemoteControlClientsResponse { + items: Vec, + #[serde(default)] + cursor: Option, +} + +#[derive(Debug, Deserialize)] +struct RemoteControlClientResponse { + client_id: String, + #[serde(default)] + display_name: Option, + #[serde(default)] + device_type: Option, + #[serde(default)] + platform: Option, + #[serde(default)] + os_version: Option, + #[serde(default)] + device_model: Option, + #[serde(default)] + app_version: Option, + #[serde(default)] + last_seen_at: Option, +} + +enum ClientManagementRequest<'a> { + List { + url: &'a Url, + params: &'a RemoteControlClientsListParams, + }, + Revoke { + url: &'a Url, + }, +} + +struct ClientManagementResponse { + status: axum::http::StatusCode, + headers: HeaderMap, + body: Vec, +} + +pub(super) async fn list_remote_control_clients( + remote_control_url: &str, + auth_manager: &Arc, + params: RemoteControlClientsListParams, +) -> io::Result { + if params.environment_id.is_empty() { + return Err(io::Error::new( + ErrorKind::InvalidInput, + "remote control client list requires environmentId", + )); + } + if params + .limit + .is_some_and(|limit| !(1..=100).contains(&limit)) + { + return Err(io::Error::new( + ErrorKind::InvalidInput, + "remote control client list limit must be between 1 and 100", + )); + } + let url = environment_clients_url(remote_control_url, ¶ms.environment_id)?; + let response = send_client_management_request( + auth_manager, + ClientManagementRequest::List { + url: &url, + params: ¶ms, + }, + "list remote control clients", + ) + .await?; + let ClientManagementResponse { + status, + headers, + body, + } = response; + let body_preview = preview_remote_control_response_body(&body); + ensure_success_response(status, &headers, &url, &body_preview, "client list")?; + let response = serde_json::from_slice::(&body).map_err( + |err| { + io::Error::other(format!( + "failed to parse remote control client list response from `{url}`: HTTP {status}, {}, body: {body_preview}, decode error: {err}", + format_headers(&headers) + )) + }, + )?; + Ok(RemoteControlClientsListResponse { + data: response + .items + .into_iter() + .map(RemoteControlClient::try_from) + .collect::>()?, + next_cursor: response.cursor, + }) +} + +pub(super) async fn revoke_remote_control_client( + remote_control_url: &str, + auth_manager: &Arc, + params: RemoteControlClientsRevokeParams, +) -> io::Result { + if params.environment_id.is_empty() { + return Err(io::Error::new( + ErrorKind::InvalidInput, + "remote control client revoke requires environmentId", + )); + } + if params.client_id.is_empty() { + return Err(io::Error::new( + ErrorKind::InvalidInput, + "remote control client revoke requires clientId", + )); + } + let mut url = environment_clients_url(remote_control_url, ¶ms.environment_id)?; + url.path_segments_mut() + .map_err(|()| { + io::Error::new( + ErrorKind::InvalidInput, + "remote control URL cannot be a base", + ) + })? + .push(¶ms.client_id); + let response = send_client_management_request( + auth_manager, + ClientManagementRequest::Revoke { url: &url }, + "revoke remote control client", + ) + .await?; + let ClientManagementResponse { + status, + headers, + body, + } = response; + let body_preview = preview_remote_control_response_body(&body); + ensure_success_response(status, &headers, &url, &body_preview, "client revoke")?; + Ok(RemoteControlClientsRevokeResponse {}) +} + +async fn send_client_management_request( + auth_manager: &Arc, + request: ClientManagementRequest<'_>, + action: &str, +) -> io::Result { + let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut auth_change_rx = auth_manager.auth_change_receiver(); + let auth = load_remote_control_auth(auth_manager).await?; + let response = send_client_management_request_once(&auth, &request, action).await?; + if response.status.as_u16() != 401 + || !recover_remote_control_auth(&mut auth_recovery, &mut auth_change_rx).await + { + return Ok(response); + } + let auth = load_remote_control_auth(auth_manager).await?; + send_client_management_request_once(&auth, &request, action).await +} + +async fn send_client_management_request_once( + auth: &RemoteControlConnectionAuth, + request: &ClientManagementRequest<'_>, + action: &str, +) -> io::Result { + let client = build_reqwest_client(); + let mut auth_headers = HeaderMap::new(); + auth.auth_provider.add_auth_headers(&mut auth_headers); + let request = match request { + ClientManagementRequest::List { url, params } => { + let mut query = Vec::new(); + if let Some(cursor) = ¶ms.cursor { + query.push(("cursor", cursor.clone())); + } + if let Some(limit) = params.limit { + query.push(("limit", limit.to_string())); + } + if let Some(order) = params.order { + query.push(( + "order", + match order { + RemoteControlClientsListOrder::Asc => "asc", + RemoteControlClientsListOrder::Desc => "desc", + } + .to_string(), + )); + } + client.get((*url).clone()).query(&query) + } + ClientManagementRequest::Revoke { url } => client.delete((*url).clone()), + }; + let response = request + .timeout(REMOTE_CONTROL_CLIENT_MANAGEMENT_TIMEOUT) + .headers(auth_headers) + .header(REMOTE_CONTROL_ACCOUNT_ID_HEADER, &auth.account_id) + .send() + .await + .map_err(|err| io::Error::other(format!("failed to {action}: {err}")))?; + let headers = response.headers().clone(); + let status = response.status(); + let body = response + .bytes() + .await + .map_err(|err| io::Error::other(format!("failed to read {action} response: {err}")))? + .to_vec(); + Ok(ClientManagementResponse { + status, + headers, + body, + }) +} + +fn ensure_success_response( + status: axum::http::StatusCode, + headers: &HeaderMap, + url: &Url, + body_preview: &str, + response_kind: &str, +) -> io::Result<()> { + if status.is_success() { + return Ok(()); + } + let error_kind = match status.as_u16() { + 400 => ErrorKind::InvalidInput, + 401 | 403 => ErrorKind::PermissionDenied, + 404 => ErrorKind::NotFound, + _ => ErrorKind::Other, + }; + Err(io::Error::new( + error_kind, + format!( + "remote control {response_kind} failed at `{url}`: HTTP {status}, {}, body: {body_preview}", + format_headers(headers) + ), + )) +} + +fn environment_clients_url(remote_control_url: &str, environment_id: &str) -> io::Result { + let mut url = normalize_remote_control_base_url(remote_control_url)? + .join("wham/remote/control/environments") + .map_err(io::Error::other)?; + url.path_segments_mut() + .map_err(|()| { + io::Error::new( + ErrorKind::InvalidInput, + "remote control URL cannot be a base", + ) + })? + .push(environment_id) + .push("clients"); + Ok(url) +} + +impl TryFrom for RemoteControlClient { + type Error = io::Error; + + fn try_from(client: RemoteControlClientResponse) -> Result { + Ok(Self { + client_id: client.client_id, + display_name: client.display_name, + device_type: client.device_type, + platform: client.platform, + os_version: client.os_version, + device_model: client.device_model, + app_version: client.app_version, + last_seen_at: client + .last_seen_at + .map(|last_seen_at| { + OffsetDateTime::parse(&last_seen_at, &Rfc3339) + .map(OffsetDateTime::unix_timestamp) + .map_err(|err| { + io::Error::new( + ErrorKind::InvalidData, + format!( + "failed to parse remote control client last_seen_at `{last_seen_at}`: {err}" + ), + ) + }) + }) + .transpose()?, + }) + } +} diff --git a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs index ac67726371..c491596e65 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs @@ -1,3 +1,4 @@ +use super::auth::RemoteControlConnectionAuth; use super::pairing_unavailable_error; use super::protocol::EnrollRemoteServerRequest; use super::protocol::EnrollRemoteServerResponse; @@ -6,7 +7,6 @@ use super::protocol::RemoteControlTarget; use super::protocol::StartRemoteControlPairingRequest; use super::protocol::StartRemoteControlPairingResponse; use axum::http::HeaderMap; -use codex_api::SharedAuthProvider; use codex_app_server_protocol::RemoteControlPairingStartResponse; use codex_login::default_client::build_reqwest_client; use codex_state::RemoteControlEnrollmentRecord; @@ -151,11 +151,6 @@ impl RemoteControlEnrollment { } } -pub(super) struct RemoteControlConnectionAuth { - pub(super) auth_provider: SharedAuthProvider, - pub(super) account_id: String, -} - pub(super) async fn load_persisted_remote_control_enrollment( state_db: Option<&StateRuntime>, remote_control_target: &RemoteControlTarget, diff --git a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs index a55667886f..38e34e6d43 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs @@ -1,9 +1,13 @@ +mod auth; mod client_tracker; +mod clients; mod enroll; mod protocol; mod segment; mod websocket; +use self::auth::load_remote_control_auth; +use self::auth::recover_remote_control_auth; use self::enroll::RemoteControlEnrollment; use self::enroll::refresh_remote_control_server; use crate::transport::remote_control::websocket::RemoteControlChannels; @@ -17,6 +21,10 @@ use self::protocol::normalize_remote_control_url; use super::CHANNEL_CAPACITY; use super::TransportEvent; use super::next_connection_id; +use codex_app_server_protocol::RemoteControlClientsListParams; +use codex_app_server_protocol::RemoteControlClientsListResponse; +use codex_app_server_protocol::RemoteControlClientsRevokeParams; +use codex_app_server_protocol::RemoteControlClientsRevokeResponse; use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlPairingStartParams; use codex_app_server_protocol::RemoteControlPairingStartResponse; @@ -57,6 +65,7 @@ pub struct RemoteControlHandle { enabled_tx: Arc>, status_tx: Arc>, state_db_available: bool, + remote_control_url: String, current_enrollment: CurrentRemoteControlEnrollment, auth_manager: Arc, } @@ -146,7 +155,7 @@ impl RemoteControlHandle { if !*self.enabled_tx.borrow() { return Err(Self::pairing_disabled_error()); } - let mut auth = websocket::load_remote_control_auth(&self.auth_manager) + let mut auth = load_remote_control_auth(&self.auth_manager) .await .map_err(|_| pairing_unavailable_error())?; let mut enrollment = { @@ -205,7 +214,7 @@ impl RemoteControlHandle { if !*self.enabled_tx.borrow() { return Err(Self::pairing_disabled_error()); } - let current_auth = websocket::load_remote_control_auth(&self.auth_manager) + let current_auth = load_remote_control_auth(&self.auth_manager) .await .map_err(|_| pairing_unavailable_error())?; if current_auth.account_id != auth.account_id { @@ -214,6 +223,22 @@ impl RemoteControlHandle { pairing_response } + pub async fn list_clients( + &self, + params: RemoteControlClientsListParams, + ) -> io::Result { + clients::list_remote_control_clients(&self.remote_control_url, &self.auth_manager, params) + .await + } + + pub async fn revoke_client( + &self, + params: RemoteControlClientsRevokeParams, + ) -> io::Result { + clients::revoke_remote_control_client(&self.remote_control_url, &self.auth_manager, params) + .await + } + fn pairing_disabled_error() -> io::Error { io::Error::new( io::ErrorKind::InvalidInput, @@ -255,7 +280,7 @@ impl RemoteControlHandle { async fn refresh_pairing_enrollment( current_enrollment: &CurrentRemoteControlEnrollment, auth_manager: &Arc, - auth: &mut enroll::RemoteControlConnectionAuth, + auth: &mut auth::RemoteControlConnectionAuth, installation_id: &str, enrollment: &mut RemoteControlEnrollment, ) -> io::Result<()> { @@ -265,10 +290,10 @@ async fn refresh_pairing_enrollment( } let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - if !websocket::recover_remote_control_auth(&mut auth_recovery, &mut auth_change_rx).await { + if !recover_remote_control_auth(&mut auth_recovery, &mut auth_change_rx).await { return Err(err); } - *auth = websocket::load_remote_control_auth(auth_manager) + *auth = load_remote_control_auth(auth_manager) .await .map_err(|_| pairing_unavailable_error())?; if auth.account_id != enrollment.account_id { @@ -440,6 +465,7 @@ pub async fn start_remote_control( "starting app-server remote control websocket task" ); let remote_control_url_for_log = remote_control_url.clone(); + let handle_remote_control_url = remote_control_url.clone(); let installation_id_for_log = installation_id.clone(); let server_name_for_log = server_name.clone(); let shutdown_token_for_log = shutdown_token.clone(); @@ -508,6 +534,7 @@ pub async fn start_remote_control( enabled_tx: Arc::new(enabled_tx), status_tx: Arc::new(status_tx), state_db_available, + remote_control_url: handle_remote_control_url, current_enrollment, auth_manager: handle_auth_manager, }, diff --git a/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs b/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs index 8eeb28e266..630fd862c8 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs @@ -177,6 +177,48 @@ fn is_localhost(host: &Option>) -> bool { pub(super) fn normalize_remote_control_url( remote_control_url: &str, ) -> io::Result { + let remote_control_url = normalize_remote_control_base_url(remote_control_url)?; + let map_url_parse_error = |err: url::ParseError| -> io::Error { + io::Error::new( + ErrorKind::InvalidInput, + format!("invalid remote control URL `{remote_control_url}`: {err}"), + ) + }; + + let enroll_url = remote_control_url + .join("wham/remote/control/server/enroll") + .map_err(map_url_parse_error)?; + let refresh_url = remote_control_url + .join("wham/remote/control/server/refresh") + .map_err(map_url_parse_error)?; + let pair_url = remote_control_url + .join("wham/remote/control/server/pair") + .map_err(map_url_parse_error)?; + let mut websocket_url = remote_control_url + .join("wham/remote/control/server") + .map_err(map_url_parse_error)?; + websocket_url + .set_scheme(if enroll_url.scheme() == "https" { + "wss" + } else { + "ws" + }) + .map_err(|()| { + io::Error::new( + ErrorKind::InvalidInput, + format!("invalid remote control URL `{remote_control_url}`"), + ) + })?; + + Ok(RemoteControlTarget { + websocket_url: websocket_url.to_string(), + enroll_url: enroll_url.to_string(), + refresh_url: refresh_url.to_string(), + pair_url: pair_url.to_string(), + }) +} + +pub(super) fn normalize_remote_control_base_url(remote_control_url: &str) -> io::Result { let map_url_parse_error = |err: url::ParseError| -> io::Error { io::Error::new( ErrorKind::InvalidInput, @@ -198,35 +240,14 @@ pub(super) fn normalize_remote_control_url( remote_control_url.set_path(&normalized_path); } - let enroll_url = remote_control_url - .join("wham/remote/control/server/enroll") - .map_err(map_url_parse_error)?; - let refresh_url = remote_control_url - .join("wham/remote/control/server/refresh") - .map_err(map_url_parse_error)?; - let pair_url = remote_control_url - .join("wham/remote/control/server/pair") - .map_err(map_url_parse_error)?; - let mut websocket_url = remote_control_url - .join("wham/remote/control/server") - .map_err(map_url_parse_error)?; - let host = enroll_url.host(); - match enroll_url.scheme() { - "https" if is_localhost(&host) || is_allowed_remote_control_chatgpt_host(&host) => { - websocket_url.set_scheme("wss").map_err(map_scheme_error)?; - } - "http" if is_localhost(&host) => { - websocket_url.set_scheme("ws").map_err(map_scheme_error)?; - } + let host = remote_control_url.host(); + match remote_control_url.scheme() { + "https" if is_localhost(&host) || is_allowed_remote_control_chatgpt_host(&host) => {} + "http" if is_localhost(&host) => {} _ => return Err(map_scheme_error(())), } - Ok(RemoteControlTarget { - websocket_url: websocket_url.to_string(), - enroll_url: enroll_url.to_string(), - refresh_url: refresh_url.to_string(), - pair_url: pair_url.to_string(), - }) + Ok(remote_control_url) } #[cfg(test)] diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 5d96f89d89..a1a284da9b 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -60,6 +60,7 @@ use tokio_tungstenite::accept_hdr_async; use tokio_tungstenite::tungstenite; use tokio_util::sync::CancellationToken; +mod clients_tests; mod pairing_tests; const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; @@ -163,6 +164,7 @@ fn remote_control_handle_with_current_enrollment( enabled_tx: Arc::new(enabled_tx), status_tx: Arc::new(status_tx), state_db_available: true, + remote_control_url: remote_control_url.to_string(), current_enrollment, auth_manager, } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs new file mode 100644 index 0000000000..22aa290762 --- /dev/null +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs @@ -0,0 +1,359 @@ +use super::super::clients::list_remote_control_clients; +use super::super::clients::revoke_remote_control_client; +use super::*; +use codex_app_server_protocol::RemoteControlClient; +use codex_app_server_protocol::RemoteControlClientsListOrder; +use codex_app_server_protocol::RemoteControlClientsListParams; +use codex_app_server_protocol::RemoteControlClientsListResponse; +use codex_app_server_protocol::RemoteControlClientsRevokeParams; +use codex_app_server_protocol::RemoteControlClientsRevokeResponse; +use pretty_assertions::assert_eq; + +fn client_management_handle( + remote_control_url: String, + auth_manager: Arc, +) -> RemoteControlHandle { + let (enabled_tx, _enabled_rx) = watch::channel(/*init*/ false); + let (status_tx, _status_rx) = watch::channel(RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Disabled, + server_name: test_server_name(), + installation_id: TEST_INSTALLATION_ID.to_string(), + environment_id: None, + }); + RemoteControlHandle { + enabled_tx: Arc::new(enabled_tx), + status_tx: Arc::new(status_tx), + state_db_available: false, + remote_control_url, + current_enrollment: Arc::new(StdMutex::new(None)), + auth_manager, + } +} + +fn empty_client_list() -> serde_json::Value { + json!({ + "items": [], + "cursor": null, + }) +} + +#[tokio::test] +async fn remote_control_handle_lists_clients_while_disabled() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let request = accept_http_request(&listener).await; + assert_eq!( + request.request_line, + "GET /backend-api/wham/remote/control/environments/env%20%2F%3F/clients?cursor=cursor+%2F%3F&limit=10&order=asc HTTP/1.1" + ); + assert_eq!( + request.headers.get("authorization"), + Some(&"Bearer Access Token".to_string()) + ); + assert_eq!( + request.headers.get(REMOTE_CONTROL_ACCOUNT_ID_HEADER), + Some(&"account_id".to_string()) + ); + respond_with_json( + request.stream, + json!({ + "items": [{ + "client_id": "client-123", + "account_user_id": "user-123", + "enrollment_status": "enrolled_device_key", + "display_name": "Anton Phone", + "device_type": "phone", + "platform": "ios", + "os_version": "19.0", + "device_model": "iPhone", + "app_version": "1.2.3", + "last_seen_at": "2026-03-05T07:00:00Z", + "last_seen_city": "San Francisco", + }], + "cursor": "next-cursor", + }), + ) + .await; + }); + let handle = client_management_handle(remote_control_url, remote_control_auth_manager()); + + let response = handle + .list_clients(RemoteControlClientsListParams { + environment_id: "env /?".to_string(), + cursor: Some("cursor /?".to_string()), + limit: Some(10), + order: Some(RemoteControlClientsListOrder::Asc), + }) + .await + .expect("client list should succeed while remote control is disabled"); + server_task.await.expect("server task should finish"); + + assert_eq!( + response, + RemoteControlClientsListResponse { + data: vec![RemoteControlClient { + client_id: "client-123".to_string(), + display_name: Some("Anton Phone".to_string()), + device_type: Some("phone".to_string()), + platform: Some("ios".to_string()), + os_version: Some("19.0".to_string()), + device_model: Some("iPhone".to_string()), + app_version: Some("1.2.3".to_string()), + last_seen_at: Some(1_772_694_000), + }], + next_cursor: Some("next-cursor".to_string()), + } + ); +} + +#[tokio::test] +async fn remote_control_handle_revokes_client_while_disabled() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let request = accept_http_request(&listener).await; + assert_eq!( + request.request_line, + "DELETE /backend-api/wham/remote/control/environments/env%20%2F%3F/clients/client%20%2F%3F HTTP/1.1" + ); + respond_with_status(request.stream, "204 No Content", "").await; + }); + let handle = client_management_handle(remote_control_url, remote_control_auth_manager()); + + let response = handle + .revoke_client(RemoteControlClientsRevokeParams { + environment_id: "env /?".to_string(), + client_id: "client /?".to_string(), + }) + .await + .expect("client revoke should succeed while remote control is disabled"); + server_task.await.expect("server task should finish"); + + assert_eq!(response, RemoteControlClientsRevokeResponse {}); +} + +#[tokio::test] +async fn list_remote_control_clients_recovers_auth_after_unauthorized() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let stale_request = accept_http_request(&listener).await; + assert_eq!( + stale_request.headers.get("authorization"), + Some(&"Bearer stale-token".to_string()) + ); + respond_with_status(stale_request.stream, "401 Unauthorized", "").await; + + let recovered_request = accept_http_request(&listener).await; + assert_eq!( + recovered_request.headers.get("authorization"), + Some(&"Bearer fresh-token".to_string()) + ); + respond_with_json(recovered_request.stream, empty_client_list()).await; + }); + let codex_home = TempDir::new().expect("temp dir should create"); + let mut stale_auth = remote_control_auth_dot_json(Some("account_id")); + stale_auth + .tokens + .as_mut() + .expect("stale auth should include tokens") + .access_token = "stale-token".to_string(); + save_auth( + codex_home.path(), + &stale_auth, + AuthCredentialsStoreMode::File, + ) + .expect("stale auth should save"); + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await; + let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); + fresh_auth + .tokens + .as_mut() + .expect("fresh auth should include tokens") + .access_token = "fresh-token".to_string(); + save_auth( + codex_home.path(), + &fresh_auth, + AuthCredentialsStoreMode::File, + ) + .expect("fresh auth should save"); + + let response = list_remote_control_clients( + &remote_control_url, + &auth_manager, + RemoteControlClientsListParams { + environment_id: "env-123".to_string(), + ..Default::default() + }, + ) + .await + .expect("client list should recover auth"); + server_task.await.expect("server task should finish"); + + assert_eq!( + response, + RemoteControlClientsListResponse { + data: Vec::new(), + next_cursor: None, + } + ); +} + +#[tokio::test] +async fn list_remote_control_clients_retries_unauthorized_only_once() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let stale_request = accept_http_request(&listener).await; + assert_eq!( + stale_request.headers.get("authorization"), + Some(&"Bearer stale-token".to_string()) + ); + respond_with_status(stale_request.stream, "401 Unauthorized", "").await; + + let recovered_request = accept_http_request(&listener).await; + assert_eq!( + recovered_request.headers.get("authorization"), + Some(&"Bearer fresh-token".to_string()) + ); + respond_with_status(recovered_request.stream, "401 Unauthorized", "").await; + + assert!( + timeout(Duration::from_millis(100), accept_http_request(&listener)) + .await + .is_err() + ); + }); + let codex_home = TempDir::new().expect("temp dir should create"); + let mut stale_auth = remote_control_auth_dot_json(Some("account_id")); + stale_auth + .tokens + .as_mut() + .expect("stale auth should include tokens") + .access_token = "stale-token".to_string(); + save_auth( + codex_home.path(), + &stale_auth, + AuthCredentialsStoreMode::File, + ) + .expect("stale auth should save"); + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await; + let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); + fresh_auth + .tokens + .as_mut() + .expect("fresh auth should include tokens") + .access_token = "fresh-token".to_string(); + save_auth( + codex_home.path(), + &fresh_auth, + AuthCredentialsStoreMode::File, + ) + .expect("fresh auth should save"); + + let err = list_remote_control_clients( + &remote_control_url, + &auth_manager, + RemoteControlClientsListParams { + environment_id: "env-123".to_string(), + ..Default::default() + }, + ) + .await + .expect_err("second unauthorized response should fail"); + server_task.await.expect("server task should finish"); + + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); +} + +#[tokio::test] +async fn revoke_remote_control_client_does_not_retry_forbidden() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let request = accept_http_request(&listener).await; + respond_with_status_and_headers( + request.stream, + "403 Forbidden", + &[("x-request-id", "request-123"), ("cf-ray", "ray-123")], + "forbidden", + ) + .await; + }); + + let err = revoke_remote_control_client( + &remote_control_url, + &remote_control_auth_manager(), + RemoteControlClientsRevokeParams { + environment_id: "env-123".to_string(), + client_id: "client-123".to_string(), + }, + ) + .await + .expect_err("forbidden revoke should fail"); + server_task.await.expect("server task should finish"); + + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); + assert_eq!( + err.to_string(), + format!( + "remote control client revoke failed at `{remote_control_url}wham/remote/control/environments/env-123/clients/client-123`: HTTP 403 Forbidden, request-id: request-123, cf-ray: ray-123, body: forbidden" + ) + ); +} + +#[tokio::test] +async fn list_remote_control_clients_preserves_decode_error_context() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let request = accept_http_request(&listener).await; + respond_with_status(request.stream, "200 OK", "{").await; + }); + + let err = list_remote_control_clients( + &remote_control_url, + &remote_control_auth_manager(), + RemoteControlClientsListParams { + environment_id: "env-123".to_string(), + ..Default::default() + }, + ) + .await + .expect_err("malformed client list should fail"); + server_task.await.expect("server task should finish"); + + assert!( + err.to_string().contains( + "failed to parse remote control client list response from `http://127.0.0.1:" + ) + ); + assert!(err.to_string().contains("HTTP 200 OK")); + assert!(err.to_string().contains("body: {")); + assert!(err.to_string().contains("decode error:")); +} diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 5ec4f3d04e..60792df54c 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -13,9 +13,11 @@ use super::segment::ClientSegmentReassembler; use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES; use super::segment::split_server_envelope_for_transport; use crate::transport::TransportEvent; +use crate::transport::remote_control::auth::RemoteControlConnectionAuth; +use crate::transport::remote_control::auth::load_remote_control_auth; +use crate::transport::remote_control::auth::recover_remote_control_auth; use crate::transport::remote_control::client_tracker::ClientTracker; use crate::transport::remote_control::client_tracker::REMOTE_CONTROL_IDLE_SWEEP_INTERVAL; -use crate::transport::remote_control::enroll::RemoteControlConnectionAuth; use crate::transport::remote_control::enroll::RemoteControlEnrollment; use crate::transport::remote_control::enroll::enroll_remote_control_server; use crate::transport::remote_control::enroll::format_headers; @@ -1172,51 +1174,6 @@ fn build_remote_control_websocket_request( Ok(request) } -pub(crate) async fn load_remote_control_auth( - auth_manager: &Arc, -) -> io::Result { - let mut reloaded = false; - let auth = loop { - let Some(auth) = auth_manager.auth().await else { - if reloaded { - return Err(io::Error::new( - ErrorKind::PermissionDenied, - "remote control requires ChatGPT authentication", - )); - } - auth_manager.reload().await; - reloaded = true; - continue; - }; - if !auth.uses_codex_backend() { - break auth; - } - if auth.get_account_id().is_none() && !reloaded { - auth_manager.reload().await; - reloaded = true; - continue; - } - break auth; - }; - - if !auth.uses_codex_backend() { - return Err(io::Error::new( - ErrorKind::PermissionDenied, - "remote control requires ChatGPT authentication; API key auth is not supported", - )); - } - - Ok(RemoteControlConnectionAuth { - auth_provider: codex_model_provider::auth_provider_from_auth(&auth), - account_id: auth.get_account_id().ok_or_else(|| { - io::Error::new( - ErrorKind::WouldBlock, - "remote control enrollment is waiting for a ChatGPT account id", - ) - })?, - }) -} - fn next_reconnect_delay(reconnect_attempt: &mut u64) -> (std::time::Duration, bool) { let reconnect_delay = backoff(*reconnect_attempt).min(REMOTE_CONTROL_RECONNECT_BACKOFF_CAP); let reconnect_backoff_reset = reconnect_delay == REMOTE_CONTROL_RECONNECT_BACKOFF_CAP; @@ -1548,52 +1505,6 @@ async fn enroll_remote_control_server_if_missing( Ok(()) } -pub(super) async fn recover_remote_control_auth( - auth_recovery: &mut UnauthorizedRecovery, - auth_change_rx: &mut watch::Receiver, -) -> bool { - if !auth_recovery.has_next() { - return false; - } - - let mode = auth_recovery.mode_name(); - let step = auth_recovery.step_name(); - let auth_change_revision_before_recovery = *auth_change_rx.borrow(); - match auth_recovery.next().await { - Ok(step_result) => { - if step_result.auth_state_changed() == Some(true) { - mark_recovery_auth_change_seen( - auth_change_rx, - auth_change_revision_before_recovery, - ); - } - info!( - "remote control websocket auth recovery succeeded: mode={mode}, step={step}, auth_state_changed={:?}", - step_result.auth_state_changed() - ); - true - } - Err(err) => { - warn!("remote control websocket auth recovery failed: mode={mode}, step={step}: {err}"); - false - } - } -} - -fn mark_recovery_auth_change_seen( - auth_change_rx: &mut watch::Receiver, - auth_change_revision_before_recovery: u64, -) { - let auth_change_revision_after_recovery = *auth_change_rx.borrow(); - if auth_change_revision_after_recovery == auth_change_revision_before_recovery.wrapping_add(1) { - // Recovery updated the same watch that wakes the outer reconnect - // loop. Mark only that single revision seen; if more revisions - // arrived while recovery was in flight, leave them pending so the - // reconnect loop still reacts to the later external auth change. - auth_change_rx.borrow_and_update(); - } -} - fn format_remote_control_websocket_connect_error( websocket_url: &str, err: &tungstenite::Error, @@ -1620,6 +1531,7 @@ mod tests { use super::*; use crate::outgoing_message::OutgoingMessage; use crate::transport::remote_control::ServerEvent; + use crate::transport::remote_control::auth::mark_recovery_auth_change_seen; use crate::transport::remote_control::protocol::StreamId; use crate::transport::remote_control::protocol::normalize_remote_control_url; use chrono::Utc; diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index fdc34e524c..dc02ff8c04 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -211,6 +211,8 @@ Example with notification opt-out: - `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. This does not revoke already enrolled controller devices. - `remoteControl/status/read` — experimental; read the current remote-control status snapshot. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. - `remoteControl/pairing/start` — experimental; start a short-lived remote-control pairing artifact for the current app-server process. Pass `manualCode: true` to also request a manual pairing code. Returns `pairingCode`, `manualPairingCode`, `environmentId`, and Unix-seconds `expiresAt`; app-server intentionally does not expose the backend `serverId`. +- `remoteControl/client/list` — experimental; list controller devices granted access to an environment. Pass `environmentId` and optional `cursor`, `limit`, and `order`; returns picker-oriented client metadata plus `nextCursor`. This signed-in account-management operation works while the local relay is disabled or unenrolled. +- `remoteControl/client/revoke` — experimental; revoke one controller device's grant for an environment. Pass `environmentId` and `clientId`; returns an empty object. This signed-in account-management operation works while the local relay is disabled or unenrolled. - `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot. - `skills/config/write` — write user-level skill config by name or absolute path. - `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 7aa93ec86e..84f22809ee 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -922,6 +922,16 @@ impl MessageProcessor { .pairing_start(params) .await .map(|response| Some(response.into())), + ClientRequest::RemoteControlClientsList { params, .. } => self + .remote_control_processor + .clients_list(params) + .await + .map(|response| Some(response.into())), + ClientRequest::RemoteControlClientsRevoke { params, .. } => self + .remote_control_processor + .clients_revoke(params) + .await + .map(|response| Some(response.into())), ClientRequest::ConfigRequirementsRead { params: _, .. } => self .config_processor .config_requirements_read() diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor.rs b/codex-rs/app-server/src/request_processors/remote_control_processor.rs index 7c279585f2..804339c17a 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor.rs @@ -3,6 +3,10 @@ use crate::error_code::invalid_request; use crate::transport::RemoteControlHandle; use crate::transport::RemoteControlUnavailable; use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::RemoteControlClientsListParams; +use codex_app_server_protocol::RemoteControlClientsListResponse; +use codex_app_server_protocol::RemoteControlClientsRevokeParams; +use codex_app_server_protocol::RemoteControlClientsRevokeResponse; use codex_app_server_protocol::RemoteControlDisableResponse; use codex_app_server_protocol::RemoteControlEnableResponse; use codex_app_server_protocol::RemoteControlPairingStartParams; @@ -55,6 +59,26 @@ impl RemoteControlRequestProcessor { .map_err(map_pairing_start_error) } + pub(crate) async fn clients_list( + &self, + params: RemoteControlClientsListParams, + ) -> Result { + self.handle()? + .list_clients(params) + .await + .map_err(map_client_management_error) + } + + pub(crate) async fn clients_revoke( + &self, + params: RemoteControlClientsRevokeParams, + ) -> Result { + self.handle()? + .revoke_client(params) + .await + .map_err(map_client_management_error) + } + fn handle(&self) -> Result<&RemoteControlHandle, JSONRPCErrorError> { self.remote_control_handle .as_ref() @@ -74,5 +98,15 @@ fn map_pairing_start_error(err: io::Error) -> JSONRPCErrorError { } } +fn map_client_management_error(err: io::Error) -> JSONRPCErrorError { + match err.kind() { + io::ErrorKind::InvalidInput + | io::ErrorKind::NotFound + | io::ErrorKind::PermissionDenied + | io::ErrorKind::WouldBlock => invalid_request(err.to_string()), + _ => internal_error(err.to_string()), + } +} + #[cfg(test)] mod remote_control_processor_tests; diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs b/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs index 8a1db50a3a..4381e48959 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs @@ -46,3 +46,34 @@ fn pairing_start_maps_backend_failures_to_internal_error() { } ); } + +#[test] +fn client_management_maps_user_actionable_errors_to_invalid_request() { + for kind in [ + io::ErrorKind::InvalidInput, + io::ErrorKind::NotFound, + io::ErrorKind::PermissionDenied, + io::ErrorKind::WouldBlock, + ] { + assert_eq!( + map_client_management_error(io::Error::new(kind, "client management unavailable")), + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + data: None, + message: "client management unavailable".to_string(), + } + ); + } +} + +#[test] +fn client_management_maps_backend_failures_to_internal_error() { + assert_eq!( + map_client_management_error(io::Error::other("client management failed")), + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + data: None, + message: "client management failed".to_string(), + } + ); +} diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index 88378322a8..ec4b30d92f 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -67,6 +67,8 @@ use codex_app_server_protocol::ProcessKillParams; use codex_app_server_protocol::ProcessResizePtyParams; use codex_app_server_protocol::ProcessSpawnParams; use codex_app_server_protocol::ProcessWriteStdinParams; +use codex_app_server_protocol::RemoteControlClientsListParams; +use codex_app_server_protocol::RemoteControlClientsRevokeParams; use codex_app_server_protocol::RemoteControlPairingStartParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; @@ -654,6 +656,25 @@ impl TestAppServer { .await } + /// Send a `remoteControl/client/list` JSON-RPC request. + pub async fn send_remote_control_clients_list_request( + &mut self, + params: RemoteControlClientsListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("remoteControl/client/list", params).await + } + + /// Send a `remoteControl/client/revoke` JSON-RPC request. + pub async fn send_remote_control_clients_revoke_request( + &mut self, + params: RemoteControlClientsRevokeParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("remoteControl/client/revoke", params) + .await + } + /// Send an `app/list` JSON-RPC request. pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result { let params = Some(serde_json::to_value(params)?); diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs index 86187cfc40..b5384d3bad 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_control.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -8,6 +8,12 @@ use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RemoteControlClient; +use codex_app_server_protocol::RemoteControlClientsListOrder; +use codex_app_server_protocol::RemoteControlClientsListParams; +use codex_app_server_protocol::RemoteControlClientsListResponse; +use codex_app_server_protocol::RemoteControlClientsRevokeParams; +use codex_app_server_protocol::RemoteControlClientsRevokeResponse; use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlDisableResponse; use codex_app_server_protocol::RemoteControlEnableResponse; @@ -187,11 +193,130 @@ async fn remote_control_pairing_start_returns_pairing_artifacts() -> Result<()> Ok(()) } +#[tokio::test] +async fn remote_control_client_management_works_while_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let mut backend = ClientManagementRemoteControlBackend::start(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_remote_control_clients_list_request(RemoteControlClientsListParams { + environment_id: "environment-id".to_string(), + cursor: Some("cursor-id".to_string()), + limit: Some(10), + order: Some(RemoteControlClientsListOrder::Desc), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: RemoteControlClientsListResponse = to_response(response)?; + assert_eq!( + received, + RemoteControlClientsListResponse { + data: vec![RemoteControlClient { + client_id: "client-id".to_string(), + display_name: Some("Anton Phone".to_string()), + device_type: Some("phone".to_string()), + platform: Some("ios".to_string()), + os_version: Some("19.0".to_string()), + device_model: Some("iPhone".to_string()), + app_version: Some("1.2.3".to_string()), + last_seen_at: Some(1_772_694_000), + }], + next_cursor: Some("next-cursor".to_string()), + } + ); + + let request_id = mcp + .send_remote_control_clients_revoke_request(RemoteControlClientsRevokeParams { + environment_id: "environment-id".to_string(), + client_id: "client-id".to_string(), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: RemoteControlClientsRevokeResponse = to_response(response)?; + assert_eq!(received, RemoteControlClientsRevokeResponse {}); + assert_eq!( + timeout(DEFAULT_TIMEOUT, backend.wait_for_requests()).await??, + vec![ + "GET /backend-api/wham/remote/control/environments/environment-id/clients?cursor=cursor-id&limit=10&order=desc HTTP/1.1".to_string(), + "DELETE /backend-api/wham/remote/control/environments/environment-id/clients/client-id HTTP/1.1".to_string(), + ] + ); + Ok(()) +} + struct BlockingRemoteControlBackend { enroll_request_rx: Option>>, server_task: JoinHandle<()>, } +struct ClientManagementRemoteControlBackend { + requests_rx: Option>>>, + server_task: JoinHandle<()>, +} + +impl ClientManagementRemoteControlBackend { + async fn start(codex_home: &std::path::Path) -> Result { + let listener = configured_remote_control_listener(codex_home).await?; + let (requests_tx, requests_rx) = oneshot::channel(); + let server_task = tokio::spawn(async move { + let result = async { + let list_request = read_http_request(&listener).await?; + let list_request_line = list_request.request_line; + respond_with_json( + list_request.reader.into_inner(), + serde_json::json!({ + "items": [{ + "client_id": "client-id", + "account_user_id": "user-id", + "enrollment_status": "enrolled_device_key", + "display_name": "Anton Phone", + "device_type": "phone", + "platform": "ios", + "os_version": "19.0", + "device_model": "iPhone", + "app_version": "1.2.3", + "last_seen_at": "2026-03-05T07:00:00Z", + "last_seen_city": "San Francisco", + }], + "cursor": "next-cursor", + }), + ) + .await?; + + let revoke_request = read_http_request(&listener).await?; + let revoke_request_line = revoke_request.request_line; + respond_with_status(revoke_request.reader.into_inner(), "204 No Content", "") + .await?; + + Ok(vec![list_request_line, revoke_request_line]) + } + .await; + let _ = requests_tx.send(result); + }); + Ok(Self { + requests_rx: Some(requests_rx), + server_task, + }) + } + + async fn wait_for_requests(&mut self) -> Result> { + self.requests_rx + .take() + .context("requests should only be awaited once")? + .await? + } +} + impl BlockingRemoteControlBackend { async fn start(codex_home: &std::path::Path) -> Result { let listener = configured_remote_control_listener(codex_home).await?; @@ -303,6 +428,12 @@ impl Drop for BlockingRemoteControlBackend { } } +impl Drop for ClientManagementRemoteControlBackend { + fn drop(&mut self) { + self.server_task.abort(); + } +} + struct HttpRequest { request_line: String, reader: BufReader, @@ -365,3 +496,16 @@ async fn respond_with_json(stream: TcpStream, body: serde_json::Value) -> Result .await?; Ok(()) } + +async fn respond_with_status(mut stream: TcpStream, status: &str, body: &str) -> Result<()> { + stream + .write_all( + format!( + "HTTP/1.1 {status}\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await?; + Ok(()) +} From d36a3ead3c896d0552207763ef483262bce9ac73 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 2 Jun 2026 17:34:04 -0700 Subject: [PATCH 52/59] revert: publish release symbol artifacts (#25988) revert https://github.com/openai/codex/pull/25916 and https://github.com/openai/codex/pull/25649. --- ...hive-release-symbols-and-strip-binaries.sh | 119 ------------------ .github/workflows/rust-release-windows.yml | 35 +----- .github/workflows/rust-release.yml | 41 +----- 3 files changed, 3 insertions(+), 192 deletions(-) delete mode 100755 .github/scripts/archive-release-symbols-and-strip-binaries.sh diff --git a/.github/scripts/archive-release-symbols-and-strip-binaries.sh b/.github/scripts/archive-release-symbols-and-strip-binaries.sh deleted file mode 100755 index 3e5894bb99..0000000000 --- a/.github/scripts/archive-release-symbols-and-strip-binaries.sh +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: archive-release-symbols-and-strip-binaries.sh \ - --target \ - --artifact-name \ - --release-dir \ - --archive-dir \ - --binaries "" -EOF -} - -target="" -artifact_name="" -release_dir="" -archive_dir="" -binaries="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --target) - target="${2:?--target requires a value}" - shift 2 - ;; - --artifact-name) - artifact_name="${2:?--artifact-name requires a value}" - shift 2 - ;; - --release-dir) - release_dir="${2:?--release-dir requires a value}" - shift 2 - ;; - --archive-dir) - archive_dir="${2:?--archive-dir requires a value}" - shift 2 - ;; - --binaries) - binaries="${2:?--binaries requires a value}" - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Unexpected argument: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done - -if [[ -z "$target" || -z "$artifact_name" || -z "$release_dir" || -z "$archive_dir" || -z "$binaries" ]]; then - usage >&2 - exit 1 -fi - -symbols_root="${RUNNER_TEMP:-/tmp}/codex-symbols-${artifact_name}" -symbols_dir="${symbols_root}/codex-symbols-${artifact_name}" -archive_path="${archive_dir%/}/codex-symbols-${artifact_name}.tar.gz" -rm -rf "$symbols_root" -mkdir -p "$symbols_dir" "$archive_dir" -read -r -a binary_names <<< "$binaries" - -case "$target" in - *apple-darwin) - for binary in "${binary_names[@]}"; do - binary_path="${release_dir%/}/${binary}" - dsym_path="${binary_path}.dSYM" - if [[ ! -f "$binary_path" ]]; then - echo "Binary $binary_path not found" >&2 - exit 1 - fi - if [[ ! -d "$dsym_path" ]]; then - echo "dSYM $dsym_path not found" >&2 - exit 1 - fi - - cp -RL "$dsym_path" "${symbols_dir}/${binary}.dSYM" - strip -S -x "$binary_path" - done - ;; - *linux*) - objcopy_bin="${OBJCOPY:-objcopy}" - strip_bin="${STRIP:-strip}" - for binary in "${binary_names[@]}"; do - binary_path="${release_dir%/}/${binary}" - debug_path="${symbols_dir}/${binary}.debug" - if [[ ! -f "$binary_path" ]]; then - echo "Binary $binary_path not found" >&2 - exit 1 - fi - - "$objcopy_bin" --only-keep-debug "$binary_path" "$debug_path" - "$strip_bin" --strip-debug --strip-unneeded "$binary_path" - "$objcopy_bin" --add-gnu-debuglink="$debug_path" "$binary_path" - done - ;; - *windows*) - for binary in "${binary_names[@]}"; do - pdb_path="${release_dir%/}/${binary}.pdb" - if [[ ! -f "$pdb_path" ]]; then - echo "PDB $pdb_path not found" >&2 - exit 1 - fi - - cp "$pdb_path" "${symbols_dir}/${binary}.pdb" - done - ;; - *) - echo "No symbols packaging support for target: $target" >&2 - exit 1 - ;; -esac - -rm -f "$archive_path" -tar -C "$symbols_root" -czf "$archive_path" "codex-symbols-${artifact_name}" diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index fc14088ef1..89bcd33ea1 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -39,8 +39,6 @@ jobs: working-directory: codex-rs env: CARGO_PROFILE_RELEASE_LTO: ${{ inputs.release-lto }} - CARGO_PROFILE_RELEASE_DEBUG: full - CARGO_PROFILE_RELEASE_STRIP: "false" strategy: fail-fast: false @@ -134,22 +132,10 @@ jobs: - name: Stage Windows binaries shell: bash run: | - release_dir="target/${{ matrix.target }}/release" - output_dir="$release_dir/staged-${{ matrix.bundle }}" + output_dir="target/${{ matrix.target }}/release/staged-${{ matrix.bundle }}" mkdir -p "$output_dir" for binary in ${{ matrix.binaries }}; do - pdb_name="${binary//-/_}" - pdb_path="$release_dir/${pdb_name}.pdb" - if [[ ! -f "$pdb_path" ]]; then - pdb_path="$release_dir/${binary}.pdb" - fi - if [[ ! -f "$pdb_path" ]]; then - echo "PDB for $binary not found at $release_dir/${pdb_name}.pdb or $release_dir/${binary}.pdb" >&2 - exit 1 - fi - - cp "$release_dir/${binary}.exe" "$output_dir/${binary}.exe" - cp "$pdb_path" "$output_dir/${binary}.pdb" + cp "target/${{ matrix.target }}/release/${binary}.exe" "$output_dir/${binary}.exe" done - name: Upload Windows binaries @@ -232,23 +218,6 @@ jobs: account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - - name: Build symbols archive - shell: bash - run: | - bash "${GITHUB_WORKSPACE}/.github/scripts/archive-release-symbols-and-strip-binaries.sh" \ - --target "${{ matrix.target }}" \ - --artifact-name "${{ matrix.target }}" \ - --release-dir "target/${{ matrix.target }}/release" \ - --archive-dir "symbols-dist/${{ matrix.target }}" \ - --binaries "${WINDOWS_BINARIES}" - - - name: Upload symbols archive - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.target }}-symbols - path: codex-rs/symbols-dist/${{ matrix.target }}/* - if-no-files-found: error - - name: Stage artifacts shell: bash run: | diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index ec1b902e2a..55f95fa270 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -149,9 +149,6 @@ jobs: # 2026-03-04: temporarily change releases to use thin LTO because # Ubuntu ARM is timing out at 60 minutes. CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} - CARGO_PROFILE_RELEASE_DEBUG: full - CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO: ${{ contains(matrix.target, 'apple-darwin') && 'packed' || 'off' }} - CARGO_PROFILE_RELEASE_STRIP: "false" # Use the git CLI instead of Cargo's libgit2 path for git dependencies. # macOS release runners have intermittently failed to fetch nested # submodules through SecureTransport/libgit2, especially libwebrtc's @@ -252,7 +249,7 @@ jobs: run: | set -euo pipefail sudo apt-get update -y - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends binutils pkg-config libcap-dev + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: targets: ${{ matrix.target }} @@ -311,10 +308,6 @@ jobs: exit 1 fi - # Codex embeds this digest at build time and verifies the bundled - # bwrap resource before use. Strip bwrap before hashing so the digest - # covers the exact bytes that the release packages. - strip --strip-debug --strip-unneeded "$bwrap_path" digest="$(sha256sum "$bwrap_path" | awk '{print $1}')" echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV" echo "Built bwrap ${bwrap_path} with sha256:${digest}" @@ -328,11 +321,6 @@ jobs: fi build_args=() for binary in ${{ matrix.binaries }}; do - # bwrap was built, finalized, and hashed before this build so - # Codex can embed the digest of the bytes that will be packaged. - if [[ "$binary" == "bwrap" ]]; then - continue - fi build_args+=(--bin "$binary") done echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}" @@ -345,32 +333,6 @@ jobs: path: codex-rs/target/**/cargo-timings/cargo-timing.html if-no-files-found: warn - - name: Build symbols archive and strip binaries - shell: bash - run: | - binaries=() - for binary in ${{ matrix.binaries }}; do - # bwrap is already stripped before hashing. Its symbols are not - # useful enough to justify a separate pre-Codex symbols pass. - if [[ "$binary" == "bwrap" ]]; then - continue - fi - binaries+=("$binary") - done - bash "${GITHUB_WORKSPACE}/.github/scripts/archive-release-symbols-and-strip-binaries.sh" \ - --target "${{ matrix.target }}" \ - --artifact-name "${{ matrix.artifact_name }}" \ - --release-dir "target/${{ matrix.target }}/release" \ - --archive-dir "symbols-dist/${{ matrix.artifact_name }}" \ - --binaries "${binaries[*]}" - - - name: Upload symbols archive - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }}-symbols - path: codex-rs/symbols-dist/${{ matrix.artifact_name }}/* - if-no-files-found: error - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} name: Stage unsigned macOS artifacts shell: bash @@ -1068,7 +1030,6 @@ jobs: run: | find dist -mindepth 1 -maxdepth 1 -type d \ ! -name '*-apple-darwin*-unsigned' \ - ! -name '*-symbols' \ ! -name 'aarch64-unknown-linux-musl' \ ! -name 'aarch64-unknown-linux-musl-app-server' \ ! -name 'x86_64-unknown-linux-musl' \ From 4d37e04e81f8fd17e267bc47a02a965ef4934b69 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 3 Jun 2026 18:25:38 -0700 Subject: [PATCH 53/59] Load platform MITM CA roots --- codex-rs/Cargo.lock | 2 + codex-rs/network-proxy/Cargo.toml | 6 + codex-rs/network-proxy/src/certs.rs | 22 +- codex-rs/network-proxy/src/lib.rs | 1 + codex-rs/network-proxy/src/native_certs.rs | 262 +++++++++++++++++++++ 5 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 codex-rs/network-proxy/src/native_certs.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 94dac45699..4abd55339b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3373,6 +3373,8 @@ dependencies = [ "rama-tls-rustls", "rama-unix", "rustls-native-certs", + "schannel", + "security-framework 3.5.1", "serde", "serde_json", "sha2 0.10.9", diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml index a400038382..15ae9284c8 100644 --- a/codex-rs/network-proxy/Cargo.toml +++ b/codex-rs/network-proxy/Cargo.toml @@ -45,3 +45,9 @@ tempfile = { workspace = true } [target.'cfg(target_family = "unix")'.dependencies] rama-unix = { version = "=0.3.0-alpha.4" } + +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = "3" + +[target.'cfg(windows)'.dependencies] +schannel = "0.1" diff --git a/codex-rs/network-proxy/src/certs.rs b/codex-rs/network-proxy/src/certs.rs index 001469aa9a..f316e2c21f 100644 --- a/codex-rs/network-proxy/src/certs.rs +++ b/codex-rs/network-proxy/src/certs.rs @@ -166,7 +166,7 @@ fn managed_ca_trust_bundle_for_cert_path( fn build_managed_ca_trust_bundle(managed_ca_cert_path: &Path) -> Result { let mut trust_bundle = String::new(); let rustls_native_certs::CertificateResult { certs, errors, .. } = - rustls_native_certs::load_native_certs(); + crate::native_certs::load_platform_native_certs(); if !errors.is_empty() { warn!( native_root_error_count = errors.len(), @@ -521,6 +521,26 @@ mod tests { ); } + #[test] + fn managed_ca_trust_bundle_does_not_append_startup_ca_override_to_baseline() { + let dir = tempdir().unwrap(); + let managed_ca_cert_path = dir.path().join("ca.pem"); + let startup_ca_bundle_path = dir.path().join("startup-ca.pem"); + fs::write(&managed_ca_cert_path, "managed ca\n").unwrap(); + fs::write(&startup_ca_bundle_path, "startup ca\n").unwrap(); + let env = HashMap::from([( + "SSL_CERT_FILE", + startup_ca_bundle_path.display().to_string(), + )]); + + let trust_bundle = + managed_ca_trust_bundle_for_cert_path(&managed_ca_cert_path, &env).unwrap(); + let baseline_bundle = fs::read_to_string(trust_bundle.path).unwrap(); + + assert!(!baseline_bundle.contains("startup ca")); + assert!(baseline_bundle.contains("managed ca")); + } + #[cfg(unix)] #[test] fn validate_existing_ca_key_file_rejects_group_world_permissions() { diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 9ad16f8ecc..eb641b81d4 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -6,6 +6,7 @@ mod connect_policy; mod http_proxy; mod mitm; mod mitm_hook; +mod native_certs; mod network_policy; mod policy; mod proxy; diff --git a/codex-rs/network-proxy/src/native_certs.rs b/codex-rs/network-proxy/src/native_certs.rs new file mode 100644 index 0000000000..f4ef4a7b85 --- /dev/null +++ b/codex-rs/network-proxy/src/native_certs.rs @@ -0,0 +1,262 @@ +#[cfg(any(target_os = "macos", windows))] +use rama_tls_rustls::dep::pki_types::CertificateDer; +use rustls_native_certs::CertificateResult; +#[cfg(any(target_os = "macos", windows))] +use rustls_native_certs::Error; +#[cfg(any(target_os = "macos", windows))] +use rustls_native_certs::ErrorKind; + +// `rustls_native_certs::load_native_certs()` first consults SSL_CERT_FILE and +// SSL_CERT_DIR. The managed baseline must contain platform roots only; child +// CA overrides are copied later after the child filesystem policy can be checked. +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn load_platform_native_certs() -> CertificateResult { + let mut result = + rustls_native_certs::load_certs_from_paths(platform_cert_file().as_deref(), None); + for cert_dir in platform_cert_dirs() { + extend_certificate_result( + &mut result, + rustls_native_certs::load_certs_from_paths(None, Some(&cert_dir)), + ); + } + dedupe_certs(&mut result); + result +} + +#[cfg(target_os = "macos")] +pub(crate) fn load_platform_native_certs() -> CertificateResult { + use security_framework::trust_settings::Domain; + use security_framework::trust_settings::TrustSettings; + use security_framework::trust_settings::TrustSettingsForCertificate; + use std::collections::BTreeMap; + + let mut result = CertificateResult::default(); + let mut all_certs = BTreeMap::new(); + for domain in &[Domain::User, Domain::Admin, Domain::System] { + let ts = TrustSettings::new(*domain); + let iter = match ts.iter() { + Ok(iter) => iter, + Err(err) => { + result.errors.push(Error { + context: match domain { + Domain::User => "failed to load user trust settings", + Domain::Admin => "failed to load admin trust settings", + Domain::System => "failed to load system trust settings", + }, + kind: ErrorKind::Os(err.into()), + }); + continue; + } + }; + + for cert in iter { + let der = cert.to_der(); + let trusted = match ts.tls_trust_settings_for_certificate(&cert) { + Ok(Some(trusted)) => trusted, + Ok(None) if *domain == Domain::System => TrustSettingsForCertificate::TrustRoot, + Ok(None) => continue, + Err(err) => { + result.errors.push(Error { + context: "certificate not trusted", + kind: ErrorKind::Os(err.into()), + }); + continue; + } + }; + all_certs.entry(der).or_insert(trusted); + } + } + + for (der, trusted) in all_certs { + use TrustSettingsForCertificate::*; + + if let TrustRoot | TrustAsRoot = trusted { + result.certs.push(CertificateDer::from(der)); + } + } + result +} + +#[cfg(windows)] +pub(crate) fn load_platform_native_certs() -> CertificateResult { + use schannel::cert_store::CertStore; + + let mut result = CertificateResult::default(); + let current_user_store = match CertStore::open_current_user("ROOT") { + Ok(store) => store, + Err(err) => { + result.errors.push(Error { + context: "failed to open current user certificate store", + kind: ErrorKind::Os(err.into()), + }); + return result; + } + }; + + for cert in current_user_store.certs() { + let valid_uses = match cert.valid_uses() { + Ok(valid_uses) => valid_uses, + Err(err) => { + result.errors.push(Error { + context: "failed to inspect certificate valid uses", + kind: ErrorKind::Os(err.into()), + }); + continue; + } + }; + let is_time_valid = match cert.is_time_valid() { + Ok(is_time_valid) => is_time_valid, + Err(err) => { + result.errors.push(Error { + context: "failed to inspect certificate time validity", + kind: ErrorKind::Os(err.into()), + }); + continue; + } + }; + if usable_for_rustls(valid_uses) && is_time_valid { + result + .certs + .push(CertificateDer::from(cert.to_der().to_vec())); + } + } + result +} + +#[cfg(not(any(all(unix, not(target_os = "macos")), target_os = "macos", windows)))] +pub(crate) fn load_platform_native_certs() -> CertificateResult { + rustls_native_certs::load_native_certs() +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn extend_certificate_result(result: &mut CertificateResult, extra: CertificateResult) { + result.certs.extend(extra.certs); + result.errors.extend(extra.errors); +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn dedupe_certs(result: &mut CertificateResult) { + result.certs.sort_unstable_by(|a, b| a.cmp(b)); + result.certs.dedup(); +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn platform_cert_file() -> Option { + PLATFORM_CERTIFICATE_FILE_NAMES + .iter() + .map(std::path::Path::new) + .find(|path| path.exists()) + .map(std::path::Path::to_path_buf) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn platform_cert_dirs() -> impl Iterator { + PLATFORM_CERTIFICATE_DIRS + .iter() + .map(std::path::Path::new) + .filter(|path| path.exists()) + .map(std::path::Path::to_path_buf) +} + +#[cfg(all(unix, not(target_os = "macos"), target_os = "linux"))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &[ + "/etc/ssl/certs", + "/etc/pki/tls/certs", + "/etc/security/certificates", +]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "freebsd"))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/ssl/certs", "/usr/local/share/certs"]; + +#[cfg(all( + unix, + not(target_os = "macos"), + any(target_os = "illumos", target_os = "solaris") +))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/certs/CA"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "netbsd"))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/openssl/certs"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "aix"))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/var/ssl/certs"]; + +#[cfg(all( + unix, + not(target_os = "macos"), + not(any( + target_os = "linux", + target_os = "freebsd", + target_os = "illumos", + target_os = "solaris", + target_os = "netbsd", + target_os = "aix" + )) +))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/ssl/certs"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "linux"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &[ + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/ca-bundle.pem", + "/etc/pki/tls/cacert.pem", + "/etc/ssl/cert.pem", + "/opt/etc/ssl/certs/ca-certificates.crt", + "/etc/ssl/certs/cacert.pem", +]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "freebsd"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/usr/local/etc/ssl/cert.pem"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "dragonfly"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/usr/local/share/certs/ca-root-nss.crt"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "netbsd"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/openssl/certs/ca-certificates.crt"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "openbsd"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/ssl/cert.pem"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "solaris"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/certs/ca-certificates.crt"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "illumos"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = + &["/etc/ssl/cacert.pem", "/etc/certs/ca-certificates.crt"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "android"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = + &["/data/data/com.termux/files/usr/etc/tls/cert.pem"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "haiku"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/boot/system/data/ssl/CARootCertificates.pem"]; + +#[cfg(all( + unix, + not(target_os = "macos"), + not(any( + target_os = "linux", + target_os = "freebsd", + target_os = "dragonfly", + target_os = "netbsd", + target_os = "openbsd", + target_os = "solaris", + target_os = "illumos", + target_os = "android", + target_os = "haiku", + )) +))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/ssl/certs/ca-certificates.crt"]; + +#[cfg(windows)] +fn usable_for_rustls(uses: schannel::cert_context::ValidUses) -> bool { + match uses { + schannel::cert_context::ValidUses::All => true, + schannel::cert_context::ValidUses::Oids(strs) => strs.iter().any(|x| x == PKIX_SERVER_AUTH), + } +} + +#[cfg(windows)] +const PKIX_SERVER_AUTH: &str = "1.3.6.1.5.5.7.3.1"; From de3ce0862816bade1be27c499e904f8cf6fdc47c Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 3 Jun 2026 18:29:46 -0700 Subject: [PATCH 54/59] Materialize child MITM CA bundles --- codex-rs/Cargo.lock | 2 + codex-rs/network-proxy/Cargo.toml | 2 + codex-rs/network-proxy/src/certs.rs | 403 ++++++++++++++++++- codex-rs/network-proxy/src/child_ca.rs | 187 +++++++++ codex-rs/network-proxy/src/child_ca_tests.rs | 186 +++++++++ codex-rs/network-proxy/src/lib.rs | 3 + codex-rs/network-proxy/src/proxy.rs | 91 +++-- 7 files changed, 835 insertions(+), 39 deletions(-) create mode 100644 codex-rs/network-proxy/src/child_ca.rs create mode 100644 codex-rs/network-proxy/src/child_ca_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4abd55339b..eb6e599849 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3363,6 +3363,7 @@ dependencies = [ "codex-utils-home-dir", "codex-utils-rustls-provider", "globset", + "libc", "pretty_assertions", "rama-core", "rama-http", @@ -3384,6 +3385,7 @@ dependencies = [ "tokio", "tracing", "url", + "windows-sys 0.52.0", ] [[package]] diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml index 15ae9284c8..fd9ced3568 100644 --- a/codex-rs/network-proxy/Cargo.toml +++ b/codex-rs/network-proxy/Cargo.toml @@ -44,6 +44,7 @@ pretty_assertions = { workspace = true } tempfile = { workspace = true } [target.'cfg(target_family = "unix")'.dependencies] +libc = { workspace = true } rama-unix = { version = "=0.3.0-alpha.4" } [target.'cfg(target_os = "macos")'.dependencies] @@ -51,3 +52,4 @@ security-framework = "3" [target.'cfg(windows)'.dependencies] schannel = "0.1" +windows-sys = { version = "0.52", features = ["Win32_Storage_FileSystem"] } diff --git a/codex-rs/network-proxy/src/certs.rs b/codex-rs/network-proxy/src/certs.rs index f316e2c21f..fb2f6fd532 100644 --- a/codex-rs/network-proxy/src/certs.rs +++ b/codex-rs/network-proxy/src/certs.rs @@ -23,11 +23,27 @@ use rama_tls_rustls::server::TlsAcceptorData; use sha2::Digest as _; use sha2::Sha256; use std::collections::HashMap; +use std::ffi::OsStr; +#[cfg(windows)] +use std::ffi::OsString; use std::fs; use std::fs::File; use std::fs::OpenOptions; +use std::io::Read; use std::io::Write; use std::net::IpAddr; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::os::fd::AsRawFd; +#[cfg(target_os = "macos")] +use std::os::unix::ffi::OsStrExt; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; +#[cfg(windows)] +use std::os::windows::ffi::OsStringExt; +#[cfg(windows)] +use std::os::windows::fs::MetadataExt; +#[cfg(windows)] +use std::os::windows::io::AsRawHandle; use std::path::Path; use std::path::PathBuf; use std::time::SystemTime; @@ -101,12 +117,15 @@ const MANAGED_MITM_CA_DIR: &str = "proxy"; const MANAGED_MITM_CA_CERT: &str = "ca.pem"; const MANAGED_MITM_CA_KEY: &str = "ca.key"; const MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX: &str = "ca-bundle"; +const MAX_CUSTOM_CA_BUNDLE_BYTES: u64 = 4 * 1024 * 1024; +const SSL_CERT_FILE_ENV_KEY: &str = "SSL_CERT_FILE"; +pub const SSL_CERT_DIR_ENV_KEY: &str = "SSL_CERT_DIR"; // Best-effort compatibility set for common child toolchains that accept a CA bundle path. // This is intentionally curated rather than pretending to cover every TLS client. pub const CUSTOM_CA_ENV_KEYS: [&str; 10] = [ "CODEX_CA_CERTIFICATE", - "SSL_CERT_FILE", + SSL_CERT_FILE_ENV_KEY, "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "NODE_EXTRA_CA_CERTS", @@ -122,6 +141,7 @@ pub const CUSTOM_CA_ENV_KEYS: [&str; 10] = [ pub(crate) struct ManagedMitmCaTrustBundle { pub(crate) path: PathBuf, pub(crate) startup_env_values: HashMap<&'static str, String>, + pub(crate) startup_cwd: PathBuf, } fn managed_ca_paths() -> Result<(PathBuf, PathBuf)> { @@ -146,8 +166,11 @@ fn managed_ca_trust_bundle_for_cert_path( cert_path: &Path, env: &HashMap<&'static str, String>, ) -> Result { + let startup_cwd = + std::env::current_dir().context("failed to resolve startup cwd for managed MITM CA")?; let startup_env_values = CUSTOM_CA_ENV_KEYS .into_iter() + .chain(std::iter::once(SSL_CERT_DIR_ENV_KEY)) .filter_map(|key| { env.get(key) .filter(|value| !value.is_empty()) @@ -160,6 +183,7 @@ fn managed_ca_trust_bundle_for_cert_path( Ok(ManagedMitmCaTrustBundle { path, startup_env_values, + startup_cwd, }) } @@ -184,18 +208,23 @@ fn is_current_generated_trust_bundle_path(path: &Path, managed_ca_cert_path: &Pa let Some(proxy_dir) = managed_ca_cert_path.parent() else { return false; }; - let Some(file_name) = path.file_name().and_then(|file_name| file_name.to_str()) else { - return false; - }; - if path.parent() != Some(proxy_dir) - || !file_name.starts_with(MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX) - || !file_name.ends_with(".pem") - { + if !matches_generated_trust_bundle_path(path, proxy_dir) { return false; } let Ok(trust_bundle) = fs::read(path) else { return false; }; + let expected_hash = format!("{:x}", Sha256::digest(&trust_bundle)); + if path + .file_stem() + .and_then(OsStr::to_str) + .and_then(|file_stem| { + file_stem.strip_prefix(&format!("{MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX}-")) + }) + .is_none_or(|hash| hash != expected_hash) + { + return false; + } let Ok(managed_ca_cert) = fs::read(managed_ca_cert_path) else { return false; }; @@ -220,6 +249,128 @@ fn persist_managed_ca_trust_bundle( let proxy_dir = managed_ca_cert_path .parent() .ok_or_else(|| anyhow!("managed MITM CA cert path is missing a parent"))?; + persist_ca_trust_bundle(proxy_dir, trust_bundle) +} + +pub(crate) fn materialize_ca_trust_bundle_with_custom_ca( + managed_ca_trust_bundle: &ManagedMitmCaTrustBundle, + custom_ca_bundle: &str, +) -> Result { + let proxy_dir = managed_ca_trust_bundle + .path + .parent() + .ok_or_else(|| anyhow!("managed MITM CA trust bundle path is missing a parent"))?; + anyhow::ensure!( + custom_ca_bundle.len() as u64 <= MAX_CUSTOM_CA_BUNDLE_BYTES, + "custom CA bundle exceeds {MAX_CUSTOM_CA_BUNDLE_BYTES} bytes" + ); + + let mut trust_bundle = String::new(); + append_pem_contents(&mut trust_bundle, custom_ca_bundle); + append_pem_file(&mut trust_bundle, &managed_ca_trust_bundle.path)?; + persist_ca_trust_bundle(proxy_dir, &trust_bundle) +} + +pub(crate) fn read_custom_ca_bundle(path: &Path, can_read_path: F) -> Result +where + F: Fn(&Path) -> bool, +{ + anyhow::ensure!( + can_read_path(path), + "CA bundle {} is not readable by child policy", + path.display() + ); + let path = path + .canonicalize() + .with_context(|| format!("failed to resolve CA bundle {}", path.display()))?; + anyhow::ensure!( + can_read_path(&path), + "CA bundle {} is not readable by child policy", + path.display() + ); + let mut file = open_readonly_without_following_symlink(&path)?; + let metadata = file + .metadata() + .with_context(|| format!("failed to stat CA bundle {}", path.display()))?; + anyhow::ensure!( + metadata.is_file(), + "CA bundle {} must be a regular file", + path.display() + ); + anyhow::ensure!( + metadata.len() <= MAX_CUSTOM_CA_BUNDLE_BYTES, + "CA bundle {} exceeds {MAX_CUSTOM_CA_BUNDLE_BYTES} bytes", + path.display() + ); + let opened_path = opened_file_path(&path, &file)?; + anyhow::ensure!( + can_read_path(&opened_path), + "CA bundle {} is not readable by child policy", + opened_path.display() + ); + validate_opened_file_path(&path, &opened_path, &metadata)?; + + let mut bytes = Vec::with_capacity(metadata.len() as usize); + std::io::Read::by_ref(&mut file) + .take(MAX_CUSTOM_CA_BUNDLE_BYTES + 1) + .read_to_end(&mut bytes) + .with_context(|| format!("failed to read CA bundle {}", path.display()))?; + anyhow::ensure!( + bytes.len() as u64 <= MAX_CUSTOM_CA_BUNDLE_BYTES, + "CA bundle {} exceeds {MAX_CUSTOM_CA_BUNDLE_BYTES} bytes", + path.display() + ); + String::from_utf8(bytes) + .with_context(|| format!("CA bundle {} must be valid UTF-8", path.display())) +} + +pub(crate) fn read_custom_ca_dir(dir: &Path, can_read_path: F) -> Result +where + F: Fn(&Path) -> bool, +{ + anyhow::ensure!( + can_read_path(dir), + "CA directory {} is not readable by child policy", + dir.display() + ); + let dir = dir + .canonicalize() + .with_context(|| format!("failed to resolve CA directory {}", dir.display()))?; + anyhow::ensure!( + can_read_path(&dir), + "CA directory {} is not readable by child policy", + dir.display() + ); + anyhow::ensure!( + dir.metadata() + .with_context(|| format!("failed to stat CA directory {}", dir.display()))? + .is_dir(), + "CA directory {} must be a directory", + dir.display() + ); + + let mut trust_bundle = String::new(); + for entry in fs::read_dir(&dir) + .with_context(|| format!("failed to read CA directory {}", dir.display()))? + { + let entry = entry + .with_context(|| format!("failed to read CA directory entry in {}", dir.display()))?; + let path = entry.path(); + match read_custom_ca_bundle(&path, &can_read_path) { + Ok(contents) => append_bounded_pem_contents(&mut trust_bundle, &contents)?, + Err(err) => { + warn!( + ca_bundle_path = %path.display(), + "failed to read CA directory entry; skipping it: {err}" + ); + } + } + } + + Ok(trust_bundle) +} + +fn persist_ca_trust_bundle(proxy_dir: &Path, trust_bundle: &str) -> Result { fs::create_dir_all(proxy_dir) .with_context(|| format!("failed to create {}", proxy_dir.display()))?; let hash = Sha256::digest(trust_bundle.as_bytes()); @@ -240,16 +391,177 @@ fn persist_managed_ca_trust_bundle( Ok(trust_bundle_path) } +fn matches_generated_trust_bundle_path(path: &Path, proxy_dir: &Path) -> bool { + let Some(file_name) = path.file_name().and_then(|file_name| file_name.to_str()) else { + return false; + }; + path.parent() == Some(proxy_dir) + && file_name.starts_with(&format!("{MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX}-")) + && file_name.ends_with(".pem") +} + fn append_pem_file(bundle: &mut String, path: &Path) -> Result<()> { - if !bundle.ends_with('\n') { - bundle.push('\n'); - } let pem = fs::read_to_string(path) .with_context(|| format!("failed to read CA bundle {}", path.display()))?; - bundle.push_str(&pem); + append_pem_contents(bundle, &pem); + Ok(()) +} + +pub(crate) fn append_pem_contents(bundle: &mut String, pem: &str) { + if !bundle.is_empty() && !bundle.ends_with('\n') { + bundle.push('\n'); + } + bundle.push_str(pem); if !bundle.ends_with('\n') { bundle.push('\n'); } +} + +fn append_bounded_pem_contents(bundle: &mut String, pem: &str) -> Result<()> { + let separator_len = usize::from(!bundle.is_empty() && !bundle.ends_with('\n')); + let trailing_newline_len = usize::from(!pem.ends_with('\n')); + anyhow::ensure!( + (bundle.len() + separator_len + pem.len() + trailing_newline_len) as u64 + <= MAX_CUSTOM_CA_BUNDLE_BYTES, + "CA directory exceeds {MAX_CUSTOM_CA_BUNDLE_BYTES} bytes" + ); + append_pem_contents(bundle, pem); + Ok(()) +} + +fn open_readonly_without_following_symlink(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + options + .open(path) + .with_context(|| format!("failed to open CA bundle {}", path.display())) +} + +fn opened_file_path(path: &Path, _file: &File) -> Result { + #[cfg(target_os = "linux")] + { + let opened_path = fs::read_link(format!("/proc/self/fd/{}", _file.as_raw_fd())) + .with_context(|| format!("failed to resolve opened CA bundle {}", path.display()))?; + opened_path + .canonicalize() + .with_context(|| format!("failed to canonicalize opened CA bundle {}", path.display())) + } + + #[cfg(target_os = "macos")] + { + let mut opened_path = vec![0_u8; libc::PATH_MAX as usize]; + // SAFETY: fcntl writes at most PATH_MAX bytes into the provided writable buffer. + let result = + unsafe { libc::fcntl(_file.as_raw_fd(), libc::F_GETPATH, opened_path.as_mut_ptr()) }; + anyhow::ensure!( + result != -1, + "failed to resolve opened CA bundle {}: {}", + path.display(), + std::io::Error::last_os_error() + ); + let opened_path_len = opened_path + .iter() + .position(|byte| *byte == 0) + .unwrap_or(opened_path.len()); + PathBuf::from(OsStr::from_bytes(&opened_path[..opened_path_len])) + .canonicalize() + .with_context(|| format!("failed to canonicalize opened CA bundle {}", path.display())) + } + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + #[cfg(windows)] + { + use windows_sys::Win32::Storage::FileSystem::FILE_NAME_NORMALIZED; + use windows_sys::Win32::Storage::FileSystem::GetFinalPathNameByHandleW; + use windows_sys::Win32::Storage::FileSystem::VOLUME_NAME_DOS; + + let mut opened_path = vec![0_u16; 512]; + loop { + // SAFETY: `_file` owns a live OS handle and `opened_path` is writable for its + // declared capacity. + let length = unsafe { + GetFinalPathNameByHandleW( + _file.as_raw_handle() as _, + opened_path.as_mut_ptr(), + opened_path.len() as u32, + FILE_NAME_NORMALIZED | VOLUME_NAME_DOS, + ) + }; + anyhow::ensure!( + length != 0, + "failed to resolve opened CA bundle {}: {}", + path.display(), + std::io::Error::last_os_error() + ); + if length < opened_path.len() as u32 { + opened_path.truncate(length as usize); + break; + } + opened_path.resize(length as usize + 1, 0); + } + PathBuf::from(OsString::from_wide(&opened_path)) + .canonicalize() + .with_context(|| { + format!("failed to canonicalize opened CA bundle {}", path.display()) + }) + } + + #[cfg(not(windows))] + { + path.canonicalize() + .with_context(|| format!("failed to resolve CA bundle {}", path.display())) + } + } +} + +fn validate_opened_file_path( + path: &Path, + opened_path: &Path, + metadata: &fs::Metadata, +) -> Result<()> { + #[cfg(unix)] + { + let opened_path_metadata = fs::metadata(opened_path).with_context(|| { + format!("failed to stat opened CA bundle {}", opened_path.display()) + })?; + anyhow::ensure!( + metadata.dev() == opened_path_metadata.dev() + && metadata.ino() == opened_path_metadata.ino(), + "CA bundle {} changed before it could be validated", + path.display() + ); + } + + #[cfg(not(unix))] + { + #[cfg(windows)] + { + let opened_path_metadata = fs::metadata(opened_path).with_context(|| { + format!("failed to stat opened CA bundle {}", opened_path.display()) + })?; + anyhow::ensure!( + metadata.volume_serial_number() == opened_path_metadata.volume_serial_number() + && metadata.file_index() == opened_path_metadata.file_index(), + "CA bundle {} changed before it could be validated", + path.display() + ); + } + + #[cfg(not(windows))] + { + let _ = path; + let _ = opened_path; + let _ = metadata; + } + } + Ok(()) } @@ -507,17 +819,36 @@ mod tests { )); } + #[test] + fn current_generated_trust_bundle_path_rejects_hash_mismatch() { + let dir = tempdir().unwrap(); + let managed_ca_cert_path = dir.path().join("ca.pem"); + let trust_bundle_path = dir.path().join("ca-bundle-123.pem"); + fs::write(&managed_ca_cert_path, "managed ca\n").unwrap(); + fs::write(&trust_bundle_path, "custom ca\nmanaged ca\n").unwrap(); + assert!(!is_current_generated_trust_bundle_path( + &trust_bundle_path, + &managed_ca_cert_path, + )); + } + #[test] fn managed_ca_trust_bundle_records_startup_ca_env_values() { let dir = tempdir().unwrap(); let managed_ca_cert_path = dir.path().join("ca.pem"); fs::write(&managed_ca_cert_path, "managed ca\n").unwrap(); - let env = HashMap::from([("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string())]); + let env = HashMap::from([ + ("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string()), + (SSL_CERT_DIR_ENV_KEY, "/tmp/startup-certs".to_string()), + ]); let trust_bundle = managed_ca_trust_bundle_for_cert_path(&managed_ca_cert_path, &env).unwrap(); assert_eq!( trust_bundle.startup_env_values, - HashMap::from([("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string())]) + HashMap::from([ + ("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string()), + (SSL_CERT_DIR_ENV_KEY, "/tmp/startup-certs".to_string()), + ]) ); } @@ -541,6 +872,50 @@ mod tests { assert!(baseline_bundle.contains("managed ca")); } + #[test] + fn read_custom_ca_bundle_rejects_non_regular_file() { + let dir = tempdir().unwrap(); + + let err = read_custom_ca_bundle(dir.path(), |_| true).unwrap_err(); + + assert!( + err.to_string().contains("must be a regular file") + || err.to_string().contains("failed to open CA bundle"), + "unexpected error: {err:#}" + ); + } + + #[cfg(unix)] + #[test] + fn read_custom_ca_bundle_reads_readable_symlink() { + let dir = tempdir().unwrap(); + let ca_bundle_path = dir.path().join("ca.pem"); + let symlink_path = dir.path().join("ca-link.pem"); + fs::write(&ca_bundle_path, "custom ca\n").unwrap(); + std::os::unix::fs::symlink(&ca_bundle_path, &symlink_path).unwrap(); + + assert_eq!( + read_custom_ca_bundle(&symlink_path, |_| true).unwrap(), + "custom ca\n" + ); + } + + #[cfg(windows)] + #[test] + fn opened_file_path_uses_windows_handle_target() { + let dir = tempdir().unwrap(); + let checked_path = dir.path().join("checked.pem"); + let opened_path = dir.path().join("opened.pem"); + fs::write(&checked_path, "checked ca\n").unwrap(); + fs::write(&opened_path, "opened ca\n").unwrap(); + let file = File::open(&opened_path).unwrap(); + + assert_eq!( + opened_file_path(&checked_path, &file).unwrap(), + opened_path.canonicalize().unwrap() + ); + } + #[cfg(unix)] #[test] fn validate_existing_ca_key_file_rejects_group_world_permissions() { diff --git a/codex-rs/network-proxy/src/child_ca.rs b/codex-rs/network-proxy/src/child_ca.rs new file mode 100644 index 0000000000..66f5c54f3d --- /dev/null +++ b/codex-rs/network-proxy/src/child_ca.rs @@ -0,0 +1,187 @@ +use crate::certs::ManagedMitmCaTrustBundle; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::HashMap; +use std::path::Path; +use tracing::warn; + +pub(crate) fn prepare_mitm_ca_trust_bundle_env( + mitm_ca_trust_bundle: &ManagedMitmCaTrustBundle, + env: &mut HashMap, + cwd: &Path, + startup_ca_env_keys_present_in_child: &[&'static str], + can_read_path: F, +) -> Vec +where + F: Fn(&Path) -> bool, +{ + let ssl_cert_dir_contents = read_child_ca_dir_contents( + mitm_ca_trust_bundle, + env, + cwd, + startup_ca_env_keys_present_in_child, + &can_read_path, + ); + // Fold SSL_CERT_DIR into SSL_CERT_FILE so children cannot consult an + // unmaterialized CA directory after preparation. + env.remove(crate::certs::SSL_CERT_DIR_ENV_KEY); + let mut materialized_ca_trust_bundle_paths = Vec::new(); + for key in crate::certs::CUSTOM_CA_ENV_KEYS { + let Some(value) = env.get(key).filter(|value| !value.is_empty()) else { + continue; + }; + let mut custom_ca_bundle = read_child_ca_bundle_contents( + mitm_ca_trust_bundle, + key, + value, + cwd, + startup_ca_env_keys_present_in_child, + &can_read_path, + ) + .unwrap_or_default(); + if key == "SSL_CERT_FILE" + && let Some(ssl_cert_dir_contents) = ssl_cert_dir_contents.as_deref() + { + crate::certs::append_pem_contents(&mut custom_ca_bundle, ssl_cert_dir_contents); + } + if custom_ca_bundle.is_empty() { + continue; + } + + match crate::certs::materialize_ca_trust_bundle_with_custom_ca( + mitm_ca_trust_bundle, + &custom_ca_bundle, + ) { + Ok(path) => { + env.insert(key.to_string(), path.to_string_lossy().into_owned()); + materialized_ca_trust_bundle_paths.push(path); + } + Err(err) => { + warn!( + ca_env_key = key, + "failed to materialize child MITM CA bundle; leaving current value unchanged: {err}" + ); + } + } + } + + managed_mitm_ca_trust_bundle_paths_for_env( + mitm_ca_trust_bundle, + env, + &materialized_ca_trust_bundle_paths, + ) +} + +fn resolve_ca_bundle_path(path: &str, cwd: &Path) -> std::path::PathBuf { + let path = Path::new(path); + if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + } +} + +fn read_child_ca_bundle_contents( + mitm_ca_trust_bundle: &ManagedMitmCaTrustBundle, + key: &'static str, + value: &str, + cwd: &Path, + startup_ca_env_keys_present_in_child: &[&'static str], + can_read_path: &F, +) -> Option +where + F: Fn(&Path) -> bool, +{ + let value_path = Path::new(value); + let custom_ca_bundle_path = if startup_ca_env_keys_present_in_child.contains(&key) { + let startup_value = mitm_ca_trust_bundle.startup_env_values.get(key)?; + resolve_ca_bundle_path(startup_value, &mitm_ca_trust_bundle.startup_cwd) + } else if value_path == mitm_ca_trust_bundle.path { + return None; + } else { + resolve_ca_bundle_path(value, cwd) + }; + match crate::certs::read_custom_ca_bundle(&custom_ca_bundle_path, can_read_path) { + Ok(contents) => Some(contents), + Err(err) => { + warn!( + ca_env_key = key, + ca_bundle_path = %custom_ca_bundle_path.display(), + "failed to read child MITM CA bundle; leaving current value unchanged: {err}" + ); + None + } + } +} + +fn read_child_ca_dir_contents( + mitm_ca_trust_bundle: &ManagedMitmCaTrustBundle, + env: &HashMap, + cwd: &Path, + startup_ca_env_keys_present_in_child: &[&'static str], + can_read_path: &F, +) -> Option +where + F: Fn(&Path) -> bool, +{ + let value = env + .get(crate::certs::SSL_CERT_DIR_ENV_KEY) + .filter(|value| !value.is_empty())?; + let ca_dir_cwd = + if startup_ca_env_keys_present_in_child.contains(&crate::certs::SSL_CERT_DIR_ENV_KEY) { + &mitm_ca_trust_bundle.startup_cwd + } else { + cwd + }; + let mut trust_bundle = String::new(); + for ca_dir_path in std::env::split_paths(value).map(|path| { + if path.is_absolute() { + path + } else { + ca_dir_cwd.join(path) + } + }) { + match crate::certs::read_custom_ca_dir(&ca_dir_path, can_read_path) { + Ok(contents) if !contents.is_empty() => { + crate::certs::append_pem_contents(&mut trust_bundle, &contents); + } + Ok(_) => {} + Err(err) => { + warn!( + ca_bundle_path = %ca_dir_path.display(), + "failed to read child MITM CA directory; skipping it: {err}" + ); + } + } + } + if trust_bundle.is_empty() { + None + } else { + Some(trust_bundle) + } +} + +fn managed_mitm_ca_trust_bundle_paths_for_env( + mitm_ca_trust_bundle: &ManagedMitmCaTrustBundle, + env: &HashMap, + materialized_ca_trust_bundle_paths: &[std::path::PathBuf], +) -> Vec { + let mut paths = crate::certs::CUSTOM_CA_ENV_KEYS + .into_iter() + .filter_map(|key| env.get(key)) + .map(Path::new) + .filter(|path| { + *path == mitm_ca_trust_bundle.path + || materialized_ca_trust_bundle_paths + .iter() + .any(|materialized_path| path == materialized_path) + }) + .filter_map(|path| AbsolutePathBuf::from_absolute_path(path).ok()) + .collect::>(); + paths.sort(); + paths.dedup(); + paths +} + +#[cfg(test)] +#[path = "child_ca_tests.rs"] +mod tests; diff --git a/codex-rs/network-proxy/src/child_ca_tests.rs b/codex-rs/network-proxy/src/child_ca_tests.rs new file mode 100644 index 0000000000..de1ae98b50 --- /dev/null +++ b/codex-rs/network-proxy/src/child_ca_tests.rs @@ -0,0 +1,186 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::fs; +use std::path::Path; +use tempfile::tempdir; + +const REQUESTS_CA_BUNDLE_ENV_KEY: &str = "REQUESTS_CA_BUNDLE"; + +fn test_mitm_ca_trust_bundle( + dir: &tempfile::TempDir, + startup_env_values: HashMap<&'static str, String>, +) -> ManagedMitmCaTrustBundle { + let path = dir.path().join("ca-bundle.pem"); + fs::write(&path, "managed ca\n").unwrap(); + ManagedMitmCaTrustBundle { + path, + startup_env_values, + startup_cwd: dir.path().to_path_buf(), + } +} + +fn requests_ca_bundle_env(value: impl Into) -> HashMap { + HashMap::from([(REQUESTS_CA_BUNDLE_ENV_KEY.to_string(), value.into())]) +} + +fn requests_ca_bundle_contents(env: &HashMap) -> String { + fs::read_to_string(Path::new( + env.get(REQUESTS_CA_BUNDLE_ENV_KEY) + .expect("REQUESTS_CA_BUNDLE should be set"), + )) + .unwrap() +} + +#[test] +fn materializes_readable_startup_ca_override() { + let dir = tempdir().unwrap(); + let startup_ca_bundle_path = dir.path().join("startup-ca.pem"); + let command_cwd = dir.path().join("command-cwd"); + fs::create_dir(&command_cwd).unwrap(); + fs::write(&startup_ca_bundle_path, "startup ca\n").unwrap(); + let mitm_ca_trust_bundle = test_mitm_ca_trust_bundle( + &dir, + HashMap::from([(REQUESTS_CA_BUNDLE_ENV_KEY, "startup-ca.pem".to_string())]), + ); + let mut env = requests_ca_bundle_env("startup-ca.pem"); + + let bundle_paths = prepare_mitm_ca_trust_bundle_env( + &mitm_ca_trust_bundle, + &mut env, + &command_cwd, + &[REQUESTS_CA_BUNDLE_ENV_KEY], + |_| true, + ); + + assert_eq!( + requests_ca_bundle_contents(&env), + "startup ca\nmanaged ca\n" + ); + assert_eq!(bundle_paths.len(), 1); +} + +#[test] +fn does_not_restore_filtered_startup_override() { + let dir = tempdir().unwrap(); + let mitm_ca_trust_bundle = test_mitm_ca_trust_bundle( + &dir, + HashMap::from([(REQUESTS_CA_BUNDLE_ENV_KEY, "startup-ca.pem".to_string())]), + ); + let mut env = requests_ca_bundle_env(mitm_ca_trust_bundle.path.display().to_string()); + + let bundle_paths = + prepare_mitm_ca_trust_bundle_env(&mitm_ca_trust_bundle, &mut env, dir.path(), &[], |_| { + true + }); + + assert_eq!( + env.get(REQUESTS_CA_BUNDLE_ENV_KEY), + Some(&mitm_ca_trust_bundle.path.display().to_string()) + ); + assert_eq!(bundle_paths.len(), 1); +} + +#[test] +fn materializes_readable_command_scoped_override() { + let dir = tempdir().unwrap(); + let command_ca_bundle_path = dir.path().join("command-ca.pem"); + fs::write(&command_ca_bundle_path, "command ca\n").unwrap(); + let mut env = requests_ca_bundle_env("command-ca.pem"); + let mitm_ca_trust_bundle = test_mitm_ca_trust_bundle(&dir, HashMap::new()); + + prepare_mitm_ca_trust_bundle_env(&mitm_ca_trust_bundle, &mut env, dir.path(), &[], |_| true); + + assert_eq!( + requests_ca_bundle_contents(&env), + "command ca\nmanaged ca\n" + ); +} + +#[test] +fn materializes_readable_ssl_cert_dir() { + let dir = tempdir().unwrap(); + let ssl_cert_dir_paths = [dir.path().join("certs-a"), dir.path().join("certs-b")]; + for (path, contents) in ssl_cert_dir_paths.iter().zip(["dir ca a\n", "dir ca b\n"]) { + fs::create_dir(path).unwrap(); + fs::write(path.join("ordinary-ca.pem"), contents).unwrap(); + } + let mitm_ca_trust_bundle_path = dir.path().join("ca-bundle.pem"); + fs::write(&mitm_ca_trust_bundle_path, "managed ca\n").unwrap(); + let ssl_cert_dir = std::env::join_paths(["certs-a", "certs-b"]).unwrap(); + let mut env = HashMap::from([ + ( + "SSL_CERT_FILE".to_string(), + mitm_ca_trust_bundle_path.display().to_string(), + ), + ( + crate::certs::SSL_CERT_DIR_ENV_KEY.to_string(), + ssl_cert_dir.to_string_lossy().into_owned(), + ), + ]); + let mitm_ca_trust_bundle = ManagedMitmCaTrustBundle { + path: mitm_ca_trust_bundle_path, + startup_env_values: HashMap::from([( + crate::certs::SSL_CERT_DIR_ENV_KEY, + ssl_cert_dir.to_string_lossy().into_owned(), + )]), + startup_cwd: dir.path().to_path_buf(), + }; + + prepare_mitm_ca_trust_bundle_env( + &mitm_ca_trust_bundle, + &mut env, + dir.path(), + &[crate::certs::SSL_CERT_DIR_ENV_KEY], + |_| true, + ); + + let ssl_cert_file_path = Path::new( + env.get("SSL_CERT_FILE") + .expect("SSL_CERT_FILE should be set"), + ); + assert_eq!( + fs::read_to_string(ssl_cert_file_path).unwrap(), + "dir ca a\ndir ca b\nmanaged ca\n" + ); + assert_eq!(env.get(crate::certs::SSL_CERT_DIR_ENV_KEY), None); +} + +#[test] +fn preserves_unreadable_command_scoped_override() { + let dir = tempdir().unwrap(); + let command_ca_bundle_path = dir.path().join("command-ca.pem"); + fs::write(&command_ca_bundle_path, "command ca\n").unwrap(); + let mut env = requests_ca_bundle_env("command-ca.pem"); + let mitm_ca_trust_bundle = test_mitm_ca_trust_bundle(&dir, HashMap::new()); + + let bundle_paths = + prepare_mitm_ca_trust_bundle_env(&mitm_ca_trust_bundle, &mut env, dir.path(), &[], |_| { + false + }); + + assert_eq!( + env.get(REQUESTS_CA_BUNDLE_ENV_KEY), + Some(&"command-ca.pem".to_string()) + ); + assert!(bundle_paths.is_empty()); +} + +#[test] +fn does_not_whitelist_existing_generated_bundle_override() { + let dir = tempdir().unwrap(); + let generated_ca_bundle_path = dir.path().join("ca-bundle-handcrafted.pem"); + fs::write(&generated_ca_bundle_path, "extra ca\nmanaged ca\n").unwrap(); + let mut env = requests_ca_bundle_env(generated_ca_bundle_path.display().to_string()); + let mitm_ca_trust_bundle = test_mitm_ca_trust_bundle(&dir, HashMap::new()); + + let bundle_paths = + prepare_mitm_ca_trust_bundle_env(&mitm_ca_trust_bundle, &mut env, dir.path(), &[], |_| { + false + }); + + assert_eq!( + env.get(REQUESTS_CA_BUNDLE_ENV_KEY), + Some(&generated_ca_bundle_path.display().to_string()) + ); + assert!(bundle_paths.is_empty()); +} diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index eb641b81d4..caf7165510 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -1,6 +1,7 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] mod certs; +mod child_ca; mod config; mod connect_policy; mod http_proxy; @@ -18,6 +19,7 @@ mod state; mod upstream; pub use certs::CUSTOM_CA_ENV_KEYS; +pub use certs::SSL_CERT_DIR_ENV_KEY; pub use certs::is_managed_mitm_ca_trust_bundle_path; pub use config::NetworkDomainPermission; pub use config::NetworkDomainPermissionEntry; @@ -46,6 +48,7 @@ pub use proxy::Args; #[cfg(target_os = "macos")] pub use proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER; pub use proxy::DEFAULT_NO_PROXY_VALUE; +pub use proxy::MITM_CA_ENV_ACTIVE_ENV_KEY; pub use proxy::NO_PROXY_ENV_KEYS; pub use proxy::NetworkProxy; pub use proxy::NetworkProxyBuilder; diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index c3685a4310..df49774b06 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -13,6 +13,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; use std::net::SocketAddr; use std::net::TcpListener as StdTcpListener; +use std::path::Path; use std::sync::Arc; use std::sync::Mutex; use std::sync::RwLock; @@ -308,6 +309,7 @@ impl NetworkProxyRuntimeSettings { let mitm_ca_trust_bundle = if config.network.mitm { let env = crate::certs::CUSTOM_CA_ENV_KEYS .into_iter() + .chain(std::iter::once(crate::certs::SSL_CERT_DIR_ENV_KEY)) .filter_map(|key| std::env::var(key).ok().map(|value| (key, value))) .collect(); Some(crate::certs::managed_ca_trust_bundle(&env)?) @@ -376,6 +378,7 @@ pub const PROXY_URL_ENV_KEYS: &[&str] = &[ pub const ALL_PROXY_ENV_KEYS: &[&str] = &["ALL_PROXY", "all_proxy"]; pub const PROXY_ACTIVE_ENV_KEY: &str = "CODEX_NETWORK_PROXY_ACTIVE"; +pub const MITM_CA_ENV_ACTIVE_ENV_KEY: &str = "CODEX_NETWORK_PROXY_MITM_CA_ENV_ACTIVE"; pub const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING"; const ELECTRON_GET_USE_PROXY_ENV_KEY: &str = "ELECTRON_GET_USE_PROXY"; const NODE_USE_ENV_PROXY_ENV_KEY: &str = "NODE_USE_ENV_PROXY"; @@ -383,6 +386,7 @@ const NODE_USE_ENV_PROXY_ENV_KEY: &str = "NODE_USE_ENV_PROXY"; const GIT_SSH_COMMAND_ENV_KEY: &str = "GIT_SSH_COMMAND"; pub const PROXY_ENV_KEYS: &[&str] = &[ PROXY_ACTIVE_ENV_KEY, + MITM_CA_ENV_ACTIVE_ENV_KEY, ALLOW_LOCAL_BINDING_ENV_KEY, ELECTRON_GET_USE_PROXY_ENV_KEY, NODE_USE_ENV_PROXY_ENV_KEY, @@ -571,24 +575,23 @@ fn apply_proxy_env_overrides( } if let Some(mitm_ca_trust_bundle) = mitm_ca_trust_bundle { + env.insert(MITM_CA_ENV_ACTIVE_ENV_KEY.to_string(), "1".to_string()); let managed_path = mitm_ca_trust_bundle.path.to_string_lossy().into_owned(); for key in crate::certs::CUSTOM_CA_ENV_KEYS { if env .get(key) .filter(|value| !value.is_empty()) - .is_some_and(|value| { - value != &managed_path - && mitm_ca_trust_bundle.startup_env_values.get(key) != Some(value) - }) + .is_some_and(|value| value != &managed_path) { - // TODO(winston): Materialize policy-checked per-child bundles for readable - // startup and command-scoped CA overrides. For now startup overrides are - // replaced with the default bundle and later command-scoped overrides are - // preserved, either of which can make intercepted TLS fail. + // Child-scoped overrides, including inherited startup values, need the + // effective filesystem policy before we can combine them with the managed CA + // bundle, so leave them for prepare_child_env(). continue; } env.insert(key.to_string(), managed_path.clone()); } + } else { + env.remove(MITM_CA_ENV_ACTIVE_ENV_KEY); } } @@ -629,17 +632,6 @@ impl NetworkProxy { self.runtime_settings().dangerously_allow_all_unix_sockets } - /// Returns the generated MITM CA bundle path child sandboxes should expose to TLS clients. - pub fn managed_mitm_ca_trust_bundle_path(&self) -> Option { - self.runtime_settings() - .mitm_ca_trust_bundle - .and_then(|bundle| { - AbsolutePathBuf::from_absolute_path(bundle.path) - .map_err(|err| warn!("managed MITM CA trust bundle path is invalid: {err}")) - .ok() - }) - } - pub fn apply_to_env(&self, env: &mut HashMap) { let runtime_settings = self.runtime_settings(); // Enforce proxying for child processes. Proxy endpoint values are always rewritten; @@ -654,6 +646,51 @@ impl NetworkProxy { ); } + /// Rewrites readable child-selected CA bundles into immutable managed MITM bundles. + pub fn prepare_child_env( + &self, + env: &mut HashMap, + cwd: &Path, + can_read_path: F, + ) -> Vec + where + F: Fn(&Path) -> bool, + { + let runtime_settings = self.runtime_settings(); + let startup_ca_env_keys_present_in_child = runtime_settings + .mitm_ca_trust_bundle + .as_ref() + .map_or_else(Vec::new, |mitm_ca_trust_bundle| { + mitm_ca_trust_bundle + .startup_env_values + .iter() + .filter_map(|(&key, startup_value)| { + (env.get(key) == Some(startup_value)).then_some(key) + }) + .collect() + }); + apply_proxy_env_overrides( + env, + self.http_addr, + self.socks_addr, + self.socks_enabled, + runtime_settings.allow_local_binding, + runtime_settings.mitm_ca_trust_bundle.as_ref(), + ); + runtime_settings.mitm_ca_trust_bundle.as_ref().map_or_else( + Vec::new, + |mitm_ca_trust_bundle| { + crate::child_ca::prepare_mitm_ca_trust_bundle_env( + mitm_ca_trust_bundle, + env, + cwd, + &startup_ca_env_keys_present_in_child, + can_read_path, + ) + }, + ) + } + pub async fn replace_config_state(&self, new_state: ConfigState) -> Result<()> { let current_cfg = self.state.current_cfg().await?; anyhow::ensure!( @@ -1110,6 +1147,7 @@ mod tests { let mitm_ca_trust_bundle = crate::certs::ManagedMitmCaTrustBundle { path: mitm_ca_trust_bundle_path.to_path_buf(), startup_env_values: HashMap::new(), + startup_cwd: Path::new("/tmp").to_path_buf(), }; apply_proxy_env_overrides( &mut env, @@ -1129,18 +1167,21 @@ mod tests { } #[test] - fn apply_proxy_env_overrides_preserves_command_scoped_mitm_ca_override() { - let command_ca_bundle_path = "/tmp/command-ca.pem".to_string(); + fn apply_proxy_env_overrides_preserves_startup_mitm_ca_override() { + let startup_ca_bundle_path = "/tmp/startup-ca.pem".to_string(); let mut env = HashMap::from([( "REQUESTS_CA_BUNDLE".to_string(), - command_ca_bundle_path.clone(), + startup_ca_bundle_path.clone(), )]); let mitm_ca_trust_bundle_path = Path::new("/tmp/codex-proxy/ca-bundle.pem"); let mitm_ca_trust_bundle = crate::certs::ManagedMitmCaTrustBundle { path: mitm_ca_trust_bundle_path.to_path_buf(), - startup_env_values: HashMap::new(), + startup_env_values: HashMap::from([( + "REQUESTS_CA_BUNDLE", + startup_ca_bundle_path.clone(), + )]), + startup_cwd: Path::new("/tmp").to_path_buf(), }; - apply_proxy_env_overrides( &mut env, SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128), @@ -1150,7 +1191,7 @@ mod tests { Some(&mitm_ca_trust_bundle), ); - assert_eq!(env.get("REQUESTS_CA_BUNDLE"), Some(&command_ca_bundle_path)); + assert_eq!(env.get("REQUESTS_CA_BUNDLE"), Some(&startup_ca_bundle_path)); assert_eq!( env.get("SSL_CERT_FILE"), Some(&mitm_ca_trust_bundle_path.display().to_string()) From 58afe6e8c75e1185f31119248342db3b10d5d94a Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 3 Jun 2026 18:46:10 -0700 Subject: [PATCH 55/59] Prepare managed child MITM CA env --- codex-rs/cli/src/debug_sandbox.rs | 23 ++--- codex-rs/core/src/tools/runtimes/mod.rs | 13 ++- codex-rs/core/src/tools/runtimes/mod_tests.rs | 83 +++++++++++++++++++ codex-rs/sandboxing/src/lib.rs | 2 +- codex-rs/sandboxing/src/manager.rs | 60 +++++++++++--- codex-rs/sandboxing/src/manager_tests.rs | 46 +++++++++- 6 files changed, 193 insertions(+), 34 deletions(-) diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 6d59bf7d23..44e9ea8310 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -19,11 +19,11 @@ use codex_protocol::config_types::SandboxMode; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_sandboxing::landlock::allow_network_for_proxy; use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_permission_profile; +use codex_sandboxing::prepare_managed_network_child; #[cfg(target_os = "macos")] use codex_sandboxing::seatbelt::CreateSeatbeltCommandArgsParams; #[cfg(target_os = "macos")] use codex_sandboxing::seatbelt::create_seatbelt_command_args; -use codex_sandboxing::with_managed_mitm_ca_readable_root; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; use tokio::process::Child; @@ -216,7 +216,7 @@ async fn run_command_under_sandbox( #[cfg(target_os = "windows")] let workspace_roots = config.effective_workspace_roots(); - let env = create_env( + let mut env = create_env( &config.permissions.shell_environment_policy, /*thread_id*/ None, ); @@ -258,13 +258,11 @@ async fn run_command_under_sandbox( let network = network_proxy .as_ref() .map(codex_core::config::StartedNetworkProxy::proxy); - let managed_mitm_ca_trust_bundle_path = match network.as_ref() { - Some(network) => network.managed_mitm_ca_trust_bundle_path(), - None => None, - }; - let runtime_permission_profile = with_managed_mitm_ca_readable_root( + let runtime_permission_profile = prepare_managed_network_child( + network.as_ref(), + &mut env, + cwd.as_path(), config.permissions.effective_permission_profile(), - managed_mitm_ca_trust_bundle_path.as_ref(), sandbox_policy_cwd.as_path(), ); @@ -291,9 +289,6 @@ async fn run_command_under_sandbox( env, |env_map| { env_map.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); - if let Some(network) = network.as_ref() { - network.apply_to_env(env_map); - } }, ) .await? @@ -320,11 +315,7 @@ async fn run_command_under_sandbox( cwd.to_path_buf(), network_sandbox_policy, env, - |env_map| { - if let Some(network) = network.as_ref() { - network.apply_to_env(env_map); - } - }, + |_env_map| {}, ) .await? } diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index b237f44c40..ae1090c5b4 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -13,10 +13,12 @@ use crate::tools::sandboxing::ToolError; #[cfg(target_os = "macos")] use codex_network_proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER; use codex_network_proxy::CUSTOM_CA_ENV_KEYS; +use codex_network_proxy::MITM_CA_ENV_ACTIVE_ENV_KEY; use codex_network_proxy::PROXY_ACTIVE_ENV_KEY; use codex_network_proxy::PROXY_ENV_KEYS; #[cfg(target_os = "macos")] use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY; +use codex_network_proxy::SSL_CERT_DIR_ENV_KEY; use codex_network_proxy::is_managed_mitm_ca_trust_bundle_path; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; @@ -258,10 +260,17 @@ fn build_proxy_env_exports() -> (String, String) { let (captures, restores) = build_override_exports_for_keys("__CODEX_SNAPSHOT_PROXY_OVERRIDE", &keys); let key = PROXY_ACTIVE_ENV_KEY; + let (ssl_cert_dir_captures, ssl_cert_dir_restores) = build_override_exports_for_keys( + "__CODEX_SNAPSHOT_MITM_CA_OVERRIDE", + &[SSL_CERT_DIR_ENV_KEY], + ); + let mitm_ca_key = MITM_CA_ENV_ACTIVE_ENV_KEY; let proxy_blocks = ( - format!("{captures}\n__CODEX_SNAPSHOT_PROXY_ENV_SET=\"${{{key}+x}}\""), format!( - "if [ -n \"$__CODEX_SNAPSHOT_PROXY_ENV_SET\" ] || [ -n \"${{{key}+x}}\" ]; then\n{restores}\nfi" + "{captures}\n__CODEX_SNAPSHOT_PROXY_ENV_SET=\"${{{key}+x}}\"\n{ssl_cert_dir_captures}\n__CODEX_SNAPSHOT_MITM_CA_ENV_SET=\"${{{mitm_ca_key}+x}}\"" + ), + format!( + "if [ -n \"$__CODEX_SNAPSHOT_PROXY_ENV_SET\" ] || [ -n \"${{{key}+x}}\" ]; then\n{restores}\nfi\nif [ -n \"$__CODEX_SNAPSHOT_MITM_CA_ENV_SET\" ] || [ -n \"${{{mitm_ca_key}+x}}\" ]; then\n{ssl_cert_dir_restores}\nfi" ), ); let git_blocks = build_codex_proxy_git_ssh_command_exports(); diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index 369f301cd7..a0316d13f4 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -11,6 +11,7 @@ use codex_network_proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER; use codex_network_proxy::CUSTOM_CA_ENV_KEYS; use codex_network_proxy::ConfigReloader; use codex_network_proxy::ConfigState; +use codex_network_proxy::MITM_CA_ENV_ACTIVE_ENV_KEY; use codex_network_proxy::NetworkProxy; use codex_network_proxy::NetworkProxyConfig; use codex_network_proxy::NetworkProxyConstraints; @@ -19,6 +20,7 @@ use codex_network_proxy::PROXY_ACTIVE_ENV_KEY; use codex_network_proxy::PROXY_ENV_KEYS; #[cfg(target_os = "macos")] use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY; +use codex_network_proxy::SSL_CERT_DIR_ENV_KEY; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; use codex_sandboxing::SandboxManager; @@ -576,6 +578,87 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_proxy_env_from_process_env() { ); } +#[test] +fn maybe_wrap_shell_lc_with_snapshot_clears_snapshot_ssl_cert_dir() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + format!("# Snapshot file\nexport {SSL_CERT_DIR_ENV_KEY}='/tmp/snapshot-certs'\n"), + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path.abs(), + dir.path().abs(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + format!( + "if [ \"${{{SSL_CERT_DIR_ENV_KEY}+x}}\" = x ]; then printf 'set'; else printf 'unset'; fi" + ), + ]; + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + &dir.path().abs(), + &HashMap::new(), + &HashMap::new(), + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env(MITM_CA_ENV_ACTIVE_ENV_KEY, "1") + .env_remove(SSL_CERT_DIR_ENV_KEY) + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!(String::from_utf8_lossy(&output.stdout), "unset"); +} + +#[test] +fn maybe_wrap_shell_lc_with_snapshot_preserves_snapshot_ssl_cert_dir_without_mitm() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + format!("# Snapshot file\nexport {SSL_CERT_DIR_ENV_KEY}='/tmp/snapshot-certs'\n"), + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path.abs(), + dir.path().abs(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + format!("printf '%s' \"${{{SSL_CERT_DIR_ENV_KEY}}}\""), + ]; + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + &dir.path().abs(), + &HashMap::new(), + &HashMap::new(), + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env(PROXY_ACTIVE_ENV_KEY, "1") + .env_remove(SSL_CERT_DIR_ENV_KEY) + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "/tmp/snapshot-certs" + ); +} + #[cfg(target_os = "macos")] #[test] fn maybe_wrap_shell_lc_with_snapshot_refreshes_codex_proxy_git_ssh_command() { diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index 2dda65f9d5..99cd521fb9 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -19,7 +19,7 @@ pub use manager::SandboxType; pub use manager::SandboxablePreference; pub use manager::compatibility_sandbox_policy_for_permission_profile; pub use manager::get_platform_sandbox; -pub use manager::with_managed_mitm_ca_readable_root; +pub use manager::prepare_managed_network_child; use codex_protocol::error::CodexErr; diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index bd8f6d9b98..0e9b20a63b 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -13,6 +13,7 @@ use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::permissions::ReadDenyMatcher; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; @@ -61,20 +62,18 @@ pub fn get_platform_sandbox(windows_sandbox_enabled: bool) -> Option, + managed_mitm_ca_trust_bundle_paths: &[AbsolutePathBuf], sandbox_policy_cwd: &Path, ) -> PermissionProfile { - let Some(managed_mitm_ca_trust_bundle_path) = managed_mitm_ca_trust_bundle_path else { + if managed_mitm_ca_trust_bundle_paths.is_empty() { return permission_profile; - }; + } let (file_system_sandbox_policy, network_sandbox_policy) = permission_profile.to_runtime_permissions(); - let file_system_sandbox_policy = file_system_sandbox_policy.with_additional_readable_roots( - sandbox_policy_cwd, - std::slice::from_ref(managed_mitm_ca_trust_bundle_path), - ); + let file_system_sandbox_policy = file_system_sandbox_policy + .with_additional_readable_roots(sandbox_policy_cwd, managed_mitm_ca_trust_bundle_paths); PermissionProfile::from_runtime_permissions_with_enforcement( permission_profile.enforcement(), &file_system_sandbox_policy, @@ -82,6 +81,43 @@ pub fn with_managed_mitm_ca_readable_root( ) } +pub fn prepare_managed_network_child( + network: Option<&NetworkProxy>, + env: &mut HashMap, + command_cwd: &Path, + permission_profile: PermissionProfile, + sandbox_policy_cwd: &Path, +) -> PermissionProfile { + let managed_mitm_ca_trust_bundle_paths = network.map_or_else(Vec::new, |network| { + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); + let read_deny_matcher = + ReadDenyMatcher::new(&file_system_sandbox_policy, sandbox_policy_cwd); + network.prepare_child_env(env, command_cwd, |path| { + can_read_path_with_policy( + &file_system_sandbox_policy, + read_deny_matcher.as_ref(), + path, + sandbox_policy_cwd, + ) + }) + }); + with_managed_mitm_ca_readable_roots( + permission_profile, + &managed_mitm_ca_trust_bundle_paths, + sandbox_policy_cwd, + ) +} + +fn can_read_path_with_policy( + file_system_sandbox_policy: &FileSystemSandboxPolicy, + read_deny_matcher: Option<&ReadDenyMatcher>, + path: &Path, + cwd: &Path, +) -> bool { + file_system_sandbox_policy.can_read_path_with_cwd(path, cwd) + && !read_deny_matcher.is_some_and(|matcher| matcher.is_read_denied(path)) +} + #[derive(Debug)] pub struct SandboxCommand { pub program: OsString, @@ -203,13 +239,13 @@ impl SandboxManager { windows_sandbox_private_desktop, } = request; let additional_permissions = command.additional_permissions.take(); - let managed_mitm_ca_trust_bundle_path = - network.and_then(NetworkProxy::managed_mitm_ca_trust_bundle_path); let effective_permission_profile = effective_permission_profile(permissions, additional_permissions.as_ref()); - let effective_permission_profile = with_managed_mitm_ca_readable_root( + let effective_permission_profile = prepare_managed_network_child( + network, + &mut command.env, + command.cwd.as_path(), effective_permission_profile, - managed_mitm_ca_trust_bundle_path.as_ref(), sandbox_policy_cwd, ); let (effective_file_system_policy, effective_network_policy) = diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index fede00fe1e..6019a22066 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -3,8 +3,9 @@ use super::SandboxManager; use super::SandboxTransformRequest; use super::SandboxType; use super::SandboxablePreference; +use super::can_read_path_with_policy; use super::get_platform_sandbox; -use super::with_managed_mitm_ca_readable_root; +use super::with_managed_mitm_ca_readable_roots; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions; @@ -16,6 +17,7 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::permissions::ReadDenyMatcher; use codex_utils_absolute_path::AbsolutePathBuf; use dunce::canonicalize; use pretty_assertions::assert_eq; @@ -261,9 +263,9 @@ fn managed_mitm_ca_bundle_becomes_readable_for_restricted_sandbox() { NetworkSandboxPolicy::Restricted, ); - let permission_profile = with_managed_mitm_ca_readable_root( + let permission_profile = with_managed_mitm_ca_readable_roots( permission_profile, - Some(&managed_bundle_path), + std::slice::from_ref(&managed_bundle_path), cwd.as_path(), ); let (file_system_sandbox_policy, _) = permission_profile.to_runtime_permissions(); @@ -285,6 +287,44 @@ fn managed_mitm_ca_bundle_becomes_readable_for_restricted_sandbox() { ); } +#[test] +fn managed_mitm_ca_materialization_rejects_glob_denied_paths_from_command_subdir() { + let sandbox_policy_cwd = TempDir::new().expect("create cwd"); + let sandbox_policy_cwd = AbsolutePathBuf::from_absolute_path( + canonicalize(sandbox_policy_cwd.path()).expect("canonicalize cwd"), + ) + .expect("absolute cwd"); + let command_cwd = sandbox_policy_cwd.join("subdir"); + std::fs::create_dir(command_cwd.as_path()).expect("create command cwd"); + let ca_bundle_path = command_cwd.join("../secrets/blocked.pem"); + std::fs::create_dir(sandbox_policy_cwd.join("secrets").as_path()).expect("create secrets"); + std::fs::write(ca_bundle_path.as_path(), "secret").expect("write blocked CA bundle"); + let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: sandbox_policy_cwd.clone(), + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "secrets/**".to_string(), + }, + access: FileSystemAccessMode::Deny, + }, + ]); + let read_deny_matcher = + ReadDenyMatcher::new(&file_system_sandbox_policy, sandbox_policy_cwd.as_path()) + .expect("deny matcher"); + + assert!(!can_read_path_with_policy( + &file_system_sandbox_policy, + Some(&read_deny_matcher), + ca_bundle_path.as_path(), + sandbox_policy_cwd.as_path(), + )); +} + #[cfg(target_os = "linux")] fn transform_linux_seccomp_request( codex_linux_sandbox_exe: &std::path::Path, From 5891f041b292bc32e311bb3029737e8302e4e38a Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 3 Jun 2026 18:55:52 -0700 Subject: [PATCH 56/59] Preserve always-trusted macOS roots --- codex-rs/network-proxy/src/native_certs.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/codex-rs/network-proxy/src/native_certs.rs b/codex-rs/network-proxy/src/native_certs.rs index f4ef4a7b85..0ab697375c 100644 --- a/codex-rs/network-proxy/src/native_certs.rs +++ b/codex-rs/network-proxy/src/native_certs.rs @@ -52,9 +52,7 @@ pub(crate) fn load_platform_native_certs() -> CertificateResult { for cert in iter { let der = cert.to_der(); let trusted = match ts.tls_trust_settings_for_certificate(&cert) { - Ok(Some(trusted)) => trusted, - Ok(None) if *domain == Domain::System => TrustSettingsForCertificate::TrustRoot, - Ok(None) => continue, + Ok(trusted) => trusted.unwrap_or(TrustSettingsForCertificate::TrustRoot), Err(err) => { result.errors.push(Error { context: "certificate not trusted", From 9f9257e9cdb95f73ac6bba7e85cc64f007b6b191 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 3 Jun 2026 18:55:52 -0700 Subject: [PATCH 57/59] Restore managed MITM CA bundle accessor --- codex-rs/network-proxy/src/proxy.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index df49774b06..ed65571ebb 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -632,6 +632,17 @@ impl NetworkProxy { self.runtime_settings().dangerously_allow_all_unix_sockets } + /// Returns the generated MITM CA bundle path child sandboxes should expose to TLS clients. + pub fn managed_mitm_ca_trust_bundle_path(&self) -> Option { + self.runtime_settings() + .mitm_ca_trust_bundle + .and_then(|bundle| { + AbsolutePathBuf::from_absolute_path(bundle.path) + .map_err(|err| warn!("managed MITM CA trust bundle path is invalid: {err}")) + .ok() + }) + } + pub fn apply_to_env(&self, env: &mut HashMap) { let runtime_settings = self.runtime_settings(); // Enforce proxying for child processes. Proxy endpoint values are always rewritten; From a4bbd33b6558485d50eb68d55d0bf95c4f8f75b6 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 3 Jun 2026 19:18:11 -0700 Subject: [PATCH 58/59] Address child CA review feedback --- codex-rs/network-proxy/src/certs.rs | 2 +- codex-rs/network-proxy/src/child_ca.rs | 20 ++++- codex-rs/network-proxy/src/child_ca_tests.rs | 45 ++++++++++ codex-rs/network-proxy/src/proxy.rs | 92 +++++++++++++++----- 4 files changed, 133 insertions(+), 26 deletions(-) diff --git a/codex-rs/network-proxy/src/certs.rs b/codex-rs/network-proxy/src/certs.rs index fb2f6fd532..7c68e81550 100644 --- a/codex-rs/network-proxy/src/certs.rs +++ b/codex-rs/network-proxy/src/certs.rs @@ -417,7 +417,7 @@ pub(crate) fn append_pem_contents(bundle: &mut String, pem: &str) { } } -fn append_bounded_pem_contents(bundle: &mut String, pem: &str) -> Result<()> { +pub(crate) fn append_bounded_pem_contents(bundle: &mut String, pem: &str) -> Result<()> { let separator_len = usize::from(!bundle.is_empty() && !bundle.ends_with('\n')); let trailing_newline_len = usize::from(!pem.ends_with('\n')); anyhow::ensure!( diff --git a/codex-rs/network-proxy/src/child_ca.rs b/codex-rs/network-proxy/src/child_ca.rs index 66f5c54f3d..c0f4e267c3 100644 --- a/codex-rs/network-proxy/src/child_ca.rs +++ b/codex-rs/network-proxy/src/child_ca.rs @@ -40,8 +40,16 @@ where .unwrap_or_default(); if key == "SSL_CERT_FILE" && let Some(ssl_cert_dir_contents) = ssl_cert_dir_contents.as_deref() + && let Err(err) = crate::certs::append_bounded_pem_contents( + &mut custom_ca_bundle, + ssl_cert_dir_contents, + ) { - crate::certs::append_pem_contents(&mut custom_ca_bundle, ssl_cert_dir_contents); + warn!( + ca_env_key = key, + "failed to combine child MITM CA bundle; leaving current value unchanged: {err}" + ); + continue; } if custom_ca_bundle.is_empty() { continue; @@ -142,7 +150,15 @@ where }) { match crate::certs::read_custom_ca_dir(&ca_dir_path, can_read_path) { Ok(contents) if !contents.is_empty() => { - crate::certs::append_pem_contents(&mut trust_bundle, &contents); + if let Err(err) = + crate::certs::append_bounded_pem_contents(&mut trust_bundle, &contents) + { + warn!( + ca_bundle_path = %ca_dir_path.display(), + "failed to combine child MITM CA directories; ignoring SSL_CERT_DIR override: {err}" + ); + return None; + } } Ok(_) => {} Err(err) => { diff --git a/codex-rs/network-proxy/src/child_ca_tests.rs b/codex-rs/network-proxy/src/child_ca_tests.rs index de1ae98b50..40109efd64 100644 --- a/codex-rs/network-proxy/src/child_ca_tests.rs +++ b/codex-rs/network-proxy/src/child_ca_tests.rs @@ -145,6 +145,51 @@ fn materializes_readable_ssl_cert_dir() { assert_eq!(env.get(crate::certs::SSL_CERT_DIR_ENV_KEY), None); } +#[test] +fn bounds_aggregate_ssl_cert_dir_contents() { + let dir = tempdir().unwrap(); + let ssl_cert_dir_paths = [dir.path().join("certs-a"), dir.path().join("certs-b")]; + for path in &ssl_cert_dir_paths { + fs::create_dir(path).unwrap(); + fs::write(path.join("ordinary-ca.pem"), "a".repeat(2_200_000)).unwrap(); + } + let mitm_ca_trust_bundle_path = dir.path().join("ca-bundle.pem"); + fs::write(&mitm_ca_trust_bundle_path, "managed ca\n").unwrap(); + let ssl_cert_dir = std::env::join_paths(["certs-a", "certs-b"]).unwrap(); + let mut env = HashMap::from([ + ( + "SSL_CERT_FILE".to_string(), + mitm_ca_trust_bundle_path.display().to_string(), + ), + ( + crate::certs::SSL_CERT_DIR_ENV_KEY.to_string(), + ssl_cert_dir.to_string_lossy().into_owned(), + ), + ]); + let mitm_ca_trust_bundle = ManagedMitmCaTrustBundle { + path: mitm_ca_trust_bundle_path, + startup_env_values: HashMap::from([( + crate::certs::SSL_CERT_DIR_ENV_KEY, + ssl_cert_dir.to_string_lossy().into_owned(), + )]), + startup_cwd: dir.path().to_path_buf(), + }; + + prepare_mitm_ca_trust_bundle_env( + &mitm_ca_trust_bundle, + &mut env, + dir.path(), + &[crate::certs::SSL_CERT_DIR_ENV_KEY], + |_| true, + ); + + assert_eq!( + env.get("SSL_CERT_FILE"), + Some(&mitm_ca_trust_bundle.path.display().to_string()) + ); + assert_eq!(env.get(crate::certs::SSL_CERT_DIR_ENV_KEY), None); +} + #[test] fn preserves_unreadable_command_scoped_override() { let dir = tempdir().unwrap(); diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index ed65571ebb..c8c7fe5194 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -379,6 +379,7 @@ pub const PROXY_URL_ENV_KEYS: &[&str] = &[ pub const ALL_PROXY_ENV_KEYS: &[&str] = &["ALL_PROXY", "all_proxy"]; pub const PROXY_ACTIVE_ENV_KEY: &str = "CODEX_NETWORK_PROXY_ACTIVE"; pub const MITM_CA_ENV_ACTIVE_ENV_KEY: &str = "CODEX_NETWORK_PROXY_MITM_CA_ENV_ACTIVE"; +const STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY: &str = "CODEX_NETWORK_PROXY_STARTUP_CA_ENV_KEYS_PRESENT"; pub const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING"; const ELECTRON_GET_USE_PROXY_ENV_KEY: &str = "ELECTRON_GET_USE_PROXY"; const NODE_USE_ENV_PROXY_ENV_KEY: &str = "NODE_USE_ENV_PROXY"; @@ -387,6 +388,7 @@ const GIT_SSH_COMMAND_ENV_KEY: &str = "GIT_SSH_COMMAND"; pub const PROXY_ENV_KEYS: &[&str] = &[ PROXY_ACTIVE_ENV_KEY, MITM_CA_ENV_ACTIVE_ENV_KEY, + STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY, ALLOW_LOCAL_BINDING_ENV_KEY, ELECTRON_GET_USE_PROXY_ENV_KEY, NODE_USE_ENV_PROXY_ENV_KEY, @@ -577,24 +579,53 @@ fn apply_proxy_env_overrides( if let Some(mitm_ca_trust_bundle) = mitm_ca_trust_bundle { env.insert(MITM_CA_ENV_ACTIVE_ENV_KEY.to_string(), "1".to_string()); let managed_path = mitm_ca_trust_bundle.path.to_string_lossy().into_owned(); + let startup_ca_env_keys_present_in_child = ca_env_keys() + .filter(|&key| { + env.get(key) + .filter(|value| !value.is_empty()) + .is_some_and(|value| { + mitm_ca_trust_bundle.startup_env_values.get(key) == Some(value) + || (value == &managed_path && is_tracked_startup_ca_env_key(env, key)) + }) + }) + .collect::>(); + if startup_ca_env_keys_present_in_child.is_empty() { + env.remove(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY); + } else { + env.insert( + STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY.to_string(), + startup_ca_env_keys_present_in_child.join(","), + ); + } for key in crate::certs::CUSTOM_CA_ENV_KEYS { if env .get(key) .filter(|value| !value.is_empty()) - .is_some_and(|value| value != &managed_path) + .is_some_and(|value| { + value != &managed_path && !startup_ca_env_keys_present_in_child.contains(&key) + }) { - // Child-scoped overrides, including inherited startup values, need the - // effective filesystem policy before we can combine them with the managed CA - // bundle, so leave them for prepare_child_env(). continue; } env.insert(key.to_string(), managed_path.clone()); } } else { env.remove(MITM_CA_ENV_ACTIVE_ENV_KEY); + env.remove(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY); } } +fn ca_env_keys() -> impl Iterator { + crate::certs::CUSTOM_CA_ENV_KEYS + .into_iter() + .chain(std::iter::once(crate::certs::SSL_CERT_DIR_ENV_KEY)) +} + +fn is_tracked_startup_ca_env_key(env: &HashMap, key: &str) -> bool { + env.get(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY) + .is_some_and(|keys| keys.split(',').any(|tracked_key| tracked_key == key)) +} + impl NetworkProxy { pub fn builder() -> NetworkProxyBuilder { NetworkProxyBuilder::default() @@ -646,7 +677,7 @@ impl NetworkProxy { pub fn apply_to_env(&self, env: &mut HashMap) { let runtime_settings = self.runtime_settings(); // Enforce proxying for child processes. Proxy endpoint values are always rewritten; - // managed MITM CA vars preserve child-scoped overrides after proxy startup. + // managed MITM CA vars preserve command-scoped overrides after proxy startup. apply_proxy_env_overrides( env, self.http_addr, @@ -668,18 +699,6 @@ impl NetworkProxy { F: Fn(&Path) -> bool, { let runtime_settings = self.runtime_settings(); - let startup_ca_env_keys_present_in_child = runtime_settings - .mitm_ca_trust_bundle - .as_ref() - .map_or_else(Vec::new, |mitm_ca_trust_bundle| { - mitm_ca_trust_bundle - .startup_env_values - .iter() - .filter_map(|(&key, startup_value)| { - (env.get(key) == Some(startup_value)).then_some(key) - }) - .collect() - }); apply_proxy_env_overrides( env, self.http_addr, @@ -688,6 +707,10 @@ impl NetworkProxy { runtime_settings.allow_local_binding, runtime_settings.mitm_ca_trust_bundle.as_ref(), ); + let startup_ca_env_keys_present_in_child = ca_env_keys() + .filter(|&key| is_tracked_startup_ca_env_key(env, key)) + .collect::>(); + env.remove(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY); runtime_settings.mitm_ca_trust_bundle.as_ref().map_or_else( Vec::new, |mitm_ca_trust_bundle| { @@ -1178,7 +1201,7 @@ mod tests { } #[test] - fn apply_proxy_env_overrides_preserves_startup_mitm_ca_override() { + fn apply_proxy_env_overrides_tracks_rewritten_startup_mitm_ca_override() { let startup_ca_bundle_path = "/tmp/startup-ca.pem".to_string(); let mut env = HashMap::from([( "REQUESTS_CA_BUNDLE".to_string(), @@ -1187,10 +1210,7 @@ mod tests { let mitm_ca_trust_bundle_path = Path::new("/tmp/codex-proxy/ca-bundle.pem"); let mitm_ca_trust_bundle = crate::certs::ManagedMitmCaTrustBundle { path: mitm_ca_trust_bundle_path.to_path_buf(), - startup_env_values: HashMap::from([( - "REQUESTS_CA_BUNDLE", - startup_ca_bundle_path.clone(), - )]), + startup_env_values: HashMap::from([("REQUESTS_CA_BUNDLE", startup_ca_bundle_path)]), startup_cwd: Path::new("/tmp").to_path_buf(), }; apply_proxy_env_overrides( @@ -1202,11 +1222,37 @@ mod tests { Some(&mitm_ca_trust_bundle), ); - assert_eq!(env.get("REQUESTS_CA_BUNDLE"), Some(&startup_ca_bundle_path)); + assert_eq!( + env.get("REQUESTS_CA_BUNDLE"), + Some(&mitm_ca_trust_bundle_path.display().to_string()) + ); + assert_eq!( + env.get(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY), + Some(&"REQUESTS_CA_BUNDLE".to_string()) + ); assert_eq!( env.get("SSL_CERT_FILE"), Some(&mitm_ca_trust_bundle_path.display().to_string()) ); + + env.insert( + "REQUESTS_CA_BUNDLE".to_string(), + "/tmp/command-ca.pem".to_string(), + ); + apply_proxy_env_overrides( + &mut env, + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128), + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), + /*socks_enabled*/ true, + /*allow_local_binding*/ false, + Some(&mitm_ca_trust_bundle), + ); + + assert_eq!( + env.get("REQUESTS_CA_BUNDLE"), + Some(&"/tmp/command-ca.pem".to_string()) + ); + assert_eq!(env.get(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY), None); } #[test] From c9014ad029f8febe5735f042a0cd5094868d7108 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 3 Jun 2026 19:18:11 -0700 Subject: [PATCH 59/59] Address child CA review feedback --- codex-rs/network-proxy/src/certs.rs | 2 +- codex-rs/network-proxy/src/child_ca.rs | 20 ++++- codex-rs/network-proxy/src/child_ca_tests.rs | 84 ++++++++++++------ codex-rs/network-proxy/src/proxy.rs | 92 +++++++++++++++----- 4 files changed, 146 insertions(+), 52 deletions(-) diff --git a/codex-rs/network-proxy/src/certs.rs b/codex-rs/network-proxy/src/certs.rs index fb2f6fd532..7c68e81550 100644 --- a/codex-rs/network-proxy/src/certs.rs +++ b/codex-rs/network-proxy/src/certs.rs @@ -417,7 +417,7 @@ pub(crate) fn append_pem_contents(bundle: &mut String, pem: &str) { } } -fn append_bounded_pem_contents(bundle: &mut String, pem: &str) -> Result<()> { +pub(crate) fn append_bounded_pem_contents(bundle: &mut String, pem: &str) -> Result<()> { let separator_len = usize::from(!bundle.is_empty() && !bundle.ends_with('\n')); let trailing_newline_len = usize::from(!pem.ends_with('\n')); anyhow::ensure!( diff --git a/codex-rs/network-proxy/src/child_ca.rs b/codex-rs/network-proxy/src/child_ca.rs index 66f5c54f3d..c0f4e267c3 100644 --- a/codex-rs/network-proxy/src/child_ca.rs +++ b/codex-rs/network-proxy/src/child_ca.rs @@ -40,8 +40,16 @@ where .unwrap_or_default(); if key == "SSL_CERT_FILE" && let Some(ssl_cert_dir_contents) = ssl_cert_dir_contents.as_deref() + && let Err(err) = crate::certs::append_bounded_pem_contents( + &mut custom_ca_bundle, + ssl_cert_dir_contents, + ) { - crate::certs::append_pem_contents(&mut custom_ca_bundle, ssl_cert_dir_contents); + warn!( + ca_env_key = key, + "failed to combine child MITM CA bundle; leaving current value unchanged: {err}" + ); + continue; } if custom_ca_bundle.is_empty() { continue; @@ -142,7 +150,15 @@ where }) { match crate::certs::read_custom_ca_dir(&ca_dir_path, can_read_path) { Ok(contents) if !contents.is_empty() => { - crate::certs::append_pem_contents(&mut trust_bundle, &contents); + if let Err(err) = + crate::certs::append_bounded_pem_contents(&mut trust_bundle, &contents) + { + warn!( + ca_bundle_path = %ca_dir_path.display(), + "failed to combine child MITM CA directories; ignoring SSL_CERT_DIR override: {err}" + ); + return None; + } } Ok(_) => {} Err(err) => { diff --git a/codex-rs/network-proxy/src/child_ca_tests.rs b/codex-rs/network-proxy/src/child_ca_tests.rs index de1ae98b50..4fb38c548a 100644 --- a/codex-rs/network-proxy/src/child_ca_tests.rs +++ b/codex-rs/network-proxy/src/child_ca_tests.rs @@ -31,6 +31,38 @@ fn requests_ca_bundle_contents(env: &HashMap) -> String { .unwrap() } +fn ssl_cert_dir_env( + dir: &tempfile::TempDir, + contents: [String; 2], +) -> (HashMap, ManagedMitmCaTrustBundle) { + let ssl_cert_dir_paths = [dir.path().join("certs-a"), dir.path().join("certs-b")]; + for (path, contents) in ssl_cert_dir_paths.iter().zip(contents) { + fs::create_dir(path).unwrap(); + fs::write(path.join("ordinary-ca.pem"), contents).unwrap(); + } + let mitm_ca_trust_bundle_path = dir.path().join("ca-bundle.pem"); + fs::write(&mitm_ca_trust_bundle_path, "managed ca\n").unwrap(); + let ssl_cert_dir = std::env::join_paths(["certs-a", "certs-b"]).unwrap(); + let ssl_cert_dir = ssl_cert_dir.to_string_lossy().into_owned(); + ( + HashMap::from([ + ( + "SSL_CERT_FILE".to_string(), + mitm_ca_trust_bundle_path.display().to_string(), + ), + ( + crate::certs::SSL_CERT_DIR_ENV_KEY.to_string(), + ssl_cert_dir.clone(), + ), + ]), + ManagedMitmCaTrustBundle { + path: mitm_ca_trust_bundle_path, + startup_env_values: HashMap::from([(crate::certs::SSL_CERT_DIR_ENV_KEY, ssl_cert_dir)]), + startup_cwd: dir.path().to_path_buf(), + }, + ) +} + #[test] fn materializes_readable_startup_ca_override() { let dir = tempdir().unwrap(); @@ -99,32 +131,8 @@ fn materializes_readable_command_scoped_override() { #[test] fn materializes_readable_ssl_cert_dir() { let dir = tempdir().unwrap(); - let ssl_cert_dir_paths = [dir.path().join("certs-a"), dir.path().join("certs-b")]; - for (path, contents) in ssl_cert_dir_paths.iter().zip(["dir ca a\n", "dir ca b\n"]) { - fs::create_dir(path).unwrap(); - fs::write(path.join("ordinary-ca.pem"), contents).unwrap(); - } - let mitm_ca_trust_bundle_path = dir.path().join("ca-bundle.pem"); - fs::write(&mitm_ca_trust_bundle_path, "managed ca\n").unwrap(); - let ssl_cert_dir = std::env::join_paths(["certs-a", "certs-b"]).unwrap(); - let mut env = HashMap::from([ - ( - "SSL_CERT_FILE".to_string(), - mitm_ca_trust_bundle_path.display().to_string(), - ), - ( - crate::certs::SSL_CERT_DIR_ENV_KEY.to_string(), - ssl_cert_dir.to_string_lossy().into_owned(), - ), - ]); - let mitm_ca_trust_bundle = ManagedMitmCaTrustBundle { - path: mitm_ca_trust_bundle_path, - startup_env_values: HashMap::from([( - crate::certs::SSL_CERT_DIR_ENV_KEY, - ssl_cert_dir.to_string_lossy().into_owned(), - )]), - startup_cwd: dir.path().to_path_buf(), - }; + let (mut env, mitm_ca_trust_bundle) = + ssl_cert_dir_env(&dir, ["dir ca a\n".to_string(), "dir ca b\n".to_string()]); prepare_mitm_ca_trust_bundle_env( &mitm_ca_trust_bundle, @@ -145,6 +153,30 @@ fn materializes_readable_ssl_cert_dir() { assert_eq!(env.get(crate::certs::SSL_CERT_DIR_ENV_KEY), None); } +#[test] +fn bounds_aggregate_ssl_cert_dir_contents() { + let dir = tempdir().unwrap(); + let oversized_dir_contents = "a".repeat(2_200_000); + let (mut env, mitm_ca_trust_bundle) = ssl_cert_dir_env( + &dir, + [oversized_dir_contents.clone(), oversized_dir_contents], + ); + + prepare_mitm_ca_trust_bundle_env( + &mitm_ca_trust_bundle, + &mut env, + dir.path(), + &[crate::certs::SSL_CERT_DIR_ENV_KEY], + |_| true, + ); + + assert_eq!( + env.get("SSL_CERT_FILE"), + Some(&mitm_ca_trust_bundle.path.display().to_string()) + ); + assert_eq!(env.get(crate::certs::SSL_CERT_DIR_ENV_KEY), None); +} + #[test] fn preserves_unreadable_command_scoped_override() { let dir = tempdir().unwrap(); diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index ed65571ebb..c8c7fe5194 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -379,6 +379,7 @@ pub const PROXY_URL_ENV_KEYS: &[&str] = &[ pub const ALL_PROXY_ENV_KEYS: &[&str] = &["ALL_PROXY", "all_proxy"]; pub const PROXY_ACTIVE_ENV_KEY: &str = "CODEX_NETWORK_PROXY_ACTIVE"; pub const MITM_CA_ENV_ACTIVE_ENV_KEY: &str = "CODEX_NETWORK_PROXY_MITM_CA_ENV_ACTIVE"; +const STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY: &str = "CODEX_NETWORK_PROXY_STARTUP_CA_ENV_KEYS_PRESENT"; pub const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING"; const ELECTRON_GET_USE_PROXY_ENV_KEY: &str = "ELECTRON_GET_USE_PROXY"; const NODE_USE_ENV_PROXY_ENV_KEY: &str = "NODE_USE_ENV_PROXY"; @@ -387,6 +388,7 @@ const GIT_SSH_COMMAND_ENV_KEY: &str = "GIT_SSH_COMMAND"; pub const PROXY_ENV_KEYS: &[&str] = &[ PROXY_ACTIVE_ENV_KEY, MITM_CA_ENV_ACTIVE_ENV_KEY, + STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY, ALLOW_LOCAL_BINDING_ENV_KEY, ELECTRON_GET_USE_PROXY_ENV_KEY, NODE_USE_ENV_PROXY_ENV_KEY, @@ -577,24 +579,53 @@ fn apply_proxy_env_overrides( if let Some(mitm_ca_trust_bundle) = mitm_ca_trust_bundle { env.insert(MITM_CA_ENV_ACTIVE_ENV_KEY.to_string(), "1".to_string()); let managed_path = mitm_ca_trust_bundle.path.to_string_lossy().into_owned(); + let startup_ca_env_keys_present_in_child = ca_env_keys() + .filter(|&key| { + env.get(key) + .filter(|value| !value.is_empty()) + .is_some_and(|value| { + mitm_ca_trust_bundle.startup_env_values.get(key) == Some(value) + || (value == &managed_path && is_tracked_startup_ca_env_key(env, key)) + }) + }) + .collect::>(); + if startup_ca_env_keys_present_in_child.is_empty() { + env.remove(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY); + } else { + env.insert( + STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY.to_string(), + startup_ca_env_keys_present_in_child.join(","), + ); + } for key in crate::certs::CUSTOM_CA_ENV_KEYS { if env .get(key) .filter(|value| !value.is_empty()) - .is_some_and(|value| value != &managed_path) + .is_some_and(|value| { + value != &managed_path && !startup_ca_env_keys_present_in_child.contains(&key) + }) { - // Child-scoped overrides, including inherited startup values, need the - // effective filesystem policy before we can combine them with the managed CA - // bundle, so leave them for prepare_child_env(). continue; } env.insert(key.to_string(), managed_path.clone()); } } else { env.remove(MITM_CA_ENV_ACTIVE_ENV_KEY); + env.remove(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY); } } +fn ca_env_keys() -> impl Iterator { + crate::certs::CUSTOM_CA_ENV_KEYS + .into_iter() + .chain(std::iter::once(crate::certs::SSL_CERT_DIR_ENV_KEY)) +} + +fn is_tracked_startup_ca_env_key(env: &HashMap, key: &str) -> bool { + env.get(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY) + .is_some_and(|keys| keys.split(',').any(|tracked_key| tracked_key == key)) +} + impl NetworkProxy { pub fn builder() -> NetworkProxyBuilder { NetworkProxyBuilder::default() @@ -646,7 +677,7 @@ impl NetworkProxy { pub fn apply_to_env(&self, env: &mut HashMap) { let runtime_settings = self.runtime_settings(); // Enforce proxying for child processes. Proxy endpoint values are always rewritten; - // managed MITM CA vars preserve child-scoped overrides after proxy startup. + // managed MITM CA vars preserve command-scoped overrides after proxy startup. apply_proxy_env_overrides( env, self.http_addr, @@ -668,18 +699,6 @@ impl NetworkProxy { F: Fn(&Path) -> bool, { let runtime_settings = self.runtime_settings(); - let startup_ca_env_keys_present_in_child = runtime_settings - .mitm_ca_trust_bundle - .as_ref() - .map_or_else(Vec::new, |mitm_ca_trust_bundle| { - mitm_ca_trust_bundle - .startup_env_values - .iter() - .filter_map(|(&key, startup_value)| { - (env.get(key) == Some(startup_value)).then_some(key) - }) - .collect() - }); apply_proxy_env_overrides( env, self.http_addr, @@ -688,6 +707,10 @@ impl NetworkProxy { runtime_settings.allow_local_binding, runtime_settings.mitm_ca_trust_bundle.as_ref(), ); + let startup_ca_env_keys_present_in_child = ca_env_keys() + .filter(|&key| is_tracked_startup_ca_env_key(env, key)) + .collect::>(); + env.remove(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY); runtime_settings.mitm_ca_trust_bundle.as_ref().map_or_else( Vec::new, |mitm_ca_trust_bundle| { @@ -1178,7 +1201,7 @@ mod tests { } #[test] - fn apply_proxy_env_overrides_preserves_startup_mitm_ca_override() { + fn apply_proxy_env_overrides_tracks_rewritten_startup_mitm_ca_override() { let startup_ca_bundle_path = "/tmp/startup-ca.pem".to_string(); let mut env = HashMap::from([( "REQUESTS_CA_BUNDLE".to_string(), @@ -1187,10 +1210,7 @@ mod tests { let mitm_ca_trust_bundle_path = Path::new("/tmp/codex-proxy/ca-bundle.pem"); let mitm_ca_trust_bundle = crate::certs::ManagedMitmCaTrustBundle { path: mitm_ca_trust_bundle_path.to_path_buf(), - startup_env_values: HashMap::from([( - "REQUESTS_CA_BUNDLE", - startup_ca_bundle_path.clone(), - )]), + startup_env_values: HashMap::from([("REQUESTS_CA_BUNDLE", startup_ca_bundle_path)]), startup_cwd: Path::new("/tmp").to_path_buf(), }; apply_proxy_env_overrides( @@ -1202,11 +1222,37 @@ mod tests { Some(&mitm_ca_trust_bundle), ); - assert_eq!(env.get("REQUESTS_CA_BUNDLE"), Some(&startup_ca_bundle_path)); + assert_eq!( + env.get("REQUESTS_CA_BUNDLE"), + Some(&mitm_ca_trust_bundle_path.display().to_string()) + ); + assert_eq!( + env.get(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY), + Some(&"REQUESTS_CA_BUNDLE".to_string()) + ); assert_eq!( env.get("SSL_CERT_FILE"), Some(&mitm_ca_trust_bundle_path.display().to_string()) ); + + env.insert( + "REQUESTS_CA_BUNDLE".to_string(), + "/tmp/command-ca.pem".to_string(), + ); + apply_proxy_env_overrides( + &mut env, + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128), + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), + /*socks_enabled*/ true, + /*allow_local_binding*/ false, + Some(&mitm_ca_trust_bundle), + ); + + assert_eq!( + env.get("REQUESTS_CA_BUNDLE"), + Some(&"/tmp/command-ca.pem".to_string()) + ); + assert_eq!(env.get(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY), None); } #[test]