diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs index 655c89fe20..7a2d391e60 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs @@ -22,11 +22,15 @@ use crate::endpoint::realtime_websocket::protocol::parse_realtime_event; use crate::error::ApiError; use crate::provider::Provider; use codex_client::backoff; +use codex_http_client::HttpClientFactory; use codex_http_client::maybe_build_rustls_client_config_with_custom_ca; use codex_protocol::protocol::ConversationTextParams; use codex_protocol::protocol::ConversationTextRole; use codex_protocol::protocol::RealtimeTranscriptDelta; use codex_utils_rustls_provider::ensure_rustls_crypto_provider; +use codex_websocket_client::WebSocketConnection; +use codex_websocket_client::WebSocketConnector; +use codex_websocket_client::WebSocketTlsMode; use futures::SinkExt; use futures::StreamExt; use http::HeaderMap; @@ -37,13 +41,10 @@ use std::collections::VecDeque; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; -use tokio::net::TcpStream; use tokio::sync::Mutex; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::time::sleep; -use tokio_tungstenite::MaybeTlsStream; -use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::tungstenite::Error as WsError; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::client::IntoClientRequest; @@ -78,7 +79,7 @@ enum WsCommand { impl WsStream { fn new( - inner: WebSocketStream>, + inner: WebSocketConnection, ) -> (Self, async_channel::Receiver>) { let (tx_command, mut rx_command) = mpsc::channel::(32); let (tx_message, rx_message) = async_channel::unbounded::>(); @@ -106,7 +107,7 @@ impl WsStream { } WsCommand::Close { tx_result } => { info!("realtime websocket sending close"); - let result = inner.close(None).await; + let result = inner.send(Message::Close(None)).await; if let Err(err) = &result { error!("realtime websocket close failed: {err}"); } @@ -803,6 +804,7 @@ fn contains_transcript_entry(entries: &[RealtimeTranscriptEntry], role: &str, te pub struct RealtimeWebsocketClient { provider: Provider, + http_client_factory: HttpClientFactory, webrtc_sideband_base_url: String, } @@ -814,9 +816,10 @@ enum RealtimeSessionInitialization { } impl RealtimeWebsocketClient { - pub fn new(provider: Provider) -> Self { + pub fn new(provider: Provider, http_client_factory: HttpClientFactory) -> Self { Self { provider, + http_client_factory, webrtc_sideband_base_url: OPENAI_REALTIME_API_BASE_URL.to_string(), } } @@ -998,28 +1001,29 @@ impl RealtimeWebsocketClient { info!("connecting realtime websocket: {ws_url}"); // Realtime websocket TLS should honor the same custom-CA env vars as the rest of Codex's // outbound HTTPS and websocket traffic. - let connector = maybe_build_rustls_client_config_with_custom_ca() - .map_err(|err| ApiError::Stream(format!("failed to configure websocket TLS: {err}")))? - .map(tokio_tungstenite::Connector::Rustls); + let tls_config = maybe_build_rustls_client_config_with_custom_ca() + .map_err(|err| ApiError::Stream(format!("failed to configure websocket TLS: {err}")))?; // A fresh Windows install may not have downloaded the server's trusted root yet. // Use platform validation only for system trust, preserving custom CA semantics. #[cfg(windows)] - let connector = match connector { - Some(connector) => Some(connector), - None => Some(tokio_tungstenite::Connector::Rustls( + let tls_config = match tls_config { + Some(tls_config) => Some(tls_config), + None => Some( codex_http_client::build_windows_platform_tls_config().map_err(|err| { ApiError::Stream(format!("failed to configure websocket TLS: {err}")) })?, - )), + ), }; - let (stream, response) = tokio_tungstenite::connect_async_tls_with_config( - request, - Some(websocket_config()), - false, - connector, - ) - .await - .map_err(map_realtime_websocket_connect_error)?; + let tls_mode = match tls_config { + Some(tls_config) => WebSocketTlsMode::Rustls(tls_config), + None => WebSocketTlsMode::TungsteniteDefault, + }; + let connector = WebSocketConnector::new_with_tls_mode(&self.http_client_factory, tls_mode) + .map_err(|err| ApiError::Stream(format!("failed to configure websocket TLS: {err}")))?; + let (stream, response) = connector + .connect(request, websocket_config()) + .await + .map_err(map_realtime_websocket_connect_error)?; info!( ws_url = %ws_url, status = %response.status(), @@ -1257,6 +1261,7 @@ mod tests { use super::*; use crate::endpoint::realtime_websocket::protocol::RealtimeTranscriptEntry; use crate::provider::RetryConfig; + use codex_http_client::OutboundProxyPolicy; use codex_protocol::protocol::RealtimeHandoffRequested; use codex_protocol::protocol::RealtimeInputAudioSpeechStarted; use codex_protocol::protocol::RealtimeNoopRequested; @@ -2215,20 +2220,23 @@ mod tests { #[test] fn webrtc_frameless_sideband_ignores_provider_base_url() { - let client = RealtimeWebsocketClient::new(Provider { - name: "chatgpt".to_string(), - base_url: "https://chatgpt.com/backend-api/codex".to_string(), - query_params: None, - headers: HeaderMap::new(), - retry: RetryConfig { - max_attempts: 0, - base_delay: Duration::ZERO, - retry_429: false, - retry_5xx: false, - retry_transport: false, + let client = RealtimeWebsocketClient::new( + Provider { + name: "chatgpt".to_string(), + base_url: "https://chatgpt.com/backend-api/codex".to_string(), + query_params: None, + headers: HeaderMap::new(), + retry: RetryConfig { + max_attempts: 0, + base_delay: Duration::ZERO, + retry_429: false, + retry_5xx: false, + retry_transport: false, + }, + stream_idle_timeout: Duration::from_secs(/*secs*/ 5), }, - stream_idle_timeout: Duration::from_secs(5), - }); + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ); let url = client .webrtc_sideband_url( @@ -2425,7 +2433,10 @@ mod tests { }, stream_idle_timeout: Duration::from_secs(5), }; - let client = RealtimeWebsocketClient::new(provider); + let client = RealtimeWebsocketClient::new( + provider, + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ); let connection = client .connect( RealtimeSessionConfig { @@ -2751,7 +2762,10 @@ mod tests { }, stream_idle_timeout: Duration::from_secs(5), }; - let client = RealtimeWebsocketClient::new(provider); + let client = RealtimeWebsocketClient::new( + provider, + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ); let connection = client .connect( RealtimeSessionConfig { @@ -2878,7 +2892,10 @@ mod tests { }, stream_idle_timeout: Duration::from_secs(5), }; - let client = RealtimeWebsocketClient::new(provider); + let client = RealtimeWebsocketClient::new( + provider, + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ); let connection = client .connect( RealtimeSessionConfig { @@ -2984,7 +3001,10 @@ mod tests { }, stream_idle_timeout: Duration::from_secs(5), }; - let client = RealtimeWebsocketClient::new(provider); + let client = RealtimeWebsocketClient::new( + provider, + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ); let connection = client .connect( RealtimeSessionConfig { @@ -3076,7 +3096,10 @@ mod tests { }, stream_idle_timeout: Duration::from_secs(5), }; - let client = RealtimeWebsocketClient::new(provider); + let client = RealtimeWebsocketClient::new( + provider, + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ); let connection = client .connect( RealtimeSessionConfig { diff --git a/codex-rs/codex-api/tests/realtime_websocket_e2e.rs b/codex-rs/codex-api/tests/realtime_websocket_e2e.rs index 696d0f5052..5eabd5a4e2 100644 --- a/codex-rs/codex-api/tests/realtime_websocket_e2e.rs +++ b/codex-rs/codex-api/tests/realtime_websocket_e2e.rs @@ -13,6 +13,8 @@ use codex_api::RealtimeSessionMode; use codex_api::RealtimeTranscriptState; use codex_api::RealtimeWebsocketClient; use codex_api::RetryConfig; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; use codex_protocol::protocol::RealtimeHandoffRequested; use codex_protocol::protocol::RealtimeTranscriptDelta; use codex_protocol::protocol::RealtimeTranscriptDone; @@ -140,7 +142,10 @@ async fn realtime_ws_e2e_session_create_and_event_flow() { }) .await; - let client = RealtimeWebsocketClient::new(test_provider(format!("http://{addr}"))); + let client = RealtimeWebsocketClient::new( + test_provider(format!("http://{addr}")), + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ); let connection = client .connect( RealtimeSessionConfig { @@ -246,8 +251,11 @@ async fn realtime_ws_connect_webrtc_sideband_retries_join_until_server_is_availa provider.retry.max_attempts = 1; provider.retry.base_delay = Duration::from_millis(100); - let client = RealtimeWebsocketClient::new(provider) - .with_webrtc_sideband_base_url(format!("http://{addr}")); + let client = RealtimeWebsocketClient::new( + provider, + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ) + .with_webrtc_sideband_base_url(format!("http://{addr}")); let connection = client .connect_webrtc_sideband( RealtimeSessionConfig { @@ -322,7 +330,10 @@ async fn realtime_ws_e2e_send_while_next_event_waits() { }) .await; - let client = RealtimeWebsocketClient::new(test_provider(format!("http://{addr}"))); + let client = RealtimeWebsocketClient::new( + test_provider(format!("http://{addr}")), + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ); let connection = client .connect( RealtimeSessionConfig { @@ -392,7 +403,10 @@ async fn realtime_ws_e2e_disconnected_emitted_once() { }) .await; - let client = RealtimeWebsocketClient::new(test_provider(format!("http://{addr}"))); + let client = RealtimeWebsocketClient::new( + test_provider(format!("http://{addr}")), + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ); let connection = client .connect( RealtimeSessionConfig { @@ -458,7 +472,10 @@ async fn realtime_ws_e2e_ignores_unknown_text_events() { }) .await; - let client = RealtimeWebsocketClient::new(test_provider(format!("http://{addr}"))); + let client = RealtimeWebsocketClient::new( + test_provider(format!("http://{addr}")), + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ); let connection = client .connect( RealtimeSessionConfig { @@ -567,7 +584,10 @@ async fn realtime_ws_e2e_realtime_v2_parser_emits_handoff_requested() { }) .await; - let client = RealtimeWebsocketClient::new(test_provider(format!("http://{addr}"))); + let client = RealtimeWebsocketClient::new( + test_provider(format!("http://{addr}")), + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ); let connection = client .connect( RealtimeSessionConfig { diff --git a/codex-rs/codex-api/tests/realtime_websocket_tls.rs b/codex-rs/codex-api/tests/realtime_websocket_tls.rs index 2fe132e24f..5668ff669c 100644 --- a/codex-rs/codex-api/tests/realtime_websocket_tls.rs +++ b/codex-rs/codex-api/tests/realtime_websocket_tls.rs @@ -15,6 +15,8 @@ use codex_api::RealtimeSessionConfig; use codex_api::RealtimeSessionMode; use codex_api::RealtimeWebsocketClient; use codex_api::RetryConfig; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; use codex_protocol::protocol::RealtimeVoice; use http::HeaderMap; use pretty_assertions::assert_eq; @@ -114,20 +116,23 @@ fn realtime_tls_selects_system_and_custom_trust() { } async fn check_connection(address: String) { - let client = RealtimeWebsocketClient::new(Provider { - name: "local TLS test".into(), - base_url: format!("https://{address}"), - query_params: None, - headers: HeaderMap::new(), - retry: RetryConfig { - max_attempts: 1, - base_delay: Duration::from_millis(/*millis*/ 1), - retry_429: false, - retry_5xx: false, - retry_transport: false, + let client = RealtimeWebsocketClient::new( + Provider { + name: "local TLS test".into(), + base_url: format!("https://{address}"), + query_params: None, + headers: HeaderMap::new(), + retry: RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(/*millis*/ 1), + retry_429: false, + retry_5xx: false, + retry_transport: false, + }, + stream_idle_timeout: Duration::from_secs(/*secs*/ 5), }, - stream_idle_timeout: Duration::from_secs(/*secs*/ 5), - }); + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ); let result = tokio::time::timeout( Duration::from_secs(/*secs*/ 20), client.connect( diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 5ef019d75e..29ddc53466 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -31,6 +31,7 @@ use codex_api::build_session_headers; use codex_api::map_api_error; use codex_config::config_toml::RealtimeWsMode; use codex_config::config_toml::RealtimeWsVersion; +use codex_http_client::HttpClientFactory; use codex_login::CodexAuth; use codex_login::default_client::add_originator_header; use codex_login::default_client::default_headers; @@ -507,6 +508,7 @@ struct ConversationState { struct RealtimeStart { api_provider: ApiProvider, + http_client_factory: HttpClientFactory, realtime_sideband_base_url: Option, extra_headers: Option, client_managed_handoffs: bool, @@ -600,6 +602,7 @@ impl RealtimeConversationManager { async fn start_inner(&self, start: RealtimeStart) -> CodexResult { let RealtimeStart { api_provider, + http_client_factory, realtime_sideband_base_url, extra_headers, client_managed_handoffs, @@ -653,7 +656,7 @@ impl RealtimeConversationManager { audio_rx, }; - let client = RealtimeWebsocketClient::new(api_provider); + let client = RealtimeWebsocketClient::new(api_provider, http_client_factory); let client = match realtime_sideband_base_url { Some(base_url) => client.with_webrtc_sideband_base_url(base_url), None => client, @@ -1204,6 +1207,7 @@ pub(crate) async fn handle_start( struct PreparedRealtimeConversationStart { api_provider: ApiProvider, + http_client_factory: HttpClientFactory, realtime_sideband_base_url: Option, extra_headers: Option, client_managed_handoffs: bool, @@ -1345,6 +1349,7 @@ async fn prepare_realtime_start( } Ok(PreparedRealtimeConversationStart { api_provider, + http_client_factory: config.http_client_factory(), realtime_sideband_base_url, extra_headers: Some(extra_headers), client_managed_handoffs: params.client_managed_handoffs, @@ -1567,6 +1572,7 @@ async fn handle_start_inner( ) -> CodexResult<()> { let PreparedRealtimeConversationStart { api_provider, + http_client_factory, realtime_sideband_base_url, extra_headers, client_managed_handoffs, @@ -1595,6 +1601,7 @@ async fn handle_start_inner( }; let start = RealtimeStart { api_provider, + http_client_factory, realtime_sideband_base_url, extra_headers, client_managed_handoffs, diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 2e0e3914d9..aca4328e9c 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -155,6 +155,7 @@ mod realtime_conversation; mod realtime_initial_items; mod realtime_misalignment; mod realtime_sideband_endpoint; +mod realtime_system_proxy; mod reasoning_effort_override; mod remote_env; mod remote_models; diff --git a/codex-rs/core/tests/suite/realtime_system_proxy.rs b/codex-rs/core/tests/suite/realtime_system_proxy.rs new file mode 100644 index 0000000000..a20eb7cde2 --- /dev/null +++ b/codex-rs/core/tests/suite/realtime_system_proxy.rs @@ -0,0 +1,200 @@ +//! Verifies that Voice uses the configured system proxy for its realtime sideband. + +use anyhow::Result; +use codex_features::Feature; +use codex_http_client::cache_system_proxy_route_for_test; +use codex_login::CodexAuth; +use codex_models_manager::bundled_models_response; +use codex_protocol::protocol::CodexResponseHandoffMode; +use codex_protocol::protocol::ConversationStartParams; +use codex_protocol::protocol::ConversationStartTransport; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::RealtimeConversationRealtimeEvent; +use codex_protocol::protocol::RealtimeConversationVersion; +use codex_protocol::protocol::RealtimeEvent; +use codex_protocol::protocol::RealtimeOutputModality; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; +use futures::SinkExt; +use futures::StreamExt; +use http::StatusCode; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::time::Duration; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpListener; +use tokio::process::Command; +use tokio_tungstenite::accept_async; +use tokio_tungstenite::tungstenite::Message; +use tokio_util::task::AbortOnDropHandle; +use wiremock::Mock; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const SUBPROCESS_ENV_VAR: &str = "CODEX_REALTIME_SYSTEM_PROXY_TEST_SUBPROCESS"; +const TEST_NAME: &str = + "suite::realtime_system_proxy::webrtc_sideband_honors_configured_system_proxy"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn webrtc_sideband_honors_configured_system_proxy() -> Result<()> { + skip_if_no_network!(Ok(())); + + if std::env::var_os(SUBPROCESS_ENV_VAR).is_none() { + let mut command = Command::new(std::env::current_exe()?); + command.arg("--exact").arg(TEST_NAME); + for &key in codex_network_proxy::PROXY_ENV_KEYS { + command.env_remove(key); + } + let output = command.env(SUBPROCESS_ENV_VAR, "1").output().await?; + assert!( + output.status.success(), + "subprocess test `{TEST_NAME}` failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + return Ok(()); + } + + let server = start_mock_server().await; + Mock::given(method("POST")) + .and(path("/backend-api/codex/realtime/calls")) + .respond_with( + ResponseTemplate::new(StatusCode::OK) + .insert_header("Location", "/v1/live/rtc_system_proxy") + .set_body_string("v=answer\r\n"), + ) + .mount(&server) + .await; + + let proxy = TcpListener::bind("127.0.0.1:0").await?; + let proxy_url = format!("http://{}", proxy.local_addr()?); + let proxy_task = AbortOnDropHandle::new(tokio::spawn(async move { + let (mut client, _) = proxy.accept().await?; + let mut request = Vec::new(); + let mut byte = [0_u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + anyhow::ensure!(request.len() < 64 * 1024, "proxy headers exceeded 64 KiB"); + client.read_exact(&mut byte).await?; + request.push(byte[0]); + } + let request_line = String::from_utf8(request)? + .lines() + .next() + .ok_or_else(|| anyhow::anyhow!("proxy request was empty"))? + .to_string(); + client + .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") + .await?; + let mut websocket = accept_async(client).await?; + websocket + .send(Message::Text( + json!({ + "type": "session.started", + "session": { "id": "rtc_system_proxy" } + }) + .to_string() + .into(), + )) + .await?; + // Keep the sideband open until the test closes Voice. + while let Some(Ok(message)) = websocket.next().await { + if matches!(message, Message::Close(_)) { + break; + } + } + Ok::<_, anyhow::Error>(request_line) + })); + + let backend_base_url = format!("{}/backend-api/codex", server.uri()); + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| { + config.model_catalog = + Some(bundled_models_response().expect("bundled models.json should parse")); + config.model_provider.base_url = Some(backend_base_url); + config.experimental_realtime_ws_base_url = + Some("ws://realtime-system-proxy.invalid:8765".to_string()); + config + .features + .enable(Feature::RespectSystemProxy) + .expect("test config should allow feature update"); + config.respect_system_proxy = true; + }); + let test = builder.build_with_auto_env(&server).await?; + cache_system_proxy_route_for_test( + "http://realtime-system-proxy.invalid:8765/v1/live/rtc_system_proxy", + proxy_url, + ); + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + client_managed_handoffs: false, + delegation_ack_filler: None, + flush_transcript_tail_on_session_end: false, + codex_responses_as_items: false, + codex_response_item_prefix: None, + codex_response_handoff_mode: CodexResponseHandoffMode::Thinking, + codex_response_handoff_channel_prefixes: None, + model: None, + output_modality: RealtimeOutputModality::Audio, + include_startup_context: false, + initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, + prompt: None, + realtime_session_id: None, + transport: Some(ConversationStartTransport::Webrtc { + sdp: "v=offer\r\n".to_string(), + }), + version: Some(RealtimeConversationVersion::V3), + voice: None, + })) + .await?; + + let sdp = wait_for_event_match(&test.codex, |event| match event { + EventMsg::RealtimeConversationSdp(event) => Some(Ok(event.sdp.clone())), + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::Error(message), + }) => Some(Err(anyhow::anyhow!("{message}"))), + EventMsg::Error(error) => Some(Err(anyhow::anyhow!("{error:?}"))), + _ => None, + }) + .await?; + let session = wait_for_event_match(&test.codex, |event| match event { + EventMsg::RealtimeConversationRealtime(event) => match &event.payload { + payload @ RealtimeEvent::SessionUpdated { .. } => Some(Ok(payload.clone())), + RealtimeEvent::Error(message) => Some(Err(anyhow::anyhow!("{message}"))), + _ => None, + }, + EventMsg::Error(error) => Some(Err(anyhow::anyhow!("{error:?}"))), + _ => None, + }) + .await?; + + test.codex.submit(Op::RealtimeConversationClose).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::RealtimeConversationClosed(_)) + }) + .await; + test.codex.shutdown_and_wait().await?; + let proxy_request = + tokio::time::timeout(Duration::from_secs(/*secs*/ 5), proxy_task).await???; + assert_eq!( + (sdp, session, proxy_request), + ( + "v=answer\r\n".to_string(), + RealtimeEvent::SessionUpdated { + realtime_session_id: "rtc_system_proxy".to_string(), + instructions: None, + }, + "CONNECT realtime-system-proxy.invalid:8765 HTTP/1.1".to_string(), + ) + ); + Ok(()) +} diff --git a/codex-rs/websocket-client/src/lib.rs b/codex-rs/websocket-client/src/lib.rs index e8e5b0a7c4..19cd8ccc96 100644 --- a/codex-rs/websocket-client/src/lib.rs +++ b/codex-rs/websocket-client/src/lib.rs @@ -43,11 +43,13 @@ pub struct WebSocketConnector { tcp_nodelay: TcpNodelay, } -/// Selects whether WebSocket TLS follows Codex custom-CA policy or Tungstenite defaults. -#[derive(Clone, Copy, PartialEq, Eq)] +/// Selects the TLS configuration used for WebSocket connections. +#[derive(Clone)] pub enum WebSocketTlsMode { /// Build an explicit TLS configuration from native roots and configured Codex custom CAs. ExplicitCodexTls, + /// Use an existing Rustls configuration. + Rustls(Arc), /// Let Tungstenite build its default TLS configuration when the target requires TLS. TungsteniteDefault, } @@ -66,10 +68,10 @@ impl WebSocketConnector { Self::new_with_tls_mode(http_client_factory, WebSocketTlsMode::ExplicitCodexTls) } - /// Creates a connector with explicit Codex TLS or the transport's existing TLS defaults. + /// Creates a connector with the selected WebSocket TLS configuration. /// - /// HTTPS proxy connections still build Codex TLS configuration when they establish their - /// proxy tunnel; default-mode target connections otherwise remain entirely with Tungstenite. + /// With [`WebSocketTlsMode::TungsteniteDefault`], HTTPS proxy connections still build Codex + /// TLS configuration when they establish their proxy tunnel. pub fn new_with_tls_mode( http_client_factory: &HttpClientFactory, tls_mode: WebSocketTlsMode, @@ -78,6 +80,7 @@ impl WebSocketConnector { WebSocketTlsMode::ExplicitCodexTls => { Some(build_rustls_client_config_with_custom_ca()?) } + WebSocketTlsMode::Rustls(config) => Some(config), WebSocketTlsMode::TungsteniteDefault => None, }; Ok(Self {