From d16339257e1d7c4800a0687b7e293c68b79f6991 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 6 Jul 2026 18:35:37 -0700 Subject: [PATCH] core: route Responses API through system proxy --- codex-rs/Cargo.lock | 1 + codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/client.rs | 35 +++++- codex-rs/core/src/config/config_tests.rs | 4 + codex-rs/core/src/config/mod.rs | 6 + codex-rs/core/src/session/session.rs | 1 + codex-rs/core/tests/suite/mod.rs | 1 + .../tests/suite/responses_api_system_proxy.rs | 117 ++++++++++++++++++ codex-rs/login/src/auth/default_client.rs | 41 +++--- 9 files changed, 191 insertions(+), 16 deletions(-) create mode 100644 codex-rs/core/tests/suite/responses_api_system_proxy.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9d89f8eb1f..0d4d3c33bf 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2647,6 +2647,7 @@ dependencies = [ "codex-git-utils", "codex-home", "codex-hooks", + "codex-http-client", "codex-image-generation-extension", "codex-install-context", "codex-login", diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 4435a2eb63..c617684f78 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -49,6 +49,7 @@ codex-shell-command = { workspace = true } codex-execpolicy = { workspace = true } codex-git-utils = { workspace = true } codex-hooks = { workspace = true } +codex-http-client = { workspace = true } codex-install-context = { workspace = true } codex-network-proxy = { workspace = true } codex-otel = { workspace = true } diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 43a758b0e8..c4d5da54cf 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -62,10 +62,13 @@ use codex_api::auth_header_telemetry; use codex_api::build_session_headers; use codex_api::create_text_param_for_request; use codex_api::response_create_client_metadata; +use codex_http_client::ClientRouteClass; +use codex_http_client::OutboundProxyConfig; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::RefreshTokenError; use codex_login::UnauthorizedRecovery; +use codex_login::default_client::build_default_reqwest_client_for_route; use codex_login::default_client::build_reqwest_client; use codex_otel::SessionTelemetry; use codex_otel::current_span_w3c_trace_context; @@ -248,6 +251,7 @@ pub struct ModelClient { state: Arc, agent_identity_policy: AgentIdentityAuthPolicy, prompt_cache_key_override: Option, + outbound_proxy_config: Option, } /// A turn-scoped streaming session created from a [`ModelClient`]. @@ -439,9 +443,18 @@ impl ModelClient { }), agent_identity_policy, prompt_cache_key_override: None, + outbound_proxy_config: None, } } + pub(crate) fn with_outbound_proxy_config( + mut self, + outbound_proxy_config: Option, + ) -> Self { + self.outbound_proxy_config = outbound_proxy_config; + self + } + pub(crate) fn with_prompt_cache_key_override( mut self, prompt_cache_key_override: Option, @@ -532,7 +545,8 @@ impl ModelClient { return Ok(Vec::new()); } let client_setup = self.current_client_setup().await?; - let transport = ReqwestTransport::new(build_reqwest_client()); + let transport = + self.build_responses_transport(&client_setup.api_provider, RESPONSES_COMPACT_ENDPOINT)?; let request_telemetry = Self::build_request_telemetry( session_telemetry, AuthRequestTelemetryContext::new( @@ -938,6 +952,21 @@ impl ModelClient { }) } + fn build_responses_transport( + &self, + api_provider: &ApiProvider, + endpoint: &str, + ) -> Result { + let request_url = api_provider.url_for_path(endpoint); + let client = build_default_reqwest_client_for_route( + &request_url, + ClientRouteClass::Api, + self.outbound_proxy_config.as_ref(), + ) + .map_err(std::io::Error::from)?; + Ok(ReqwestTransport::new(client)) + } + pub(crate) async fn prewarm_auth(&self) -> Result<()> { self.current_client_setup().await.map(|_| ()) } @@ -1372,7 +1401,9 @@ impl ModelClientSession { let mut pending_retry = PendingUnauthorizedRetry::default(); loop { let client_setup = self.client.current_client_setup().await?; - let transport = ReqwestTransport::new(build_reqwest_client()); + let transport = self + .client + .build_responses_transport(&client_setup.api_provider, RESPONSES_ENDPOINT)?; let request_auth_context = AuthRequestTelemetryContext::new( client_setup.auth.as_ref().map(CodexAuth::auth_mode), client_setup.api_auth.as_ref(), diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 6156b9b9ea..f17e8e08c8 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1590,6 +1590,10 @@ respect_system_proxy = true .await?; assert!(config.respect_system_proxy); + assert_eq!( + config.outbound_proxy_config(), + Some(codex_http_client::OutboundProxyConfig::respect_system_proxy()) + ); Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 54fbb97f69..d786fbe19d 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -70,6 +70,7 @@ use codex_features::MultiAgentV2ConfigToml; use codex_features::NetworkProxyConfigToml; use codex_features::TokenBudgetConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; +use codex_http_client::OutboundProxyConfig; use codex_install_context::InstallContext; use codex_login::AuthManagerConfig; use codex_login::AuthRouteConfig; @@ -1481,6 +1482,11 @@ impl Config { .then(AuthRouteConfig::respect_system_proxy) } + pub(crate) fn outbound_proxy_config(&self) -> Option { + self.respect_system_proxy + .then(OutboundProxyConfig::respect_system_proxy) + } + /// Build the plugin-manager input from the effective config. pub fn plugins_config_input(&self) -> PluginsConfigInput { PluginsConfigInput::new( diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 2261eaa495..519151fe5d 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1113,6 +1113,7 @@ impl Session { /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), attestation_provider, ) + .with_outbound_proxy_config(config.outbound_proxy_config()) .with_prompt_cache_key_override( crate::guardian::prompt_cache_key_override_for_review_session( &session_configuration.session_source, diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 3e27463370..1b0de3b264 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -99,6 +99,7 @@ mod request_permissions_tool; mod request_plugin_install; mod request_user_input; mod responses_api_proxy_headers; +mod responses_api_system_proxy; mod responses_lite; mod resume; mod resume_warning; diff --git a/codex-rs/core/tests/suite/responses_api_system_proxy.rs b/codex-rs/core/tests/suite/responses_api_system_proxy.rs new file mode 100644 index 0000000000..0a1f998896 --- /dev/null +++ b/codex-rs/core/tests/suite/responses_api_system_proxy.rs @@ -0,0 +1,117 @@ +//! Verifies that the effective system-proxy feature reaches both HTTP Responses API paths. + +use anyhow::Result; +use codex_config::CONFIG_TOML_FILE; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_compact_json_once; +use core_test_support::responses::mount_sse_once; +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::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use std::process::Command; + +const SUBPROCESS_ENV: &str = "CODEX_RESPONSES_SYSTEM_PROXY_TEST_CHILD"; +const SUBPROCESS_SENTINEL: &str = "responses system proxy child ran"; +const TEST_NAME: &str = + "suite::responses_api_system_proxy::responses_and_compact_use_enabled_system_proxy"; +const TARGET_BASE_URL: &str = "http://responses-api.invalid/v1"; + +#[cfg_attr( + not(target_os = "linux"), + ignore = "system proxy environment fallback is Linux-specific" +)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_and_compact_use_enabled_system_proxy() -> Result<()> { + if std::env::var_os(SUBPROCESS_ENV).is_none() { + return run_test_in_subprocess().await; + } + + eprintln!("{SUBPROCESS_SENTINEL}"); + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex() + .with_pre_build_hook(|codex_home| { + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + "[features]\nrespect_system_proxy = true\n", + ) + .expect("system-proxy feature config should be written"); + }) + .with_config(|config| { + config.model_provider.base_url = Some(TARGET_BASE_URL.to_string()); + }); + let test = builder.build_with_auto_env(&server).await?; + assert!(test.config.respect_system_proxy); + + test.submit_turn("exercise the Responses API proxy route") + .await?; + test.codex.submit(Op::Compact).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + Ok(()) +} + +async fn run_test_in_subprocess() -> Result<()> { + skip_if_no_network!(Ok(())); + + let proxy = start_mock_server().await; + let responses_mock = mount_sse_once( + &proxy, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let compact_mock = mount_compact_json_once( + &proxy, + serde_json::json!({ + "output": [ResponseItem::Compaction { + id: None, + encrypted_content: "encrypted compacted history".to_string(), + internal_chat_message_metadata_passthrough: None, + }] + }), + ) + .await; + + // Reqwest disables its default environment proxy matcher in CGI processes. The route-aware + // client resolves `HTTP_PROXY` explicitly, so this marker makes the test fail if either + // Responses endpoint regresses to the default client. + let output = Command::new(std::env::current_exe()?) + .args(["--exact", TEST_NAME, "--nocapture"]) + .env(SUBPROCESS_ENV, "1") + .env("HTTP_PROXY", proxy.uri()) + .env("REQUEST_METHOD", "GET") + .env_remove("http_proxy") + .env_remove("ALL_PROXY") + .env_remove("all_proxy") + .env_remove("NO_PROXY") + .env_remove("no_proxy") + .output()?; + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "system-proxy subprocess failed\nstdout:\n{}\nstderr:\n{stderr}", + String::from_utf8_lossy(&output.stdout), + ); + assert!( + stderr.contains(SUBPROCESS_SENTINEL), + "system-proxy subprocess did not execute the expected test\nstderr:\n{stderr}" + ); + + assert_eq!(responses_mock.single_request().path(), "/v1/responses"); + assert_eq!( + compact_mock.single_request().path(), + "/v1/responses/compact" + ); + Ok(()) +} diff --git a/codex-rs/login/src/auth/default_client.rs b/codex-rs/login/src/auth/default_client.rs index 1146a89d96..0d7770a8e2 100644 --- a/codex-rs/login/src/auth/default_client.rs +++ b/codex-rs/login/src/auth/default_client.rs @@ -8,8 +8,9 @@ use codex_http_client::BuildCustomCaTransportError; use codex_http_client::BuildRouteAwareHttpClientError; use codex_http_client::ClientRouteClass; use codex_http_client::HttpClient; +use codex_http_client::OutboundProxyConfig; pub use codex_http_client::RequestBuilder as CodexRequestBuilder; -use codex_http_client::build_reqwest_client_for_route; +use codex_http_client::build_reqwest_client_for_route as build_route_aware_reqwest_client; use codex_http_client::build_reqwest_client_with_custom_ca; use codex_http_client::with_chatgpt_cloudflare_cookie_store; use codex_terminal_detection::user_agent; @@ -235,6 +236,28 @@ pub fn try_build_reqwest_client() -> Result, +) -> Result { + if route_config.is_none() || is_sandboxed() { + return Ok(build_reqwest_client()); + } + + build_route_aware_reqwest_client( + default_reqwest_client_builder(), + request_url, + route_class, + route_config, + ) +} + fn default_reqwest_client_builder() -> reqwest::ClientBuilder { let mut builder = reqwest::Client::builder().default_headers(default_headers()); if is_sandboxed() { @@ -248,7 +271,7 @@ pub(crate) fn build_raw_auth_reqwest_client( endpoint: &str, auth_route_config: Option<&AuthRouteConfig>, ) -> Result { - build_reqwest_client_for_route( + build_route_aware_reqwest_client( reqwest::Client::builder(), endpoint, ClientRouteClass::Auth, @@ -261,20 +284,10 @@ pub(crate) fn build_default_auth_reqwest_client( endpoint: &str, auth_route_config: Option<&AuthRouteConfig>, ) -> Result { - let Some(route_config) = auth_route_config.map(AuthRouteConfig::route_config) else { - return Ok(build_reqwest_client()); - }; - - if is_sandboxed() { - // Preserve the sandbox's existing no-proxy policy; sandboxed command egress is routed - // separately through network-proxy. - return Ok(build_reqwest_client()); - } - build_reqwest_client_for_route( - default_reqwest_client_builder(), + build_default_reqwest_client_for_route( endpoint, ClientRouteClass::Auth, - Some(route_config), + auth_route_config.map(AuthRouteConfig::route_config), ) }