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/client_tests.rs b/codex-rs/core/src/client_tests.rs index 82c464625d..29d13c724f 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -3,6 +3,8 @@ use super::CompactConversationRequestSettings; use super::ModelClient; use super::PendingUnauthorizedRetry; use super::Prompt; +#[cfg(target_os = "linux")] +use super::RESPONSES_ENDPOINT; use super::UnauthorizedRecoveryExecution; use super::X_CODEX_INSTALLATION_ID_HEADER; use super::X_CODEX_PARENT_THREAD_ID_HEADER; @@ -56,6 +58,8 @@ use serde_json::json; use std::collections::BTreeMap; use std::collections::VecDeque; use std::pin::Pin; +#[cfg(target_os = "linux")] +use std::process::Command; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::AtomicUsize; @@ -81,6 +85,10 @@ use wiremock::matchers::path; const TEST_CHATGPT_ID_TOKEN: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS9hdXRoIjp7ImNoYXRncHRfdXNlcl9pZCI6InVzZXItMTIzNDUiLCJ1c2VyX2lkIjoidXNlci0xMjM0NSIsImNoYXRncHRfcGxhbl90eXBlIjoicHJvIiwiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjb3VudC0xMjMifX0.c2ln"; const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; +#[cfg(target_os = "linux")] +const RESPONSES_PROXY_TEST_CHILD_ENV: &str = "CODEX_RESPONSES_PROXY_TEST_CHILD"; +#[cfg(target_os = "linux")] +const RESPONSES_PROXY_TEST_CHILD_RAN: &str = "responses proxy child ran"; fn test_model_client(session_source: SessionSource) -> ModelClient { test_model_client_with_thread_id(ThreadId::new(), session_source) @@ -107,6 +115,48 @@ fn test_model_client_with_thread_id( ) } +#[cfg(target_os = "linux")] +#[test] +fn responses_transport_uses_enabled_outbound_proxy_policy() { + if std::env::var_os(RESPONSES_PROXY_TEST_CHILD_ENV).is_none() { + let test_name = "client::tests::responses_transport_uses_enabled_outbound_proxy_policy"; + let output = Command::new(std::env::current_exe().expect("current test executable")) + .args(["--exact", test_name, "--nocapture"]) + .env(RESPONSES_PROXY_TEST_CHILD_ENV, "1") + .env("HTTP_PROXY", "://invalid") + .env_remove("http_proxy") + .output() + .expect("proxy-policy subprocess should run"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "proxy-policy subprocess failed\nstdout:\n{}\nstderr:\n{stderr}", + String::from_utf8_lossy(&output.stdout), + ); + assert!( + stderr.contains(RESPONSES_PROXY_TEST_CHILD_RAN), + "proxy-policy subprocess did not execute the expected test\nstderr:\n{stderr}" + ); + return; + } + + eprintln!("{RESPONSES_PROXY_TEST_CHILD_RAN}"); + let api_provider = + create_oss_provider_with_base_url("http://responses-api.invalid/v1", WireApi::Responses) + .to_api_provider(/*auth_mode*/ None) + .expect("test API provider should resolve"); + let client = test_model_client(SessionSource::Cli).with_outbound_proxy_config(Some( + codex_http_client::OutboundProxyConfig::respect_system_proxy(), + )); + + let error = match client.build_responses_transport(&api_provider, RESPONSES_ENDPOINT) { + Err(codex_protocol::error::CodexErr::Io(error)) => error, + Err(error) => panic!("expected an IO error, got {error:?}"), + Ok(_) => panic!("expected invalid enabled proxy configuration"), + }; + assert!(error.to_string().contains("selected for api")); +} + #[tokio::test] async fn compact_uses_bearer_after_agent_identity_session_fallback() -> anyhow::Result<()> { let server = MockServer::start().await; 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/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), ) }