mirror of
https://github.com/openai/codex.git
synced 2026-09-02 14:49:14 +00:00
## Why The route-aware WebSocket connection setup in #31441 is transport infrastructure rather than Responses API protocol logic. Landing it first in a dedicated crate keeps `codex-api` focused on request and response behavior and makes the transport reusable by future WebSocket clients. WebSockets must also apply the same effective outbound proxy and custom-CA policy as HTTP without disabling the lower-latency WebSocket path. Requiring an `HttpClientFactory` when constructing the connector makes proxy-policy resolution part of the API instead of an optional call-site convention. This PR is an independent prerequisite based directly on `main`. After it merges, #31441 can rebase onto it and replace its in-crate connector with this API. ## What changed - Add a new `codex-websocket-client` workspace crate with a `WebSocketConnector` constructed from the effective `HttpClientFactory`. - Resolve every destination through that factory before connecting, then support direct connections, transport-default routing, HTTP proxies, and TLS-encrypted HTTPS proxies. - Preserve custom-CA trust for proxy and target TLS handshakes and preserve Happy Eyeballs fallback for explicit direct and proxy routes. - Expose an established `WebSocketConnection` as a uniform `Stream` and `Sink`, hiding route-specific transport types from protocol clients. - Add focused integration-style coverage for the public connector and message stream, real WSS over direct and CONNECT routes, implicit and explicit HTTPS proxy ports, and stalled-address-family fallback. ## Review guide 1. `codex-rs/websocket-client/src/lib.rs` defines the small public API and the factory-required policy invariant. 2. `codex-rs/websocket-client/src/dialer.rs` contains DNS, TCP, proxy tunneling, TLS, and WebSocket handshake setup. 3. `codex-rs/websocket-client/src/dialer_tests.rs` verifies the public stream, direct and proxied WSS paths, HTTPS port preservation, and Happy Eyeballs timing. 4. There is intentionally no consumer migration here; #31441 will become the first consumer after this prerequisite merges. ## Test plan - `cargo check -p codex-websocket-client --tests` - `just test -p codex-websocket-client` - `cargo shear` - `just bazel-lock-check`
134 lines
4.8 KiB
Rust
134 lines
4.8 KiB
Rust
//! Proxy-aware WebSocket connection setup shared by Codex API clients.
|
|
|
|
mod dialer;
|
|
|
|
use std::pin::Pin;
|
|
use std::sync::Arc;
|
|
use std::task::Context;
|
|
use std::task::Poll;
|
|
|
|
use codex_http_client::BuildCustomCaTransportError;
|
|
use codex_http_client::HttpClientFactory;
|
|
use codex_http_client::build_rustls_client_config_with_custom_ca;
|
|
use futures::Sink;
|
|
use futures::Stream;
|
|
use rustls::ClientConfig;
|
|
use tokio::io::AsyncRead;
|
|
use tokio::io::AsyncWrite;
|
|
use tokio::net::TcpStream;
|
|
use tokio_tungstenite::MaybeTlsStream;
|
|
use tokio_tungstenite::WebSocketStream as TungsteniteStream;
|
|
use tokio_tungstenite::tungstenite::Error as WebSocketError;
|
|
use tokio_tungstenite::tungstenite::Message;
|
|
use tokio_tungstenite::tungstenite::handshake::client::Request;
|
|
use tokio_tungstenite::tungstenite::handshake::client::Response;
|
|
use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
|
|
|
|
/// Connects WebSockets using the outbound proxy policy resolved by application configuration.
|
|
///
|
|
/// Construct this from the effective [`HttpClientFactory`] rather than selecting proxy behavior at
|
|
/// individual call sites. Each connection resolves its destination through that factory before
|
|
/// opening a socket.
|
|
#[derive(Clone)]
|
|
pub struct WebSocketConnector {
|
|
http_client_factory: HttpClientFactory,
|
|
tls_config: Arc<ClientConfig>,
|
|
}
|
|
|
|
impl WebSocketConnector {
|
|
/// Creates a connector using native roots and any configured Codex custom CA bundle.
|
|
pub fn new(
|
|
http_client_factory: &HttpClientFactory,
|
|
) -> Result<Self, BuildCustomCaTransportError> {
|
|
Ok(Self {
|
|
http_client_factory: http_client_factory.clone(),
|
|
tls_config: build_rustls_client_config_with_custom_ca()?,
|
|
})
|
|
}
|
|
|
|
/// Connects a WebSocket after resolving the request destination through the configured proxy
|
|
/// policy.
|
|
pub async fn connect(
|
|
&self,
|
|
request: Request,
|
|
config: WebSocketConfig,
|
|
) -> Result<(WebSocketConnection, Response), WebSocketError> {
|
|
let proxy_route = self
|
|
.http_client_factory
|
|
.resolve_proxy_route(&request.uri().to_string());
|
|
let (inner, response) =
|
|
dialer::connect(request, config, Arc::clone(&self.tls_config), proxy_route).await?;
|
|
Ok((WebSocketConnection { inner }, response))
|
|
}
|
|
}
|
|
|
|
/// An established WebSocket independent of its direct, proxy, and TLS transport layers.
|
|
///
|
|
/// This implements [`Stream`] and [`Sink`] so protocol clients can process Tungstenite messages
|
|
/// without knowing which concrete network stream route selection produced.
|
|
pub struct WebSocketConnection {
|
|
inner: ConnectionInner,
|
|
}
|
|
|
|
impl Stream for WebSocketConnection {
|
|
type Item = Result<Message, WebSocketError>;
|
|
|
|
fn poll_next(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
|
match &mut self.get_mut().inner {
|
|
ConnectionInner::TransportDefault(stream) => Pin::new(stream).poll_next(context),
|
|
ConnectionInner::Routed(stream) => Pin::new(stream).poll_next(context),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Sink<Message> for WebSocketConnection {
|
|
type Error = WebSocketError;
|
|
|
|
fn poll_ready(
|
|
self: Pin<&mut Self>,
|
|
context: &mut Context<'_>,
|
|
) -> Poll<Result<(), Self::Error>> {
|
|
match &mut self.get_mut().inner {
|
|
ConnectionInner::TransportDefault(stream) => Pin::new(stream).poll_ready(context),
|
|
ConnectionInner::Routed(stream) => Pin::new(stream).poll_ready(context),
|
|
}
|
|
}
|
|
|
|
fn start_send(self: Pin<&mut Self>, message: Message) -> Result<(), Self::Error> {
|
|
match &mut self.get_mut().inner {
|
|
ConnectionInner::TransportDefault(stream) => Pin::new(stream).start_send(message),
|
|
ConnectionInner::Routed(stream) => Pin::new(stream).start_send(message),
|
|
}
|
|
}
|
|
|
|
fn poll_flush(
|
|
self: Pin<&mut Self>,
|
|
context: &mut Context<'_>,
|
|
) -> Poll<Result<(), Self::Error>> {
|
|
match &mut self.get_mut().inner {
|
|
ConnectionInner::TransportDefault(stream) => Pin::new(stream).poll_flush(context),
|
|
ConnectionInner::Routed(stream) => Pin::new(stream).poll_flush(context),
|
|
}
|
|
}
|
|
|
|
fn poll_close(
|
|
self: Pin<&mut Self>,
|
|
context: &mut Context<'_>,
|
|
) -> Poll<Result<(), Self::Error>> {
|
|
match &mut self.get_mut().inner {
|
|
ConnectionInner::TransportDefault(stream) => Pin::new(stream).poll_close(context),
|
|
ConnectionInner::Routed(stream) => Pin::new(stream).poll_close(context),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) enum ConnectionInner {
|
|
TransportDefault(TungsteniteStream<MaybeTlsStream<TcpStream>>),
|
|
Routed(TungsteniteStream<MaybeTlsStream<Box<dyn AsyncIo>>>),
|
|
}
|
|
|
|
/// Async network I/O carried through optional proxy and target TLS handshakes.
|
|
pub(crate) trait AsyncIo: AsyncRead + AsyncWrite + Send + Unpin {}
|
|
|
|
impl<T> AsyncIo for T where T: AsyncRead + AsyncWrite + Send + Unpin {}
|