mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +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`
257 lines
8.8 KiB
Rust
257 lines
8.8 KiB
Rust
use std::collections::VecDeque;
|
|
use std::future::Future;
|
|
use std::io;
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use codex_http_client::OutboundProxyRoute;
|
|
use futures::StreamExt;
|
|
use futures::stream::FuturesUnordered;
|
|
use rustls::ClientConfig;
|
|
use rustls::pki_types::ServerName;
|
|
use tokio::net::TcpStream;
|
|
use tokio::time::Instant;
|
|
use tokio::time::sleep_until;
|
|
use tokio_rustls::TlsConnector;
|
|
use tokio_tungstenite::Connector;
|
|
use tokio_tungstenite::client_async_tls_with_config;
|
|
use tokio_tungstenite::connect_async_tls_with_config;
|
|
use tokio_tungstenite::proxy::connect_via_proxy;
|
|
use tokio_tungstenite::tungstenite::Error as WebSocketError;
|
|
use tokio_tungstenite::tungstenite::error::TlsError;
|
|
use tokio_tungstenite::tungstenite::error::UrlError;
|
|
use tokio_tungstenite::tungstenite::handshake::client::Request;
|
|
use tokio_tungstenite::tungstenite::handshake::client::Response;
|
|
use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
|
|
use tokio_tungstenite::tungstenite::proxy::ProxyConfig;
|
|
|
|
use crate::AsyncIo;
|
|
use crate::ConnectionInner;
|
|
|
|
const HAPPY_EYEBALLS_DELAY: Duration = Duration::from_millis(250);
|
|
|
|
pub(crate) async fn connect(
|
|
request: Request,
|
|
config: WebSocketConfig,
|
|
tls_config: Arc<ClientConfig>,
|
|
proxy_route: OutboundProxyRoute,
|
|
) -> Result<(ConnectionInner, Response), WebSocketError> {
|
|
let stream: Box<dyn AsyncIo> = match proxy_route {
|
|
OutboundProxyRoute::TransportDefault => {
|
|
// The workspace enables tokio-tungstenite's `proxy` feature, so its default dialer
|
|
// resolves HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, and NO_PROXY before opening the socket.
|
|
let (stream, response) = connect_async_tls_with_config(
|
|
request,
|
|
Some(config),
|
|
false, // Preserve Tungstenite's recommended Nagle default.
|
|
Some(Connector::Rustls(tls_config)),
|
|
)
|
|
.await?;
|
|
return Ok((ConnectionInner::TransportDefault(stream), response));
|
|
}
|
|
OutboundProxyRoute::Direct => {
|
|
let host = websocket_host(&request)?;
|
|
let port = websocket_port(&request)?;
|
|
Box::new(
|
|
connect_tcp(host_port(host, port))
|
|
.await
|
|
.map_err(WebSocketError::Io)?,
|
|
)
|
|
}
|
|
OutboundProxyRoute::Proxy { url } => {
|
|
let proxy = ProxyEndpoint::parse(&url)?;
|
|
let host = websocket_host(&request)?;
|
|
let port = websocket_port(&request)?;
|
|
let stream = connect_tcp(proxy.config.authority())
|
|
.await
|
|
.map_err(WebSocketError::Io)?;
|
|
let stream: Box<dyn AsyncIo> = if proxy.tls {
|
|
let server_name = ServerName::try_from(proxy.config.host.clone())
|
|
.map_err(|_| WebSocketError::Tls(TlsError::InvalidDnsName))?;
|
|
let stream = TlsConnector::from(Arc::clone(&tls_config))
|
|
.connect(server_name, stream)
|
|
.await
|
|
.map_err(WebSocketError::Io)?;
|
|
Box::new(stream)
|
|
} else {
|
|
Box::new(stream)
|
|
};
|
|
connect_via_proxy(stream, &proxy.config, host, port).await?
|
|
}
|
|
};
|
|
|
|
let (stream, response) = client_async_tls_with_config(
|
|
request,
|
|
stream,
|
|
Some(config),
|
|
Some(Connector::Rustls(tls_config)),
|
|
)
|
|
.await?;
|
|
Ok((ConnectionInner::Routed(stream), response))
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
struct ProxyEndpoint {
|
|
config: ProxyConfig,
|
|
tls: bool,
|
|
}
|
|
|
|
impl ProxyEndpoint {
|
|
fn parse(url: &str) -> Result<Self, WebSocketError> {
|
|
let mut parsed_url = url::Url::parse(url).map_err(|_| invalid_proxy_config())?;
|
|
let tls = parsed_url.scheme() == "https";
|
|
if tls {
|
|
// Capture the HTTPS default before changing schemes: `Url` normalizes default ports,
|
|
// so setting 443 before rewriting to HTTP would discard it and later imply port 80.
|
|
let port = parsed_url
|
|
.port_or_known_default()
|
|
.ok_or_else(invalid_proxy_config)?;
|
|
parsed_url
|
|
.set_scheme("http")
|
|
.map_err(|_| invalid_proxy_config())?;
|
|
parsed_url
|
|
.set_port(Some(port))
|
|
.map_err(|_| invalid_proxy_config())?;
|
|
}
|
|
let config = ProxyConfig::parse(parsed_url.as_str()).map_err(|error| match error {
|
|
WebSocketError::Url(UrlError::UnsupportedProxyScheme) => error,
|
|
_ => invalid_proxy_config(),
|
|
})?;
|
|
Ok(Self { config, tls })
|
|
}
|
|
}
|
|
|
|
fn invalid_proxy_config() -> WebSocketError {
|
|
WebSocketError::Url(UrlError::InvalidProxyConfig("<redacted>".to_string()))
|
|
}
|
|
|
|
fn websocket_host(request: &Request) -> Result<&str, WebSocketError> {
|
|
request
|
|
.uri()
|
|
.host()
|
|
.ok_or(WebSocketError::Url(UrlError::NoHostName))
|
|
}
|
|
|
|
fn websocket_port(request: &Request) -> Result<u16, WebSocketError> {
|
|
request
|
|
.uri()
|
|
.port_u16()
|
|
.or_else(|| match request.uri().scheme_str() {
|
|
Some("ws") => Some(80),
|
|
Some("wss") => Some(443),
|
|
_ => None,
|
|
})
|
|
.ok_or(WebSocketError::Url(UrlError::UnsupportedUrlScheme))
|
|
}
|
|
|
|
fn host_port(host: &str, port: u16) -> String {
|
|
if host.contains(':') && !host.starts_with('[') {
|
|
format!("[{host}]:{port}")
|
|
} else {
|
|
format!("{host}:{port}")
|
|
}
|
|
}
|
|
|
|
async fn connect_tcp(address: String) -> io::Result<TcpStream> {
|
|
let addresses = tokio::net::lookup_host(address).await?.collect::<Vec<_>>();
|
|
connect_happy_eyeballs(addresses, TcpStream::connect).await
|
|
}
|
|
|
|
async fn connect_happy_eyeballs<T, F, Fut>(
|
|
addresses: Vec<SocketAddr>,
|
|
mut connect: F,
|
|
) -> io::Result<T>
|
|
where
|
|
F: FnMut(SocketAddr) -> Fut,
|
|
Fut: Future<Output = io::Result<T>>,
|
|
{
|
|
let mut addresses = addresses.into_iter();
|
|
let Some(first_address) = addresses.next() else {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
"could not resolve to any address",
|
|
));
|
|
};
|
|
|
|
let first_is_ipv4 = first_address.is_ipv4();
|
|
let mut preferred = VecDeque::new();
|
|
let mut alternate = VecDeque::new();
|
|
for address in addresses {
|
|
if address.is_ipv4() == first_is_ipv4 {
|
|
preferred.push_back(address);
|
|
} else {
|
|
alternate.push_back(address);
|
|
}
|
|
}
|
|
|
|
let mut addresses = VecDeque::new();
|
|
while !preferred.is_empty() || !alternate.is_empty() {
|
|
if let Some(address) = alternate.pop_front() {
|
|
addresses.push_back(address);
|
|
}
|
|
if let Some(address) = preferred.pop_front() {
|
|
addresses.push_back(address);
|
|
}
|
|
}
|
|
|
|
let mut attempts = FuturesUnordered::new();
|
|
attempts.push(connect(first_address));
|
|
let mut next_attempt_at = Instant::now() + HAPPY_EYEBALLS_DELAY;
|
|
let mut last_error = None;
|
|
|
|
loop {
|
|
if addresses.is_empty() {
|
|
match attempts.next().await {
|
|
Some(Ok(stream)) => return Ok(stream),
|
|
Some(Err(error)) => {
|
|
if attempts.is_empty() {
|
|
return Err(error);
|
|
}
|
|
last_error = Some(error);
|
|
}
|
|
None => {
|
|
return Err(last_error.unwrap_or_else(|| {
|
|
io::Error::other("connection attempts ended without an error")
|
|
}));
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
tokio::select! {
|
|
result = attempts.next() => {
|
|
match result {
|
|
Some(Ok(stream)) => return Ok(stream),
|
|
Some(Err(error)) => {
|
|
last_error = Some(error);
|
|
let address = take_next_address(&mut addresses)?;
|
|
attempts.push(connect(address));
|
|
next_attempt_at = Instant::now() + HAPPY_EYEBALLS_DELAY;
|
|
}
|
|
None => {
|
|
let address = take_next_address(&mut addresses)?;
|
|
attempts.push(connect(address));
|
|
next_attempt_at = Instant::now() + HAPPY_EYEBALLS_DELAY;
|
|
}
|
|
}
|
|
}
|
|
_ = sleep_until(next_attempt_at) => {
|
|
let address = take_next_address(&mut addresses)?;
|
|
attempts.push(connect(address));
|
|
next_attempt_at = Instant::now() + HAPPY_EYEBALLS_DELAY;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn take_next_address(addresses: &mut VecDeque<SocketAddr>) -> io::Result<SocketAddr> {
|
|
addresses
|
|
.pop_front()
|
|
.ok_or_else(|| io::Error::other("connection address queue unexpectedly empty"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "dialer_tests.rs"]
|
|
mod tests;
|