From 04148b4fe88ebfbe9ba79762dcf272bd5a7b8f58 Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 23 Sep 2026 15:33:35 -0700 Subject: [PATCH] fix(openai): allow disabling WebSocket proxy discovery --- Cargo.lock | 2 +- crates/agentkit-provider-openai/Cargo.toml | 2 +- crates/agentkit-provider-openai/README.md | 6 +- .../agentkit-provider-openai/src/responses.rs | 16 ++++ .../src/responses/websocket.rs | 8 +- .../src/responses/websocket/tests.rs | 86 +++++++++++++++++++ 6 files changed, 116 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9648c80..90e3112 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -353,7 +353,7 @@ dependencies = [ [[package]] name = "agentkit-provider-openai" -version = "0.10.11" +version = "0.10.12" dependencies = [ "agentkit-adapter-completions", "agentkit-core", diff --git a/crates/agentkit-provider-openai/Cargo.toml b/crates/agentkit-provider-openai/Cargo.toml index ce25adb..d60868d 100644 --- a/crates/agentkit-provider-openai/Cargo.toml +++ b/crates/agentkit-provider-openai/Cargo.toml @@ -7,7 +7,7 @@ edition.workspace = true license.workspace = true repository.workspace = true rust-version.workspace = true -version = "0.10.11" +version = "0.10.12" [dependencies] tokio-tungstenite = { version = "=0.29.0", default-features = false, features = ["handshake"] } diff --git a/crates/agentkit-provider-openai/README.md b/crates/agentkit-provider-openai/README.md index c585af7..d7f1cc6 100644 --- a/crates/agentkit-provider-openai/README.md +++ b/crates/agentkit-provider-openai/README.md @@ -183,7 +183,11 @@ never replayed. Recovery uses the existing retry observations and deadline budge WebSocket upgrades use a dedicated reqwest HTTP/1 client with redirects and implicit HTTP retries disabled, using the existing reqwest TLS stack. A custom `Http` passed to `with_client` applies only to HTTP/SSE, **not** to WebSocket -upgrades; applications requiring custom transport middleware should keep `Http`. +upgrades. Proxy discovery from the environment/system remains enabled by default. +Set `OpenAIResponsesConfig::with_websocket_no_proxy(true)` to disable it for +`WebSocket` and `Auto` upgrades without relaxing the client's hardening or timeouts. +This does not change HTTP/SSE (including Auto fallback): configure its client +separately. Applications requiring custom transport middleware should keep `Http`. Wire contract reference: OpenAI Codex commit [`6824dabe0393337a38cb257d5fe75ae5ca168470`](https://github.com/openai/codex/tree/6824dabe0393337a38cb257d5fe75ae5ca168470), diff --git a/crates/agentkit-provider-openai/src/responses.rs b/crates/agentkit-provider-openai/src/responses.rs index 84d8657..046f5d1 100644 --- a/crates/agentkit-provider-openai/src/responses.rs +++ b/crates/agentkit-provider-openai/src/responses.rs @@ -145,6 +145,7 @@ pub struct OpenAIResponsesConfig { user_agent: Option, originator: Option, transport: OpenAIResponsesTransport, + websocket_no_proxy: bool, } impl fmt::Debug for OpenAIResponsesConfig { @@ -155,6 +156,7 @@ impl fmt::Debug for OpenAIResponsesConfig { .field("endpoint", &self.endpoint) .field("profile", &self.profile) .field("transport", &self.transport) + .field("websocket_no_proxy", &self.websocket_no_proxy) .field("header_names", &self.headers.keys().collect::>()) .field("request_policy", &self.request_policy) .field("reasoning_effort", &self.reasoning_effort) @@ -197,6 +199,7 @@ impl OpenAIResponsesConfig { user_agent: None, originator: None, transport: OpenAIResponsesTransport::Http, + websocket_no_proxy: false, } } @@ -223,6 +226,7 @@ impl OpenAIResponsesConfig { user_agent: None, originator: None, transport: OpenAIResponsesTransport::Http, + websocket_no_proxy: false, } } @@ -232,6 +236,18 @@ impl OpenAIResponsesConfig { self } + /// Disables proxy discovery for WebSocket upgrades when `true`. + /// + /// Defaults to `false`, preserving reqwest's environment/system proxy discovery. + /// Applies to both `WebSocket` and `Auto`, but not HTTP/SSE (including Auto fallback). + /// A custom HTTP client's proxy policy is not inherited by the dedicated WebSocket + /// client. Set this explicitly to preserve a no-proxy policy across transports. + /// HTTP/1, redirect/retry restrictions, and handshake timeouts remain enforced. + pub fn with_websocket_no_proxy(mut self, no_proxy: bool) -> Self { + self.websocket_no_proxy = no_proxy; + self + } + pub fn with_endpoint(mut self, endpoint: impl Into) -> Self { self.endpoint = endpoint.into(); self diff --git a/crates/agentkit-provider-openai/src/responses/websocket.rs b/crates/agentkit-provider-openai/src/responses/websocket.rs index c29ffe8..b06ca27 100644 --- a/crates/agentkit-provider-openai/src/responses/websocket.rs +++ b/crates/agentkit-provider-openai/src/responses/websocket.rs @@ -355,7 +355,13 @@ pub(super) async fn send( .redirect(reqwest::redirect::Policy::none()) .retry(reqwest::retry::never()) .connect_timeout(HANDSHAKE_TIMEOUT) - .timeout(HANDSHAKE_TIMEOUT) + .timeout(HANDSHAKE_TIMEOUT); + let client = if context.config.websocket_no_proxy { + client.no_proxy() + } else { + client + }; + let client = client .build() .map_err(|_| protocol_failure("could not build WebSocket upgrade client"))?; context.tracker.accounting.attempts = context.tracker.accounting.attempts.saturating_add(1); diff --git a/crates/agentkit-provider-openai/src/responses/websocket/tests.rs b/crates/agentkit-provider-openai/src/responses/websocket/tests.rs index 8b6f4c2..50c09df 100644 --- a/crates/agentkit-provider-openai/src/responses/websocket/tests.rs +++ b/crates/agentkit-provider-openai/src/responses/websocket/tests.rs @@ -1697,3 +1697,89 @@ async fn interrupted_missing_previous_backoff_releases_session_without_replay() peer.join().unwrap(); } } + +#[test] +fn websocket_proxy_policy_child_process() { + const ENDPOINT: &str = "AGENTKIT_TEST_WEBSOCKET_PROXY_ENDPOINT"; + if let Ok(endpoint) = std::env::var(ENDPOINT) { + // Only this child inherits the proxy environment; never mutate process globals. + tokio::runtime::Runtime::new().unwrap().block_on(async { + for transport in [ + OpenAIResponsesTransport::WebSocket, + OpenAIResponsesTransport::Auto, + ] { + for no_proxy in [None, Some(false), Some(true)] { + let config = config(&endpoint, transport); + let config = match no_proxy { + Some(value) => config.with_websocket_no_proxy(value), + None => config, + }; + // An explicitly direct HTTP client must not silently change the + // independent WebSocket client's default proxy policy. + let http = Http::new(reqwest::Client::builder().no_proxy().build().unwrap()); + let adapter = OpenAIResponsesAdapter::with_client(config, http); + let mut session = adapter + .start_session(SessionConfig::new("proxy-policy")) + .await + .unwrap(); + let result = session.begin_turn(request(), None).await; + if no_proxy == Some(true) { + let mut turn = result.expect("direct WebSocket upgrade must succeed"); + finished(&drain(&mut turn).await.unwrap()); + } else { + assert!(result.is_err(), "proxy must block the upgrade"); + } + } + } + }); + return; + } + + let (endpoint, direct) = server(|listener| { + for _ in 0..2 { + let mut ws = socket(&listener); + receive(&mut ws); + success(&mut ws); + } + }); + let expected_endpoint = endpoint.clone(); + let (proxy, blocked) = server(move |listener| { + for _ in 0..4 { + let (headers, _) = http(&listener, "403 Forbidden", "proxy blocked upgrade"); + assert!(headers.starts_with(&format!("GET {expected_endpoint} HTTP/1.1\r\n"))); + assert!( + headers + .to_ascii_lowercase() + .contains("upgrade: websocket\r\n") + ); + } + }); + let mut child = std::process::Command::new(std::env::current_exe().unwrap()); + child.args([ + "--exact", + "responses::websocket::tests::websocket_proxy_policy_child_process", + "--nocapture", + ]); + // Clear inherited bypasses and conflicting proxy variables in the child only. + for key in [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + ] { + child.env(key, &proxy); + } + child.env("NO_PROXY", "").env("no_proxy", ""); + child.env_remove("REQUEST_METHOD"); + let output = child.env(ENDPOINT, endpoint).output().unwrap(); + assert!( + output.status.success(), + "proxy policy child failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + blocked.join().unwrap(); + direct.join().unwrap(); +}