diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ec4d6bb803..2557fd929f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2786,6 +2786,7 @@ dependencies = [ "codex-exec-server", "codex-git-utils", "codex-hooks", + "codex-http-client", "codex-login", "codex-mcp", "codex-model-provider", @@ -2799,10 +2800,10 @@ dependencies = [ "codex-utils-plugins", "dirs", "flate2", + "http 1.4.0", "libc", "pretty_assertions", "regex", - "reqwest 0.12.28", "semver", "serde", "serde_json", diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 688cfae917..2c66a0b283 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -581,9 +581,10 @@ impl PluginRequestProcessor { !explicit_marketplace_kinds && config.features.enabled(Feature::RemotePlugin); let use_remote_global_catalog = include_global_remote && auth_mode.is_some_and(DomainAuthMode::uses_codex_backend); - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = RemotePluginServiceConfig::new( + config.chatgpt_base_url.clone(), + config.http_client_factory(), + ); let refresh_global_remote_catalog_cache = use_remote_global_catalog && codex_core_plugins::remote::has_cached_global_remote_plugin_catalog( config.codex_home.as_path(), @@ -1030,9 +1031,10 @@ impl PluginRequestProcessor { ); let share_context = match share_context { Some(context) => { - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = RemotePluginServiceConfig::new( + config.chatgpt_base_url.clone(), + config.http_client_factory(), + ); match codex_core_plugins::remote::fetch_remote_plugin_share_context( &remote_plugin_service_config, auth.as_ref(), @@ -1138,9 +1140,10 @@ impl PluginRequestProcessor { "remote plugin read is not enabled for marketplace {remote_marketplace_name}" ))); } - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = RemotePluginServiceConfig::new( + config.chatgpt_base_url.clone(), + config.http_client_factory(), + ); validate_remote_plugin_id(&plugin_name)?; let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail( &remote_plugin_service_config, @@ -1196,9 +1199,10 @@ impl PluginRequestProcessor { } let auth = self.auth_manager.auth().await; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = RemotePluginServiceConfig::new( + config.chatgpt_base_url.clone(), + config.http_client_factory(), + ); let remote_skill_detail = codex_core_plugins::remote::fetch_remote_plugin_skill_detail( &remote_plugin_service_config, auth.as_ref(), @@ -1249,9 +1253,10 @@ impl PluginRequestProcessor { validate_client_plugin_share_targets(share_targets)?; } - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = RemotePluginServiceConfig::new( + config.chatgpt_base_url.clone(), + config.http_client_factory(), + ); let access_policy = codex_core_plugins::remote::RemotePluginShareAccessPolicy { discoverability: discoverability.map(remote_plugin_share_discoverability), share_targets: share_targets.map(remote_plugin_share_targets), @@ -1292,9 +1297,10 @@ impl PluginRequestProcessor { } validate_client_plugin_share_targets(&share_targets)?; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = RemotePluginServiceConfig::new( + config.chatgpt_base_url.clone(), + config.http_client_factory(), + ); let result = codex_core_plugins::remote::update_remote_plugin_share_targets( &remote_plugin_service_config, auth.as_ref(), @@ -1322,9 +1328,10 @@ impl PluginRequestProcessor { _params: PluginShareListParams, ) -> Result { let (config, auth) = self.load_plugin_share_config_and_auth().await?; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = RemotePluginServiceConfig::new( + config.chatgpt_base_url.clone(), + config.http_client_factory(), + ); let data = codex_core_plugins::remote::list_remote_plugin_shares( &remote_plugin_service_config, auth.as_ref(), @@ -1361,9 +1368,10 @@ impl PluginRequestProcessor { return Err(invalid_request("invalid remote plugin id")); } - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = RemotePluginServiceConfig::new( + config.chatgpt_base_url.clone(), + config.http_client_factory(), + ); let result = codex_core_plugins::remote::checkout_remote_plugin_share( &remote_plugin_service_config, auth.as_ref(), @@ -1394,9 +1402,10 @@ impl PluginRequestProcessor { return Err(invalid_request("invalid remote plugin id")); } - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = RemotePluginServiceConfig::new( + config.chatgpt_base_url.clone(), + config.http_client_factory(), + ); codex_core_plugins::remote::delete_remote_plugin_share( &remote_plugin_service_config, auth.as_ref(), @@ -1531,9 +1540,10 @@ impl PluginRequestProcessor { validate_remote_plugin_id(&remote_plugin_id)?; let auth = self.auth_manager.auth().await; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = RemotePluginServiceConfig::new( + config.chatgpt_base_url.clone(), + config.http_client_factory(), + ); let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail_with_download_urls( &remote_plugin_service_config, @@ -1606,6 +1616,7 @@ impl PluginRequestProcessor { })?; let result = codex_core_plugins::remote_bundle::download_and_install_remote_plugin_bundle( + &remote_plugin_service_config, config.codex_home.to_path_buf(), validated_bundle, ) @@ -2020,9 +2031,10 @@ impl PluginRequestProcessor { validate_remote_plugin_id(&plugin_id)?; let auth = self.auth_manager.auth().await; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = RemotePluginServiceConfig::new( + config.chatgpt_base_url.clone(), + config.http_client_factory(), + ); let uninstall_target = codex_core_plugins::remote::resolve_remote_plugin_uninstall_target( &remote_plugin_service_config, auth.as_ref(), @@ -2296,6 +2308,7 @@ fn remote_plugin_catalog_error_type(err: &RemotePluginCatalogError) -> &'static RemotePluginCatalogError::AuthRequired => "remote_catalog_auth_required", RemotePluginCatalogError::UnsupportedAuthMode => "remote_catalog_unsupported_auth_mode", RemotePluginCatalogError::AuthToken(_) => "remote_catalog_auth_token", + RemotePluginCatalogError::HttpClient { .. } => "remote_catalog_http_client", RemotePluginCatalogError::Request { .. } => "remote_catalog_request", RemotePluginCatalogError::UnexpectedStatus { .. } => "remote_catalog_unexpected_status", RemotePluginCatalogError::Decode { .. } => "remote_catalog_decode", @@ -2342,6 +2355,7 @@ fn remote_plugin_bundle_install_error_type(err: &RemotePluginBundleInstallError) "remote_bundle_unsupported_download_url_scheme" } RemotePluginBundleInstallError::InvalidPluginId { .. } => "remote_bundle_invalid_plugin_id", + RemotePluginBundleInstallError::HttpClient { .. } => "remote_bundle_http_client", RemotePluginBundleInstallError::DownloadRequest { .. } => "remote_bundle_download_request", RemotePluginBundleInstallError::DownloadStatus { .. } => "remote_bundle_download_status", RemotePluginBundleInstallError::DownloadBody { .. } => "remote_bundle_download_body", @@ -2377,6 +2391,7 @@ fn remote_plugin_catalog_error_to_jsonrpc( | RemotePluginCatalogError::ArchiveTooLarge { .. } | RemotePluginCatalogError::UnknownMarketplace { .. } => invalid_request(message), RemotePluginCatalogError::AuthToken(_) + | RemotePluginCatalogError::HttpClient { .. } | RemotePluginCatalogError::Request { .. } | RemotePluginCatalogError::UnexpectedStatus { .. } | RemotePluginCatalogError::Decode { .. } diff --git a/codex-rs/core-plugins/Cargo.toml b/codex-rs/core-plugins/Cargo.toml index e85d16a126..1e9b2e9e58 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -22,6 +22,7 @@ codex-core-skills = { workspace = true } codex-exec-server = { workspace = true } codex-git-utils = { workspace = true } codex-hooks = { workspace = true } +codex-http-client = { workspace = true } codex-login = { workspace = true } codex-mcp = { workspace = true } codex-model-provider = { workspace = true } @@ -36,7 +37,7 @@ codex-utils-plugins = { workspace = true } chrono = { workspace = true } dirs = { workspace = true } flate2 = { workspace = true } -reqwest = { workspace = true } +http = { workspace = true } regex = { workspace = true } semver = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/core-plugins/src/discoverable_tests.rs b/codex-rs/core-plugins/src/discoverable_tests.rs index 48482ed430..1dd88969ac 100644 --- a/codex-rs/core-plugins/src/discoverable_tests.rs +++ b/codex-rs/core-plugins/src/discoverable_tests.rs @@ -851,9 +851,10 @@ plugins = true let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); fetch_and_cache_global_remote_plugin_catalog( codex_home.path(), - &RemotePluginServiceConfig { - chatgpt_base_url: plugins.chatgpt_base_url.clone(), - }, + &RemotePluginServiceConfig::new( + plugins.chatgpt_base_url.clone(), + crate::test_support::test_http_client_factory(), + ), Some(&auth), ) .await diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 16fb4c8663..eda805d6d3 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -69,6 +69,7 @@ use codex_core_skills::SkillMetadata; use codex_core_skills::config_rules::SkillConfigRules; use codex_core_skills::config_rules::skill_config_rules_from_stack; use codex_hooks::plugin_hook_declarations; +use codex_http_client::HttpClientFactory; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_plugin::AppConnectorId; @@ -110,6 +111,7 @@ pub struct PluginsConfigInput { pub plugins_enabled: bool, pub remote_plugin_enabled: bool, pub chatgpt_base_url: String, + http_client_factory: HttpClientFactory, } impl PluginsConfigInput { @@ -118,14 +120,24 @@ impl PluginsConfigInput { plugins_enabled: bool, remote_plugin_enabled: bool, chatgpt_base_url: String, + http_client_factory: HttpClientFactory, ) -> Self { Self { config_layer_stack, plugins_enabled, remote_plugin_enabled, chatgpt_base_url, + http_client_factory, } } + + /// Builds route-aware service state for remote plugin requests. + pub fn remote_plugin_service_config(&self) -> RemotePluginServiceConfig { + RemotePluginServiceConfig::new( + self.chatgpt_base_url.clone(), + self.http_client_factory.clone(), + ) + } } /// Inputs used to select endpoint-backed plugin install candidates. @@ -223,9 +235,7 @@ struct ConfiguredMarketplaceUpgradeState { } fn remote_plugin_service_config(config: &PluginsConfigInput) -> RemotePluginServiceConfig { - RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - } + config.remote_plugin_service_config() } fn featured_plugin_ids_cache_key( @@ -1942,7 +1952,7 @@ impl PluginsManager { let use_remote_global_catalog = config.remote_plugin_enabled && auth_manager.current_auth_uses_codex_backend(); if !use_remote_global_catalog { - self.start_curated_repo_sync(); + self.start_curated_repo_sync(config.http_client_factory.clone()); } let should_spawn_marketplace_auto_upgrade = { let mut state = match self.configured_marketplace_upgrade_state.write() { @@ -2298,7 +2308,7 @@ impl PluginsManager { } } - fn start_curated_repo_sync(self: &Arc) { + fn start_curated_repo_sync(self: &Arc, http_client_factory: HttpClientFactory) { if CURATED_REPO_SYNC_STARTED.swap(true, Ordering::SeqCst) { return; } @@ -2306,8 +2316,8 @@ impl PluginsManager { let codex_home = self.codex_home.clone(); if let Err(err) = std::thread::Builder::new() .name("plugins-curated-repo-sync".to_string()) - .spawn( - move || match sync_openai_plugins_repo(codex_home.as_path()) { + .spawn(move || { + match sync_openai_plugins_repo(codex_home.as_path(), http_client_factory) { Ok(curated_plugin_version) => { let configured_curated_plugin_ids = configured_curated_plugin_ids_from_codex_home(codex_home.as_path()); @@ -2331,8 +2341,8 @@ impl PluginsManager { CURATED_REPO_SYNC_STARTED.store(false, Ordering::SeqCst); warn!("failed to sync curated plugins repo: {err}"); } - }, - ) + } + }) { CURATED_REPO_SYNC_STARTED.store(false, Ordering::SeqCst); warn!("failed to start curated plugins repo sync task: {err}"); @@ -2718,6 +2728,7 @@ fn remote_plugin_mutation_error_type(err: &RemotePluginMutationError) -> &'stati RemotePluginMutationError::AuthToken(_) => "remote_mutation_auth_token", RemotePluginMutationError::InvalidBaseUrl(_) => "remote_mutation_invalid_base_url", RemotePluginMutationError::InvalidBaseUrlPath => "remote_mutation_invalid_base_url_path", + RemotePluginMutationError::HttpClient { .. } => "remote_mutation_http_client", RemotePluginMutationError::Request { .. } => "remote_mutation_request", RemotePluginMutationError::UnexpectedStatus { .. } => "remote_mutation_unexpected_status", RemotePluginMutationError::Decode { .. } => "remote_mutation_decode", diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 5e743416f2..ac5b046bc7 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -20,6 +20,7 @@ use crate::startup_sync::curated_plugins_repo_path; use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION; use crate::test_support::TEST_CURATED_PLUGIN_SHA; use crate::test_support::load_plugins_config as load_plugins_config_input; +use crate::test_support::test_http_client_factory; use crate::test_support::write_curated_plugin; use crate::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha; use crate::test_support::write_file; @@ -110,6 +111,7 @@ fn plugins_config_input_with_requirements( /*plugins_enabled*/ true, /*remote_plugin_enabled*/ false, String::new(), + test_http_client_factory(), ) } @@ -2477,6 +2479,7 @@ async fn plugin_cache_ignores_unrelated_session_overrides() { /*plugins_enabled*/ true, /*remote_plugin_enabled*/ false, "https://chatgpt.com".to_string(), + test_http_client_factory(), ) }; let manager = PluginsManager::new(codex_home.path().to_path_buf()); diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index b9b94f4ded..1602fedfd7 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -9,8 +9,13 @@ use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInstallPolicySource; use codex_app_server_protocol::PluginInterface; use codex_app_server_protocol::SkillInterface; +use codex_http_client::ClientRouteClass; +use codex_http_client::HttpClient; +use codex_http_client::HttpClientFactory; +use codex_http_client::RequestBuilder; +use codex_http_client::RouteAwareClientPool; +use codex_http_client::RouteAwareClientPoolError; use codex_login::CodexAuth; -use codex_login::default_client::build_reqwest_client; use codex_plugin::AppConnectorId; use codex_plugin::AppDeclaration; use codex_plugin::PluginCapabilitySummary; @@ -18,7 +23,7 @@ use codex_plugin::PluginId; use codex_plugin::app_connector_ids_from_declarations; use codex_plugin::prompt_safe_plugin_description; use codex_utils_absolute_path::AbsolutePathBuf; -use reqwest::RequestBuilder; +use http::StatusCode; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; @@ -118,11 +123,59 @@ const REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER: [(&str, &str); 6] = [ ), ]; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone)] pub struct RemotePluginServiceConfig { pub chatgpt_base_url: String, + http_client_factory: HttpClientFactory, + http_clients: RouteAwareClientPool, } +impl RemotePluginServiceConfig { + /// Creates remote plugin service state from the effective application HTTP configuration. + /// + /// Keeping the factory mandatory ensures every catalog, mutation, upload, and bundle request + /// follows the same outbound proxy policy. + pub fn new(chatgpt_base_url: String, http_client_factory: HttpClientFactory) -> Self { + let http_clients = RouteAwareClientPool::with_chatgpt_cloudflare_cookies( + http_client_factory.clone(), + ClientRouteClass::Api, + ); + Self { + chatgpt_base_url, + http_client_factory, + http_clients, + } + } + + pub(crate) async fn http_client_for_url( + &self, + request_url: &str, + ) -> Result { + self.http_clients.client_for_url(request_url).await + } + + async fn catalog_client_for_url( + &self, + request_url: &str, + ) -> Result { + self.http_client_for_url(request_url) + .await + .map_err(|source| RemotePluginCatalogError::HttpClient { + url: request_url.to_string(), + source, + }) + } +} + +impl PartialEq for RemotePluginServiceConfig { + fn eq(&self, other: &Self) -> bool { + self.chatgpt_base_url == other.chatgpt_base_url + && self.http_client_factory == other.http_client_factory + } +} + +impl Eq for RemotePluginServiceConfig {} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePluginUninstallTarget { pub plugin_id: PluginId, @@ -305,17 +358,24 @@ pub enum RemotePluginCatalogError { #[error("failed to read auth token for remote plugin catalog: {0}")] AuthToken(#[source] std::io::Error), + #[error("failed to build HTTP client for remote plugin request to {url}: {source}")] + HttpClient { + url: String, + #[source] + source: RouteAwareClientPoolError, + }, + #[error("failed to send remote plugin catalog request to {url}: {source}")] Request { url: String, #[source] - source: reqwest::Error, + source: codex_http_client::HttpError, }, #[error("remote plugin catalog request to {url} failed with status {status}: {body}")] UnexpectedStatus { url: String, - status: reqwest::StatusCode, + status: StatusCode, body: String, }, @@ -850,11 +910,13 @@ pub async fn fetch_recommended_plugins( ) -> Result { let auth = ensure_chatgpt_auth(auth)?; let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let url = format!("{base_url}/ps/plugins/suggested"); - let client = build_reqwest_client(); - let request = authenticated_request(client.get(&url), auth)? - .timeout(RECOMMENDED_PLUGINS_TIMEOUT) - .query(&[("scope", "GLOBAL")]); + let mut url = Url::parse(&format!("{base_url}/ps/plugins/suggested")) + .map_err(RemotePluginCatalogError::InvalidBaseUrl)?; + url.query_pairs_mut().append_pair("scope", "GLOBAL"); + let url = url.to_string(); + let client = config.catalog_client_for_url(&url).await?; + let request = + authenticated_request(client.get(&url), auth)?.timeout(RECOMMENDED_PLUGINS_TIMEOUT); let response: RecommendedPluginsResponse = send_and_decode(request, &url).await?; Ok(recommended_plugins_mode(response)) } @@ -1160,7 +1222,7 @@ pub async fn fetch_remote_plugin_skill_detail( } let url = remote_plugin_skill_detail_url(config, plugin_id, skill_name)?; - let client = build_reqwest_client(); + let client = config.catalog_client_for_url(&url).await?; let request = authenticated_request(client.get(&url), auth)?; let response: RemotePluginSkillDetailResponse = send_and_decode(request, &url).await?; if response.plugin_id != plugin_id { @@ -1315,14 +1377,13 @@ pub async fn install_remote_plugin( // marketplace name is not validated before sending the install mutation. let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let url = format!("{base_url}/ps/plugins/{plugin_id}/install"); - let client = build_reqwest_client(); - let request = authenticated_request( - client - .post(&url) - .query(&[("includeAppsNeedingAuth", "true")]), - auth, - )?; + let mut url = Url::parse(&format!("{base_url}/ps/plugins/{plugin_id}/install")) + .map_err(RemotePluginCatalogError::InvalidBaseUrl)?; + url.query_pairs_mut() + .append_pair("includeAppsNeedingAuth", "true"); + let url = url.to_string(); + let client = config.catalog_client_for_url(&url).await?; + let request = authenticated_request(client.post(&url), auth)?; let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?; if response.id != plugin_id { return Err(RemotePluginCatalogError::UnexpectedPluginId { @@ -1409,7 +1470,7 @@ pub async fn uninstall_remote_plugin( let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/{remote_plugin_id}/uninstall"); - let client = build_reqwest_client(); + let client = config.catalog_client_for_url(&url).await?; let request = authenticated_request(client.post(&url), auth)?; let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?; if response.id != remote_plugin_id { @@ -1827,17 +1888,20 @@ async fn get_remote_plugin_list_page( collection: Option<&str>, ) -> Result { let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let url = format!("{base_url}/ps/plugins/list"); - let client = build_reqwest_client(); - let mut request = authenticated_request(client.get(&url), auth)?; - request = request.query(&[("scope", scope.api_value())]); - request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]); + let mut url = Url::parse(&format!("{base_url}/ps/plugins/list")) + .map_err(RemotePluginCatalogError::InvalidBaseUrl)?; + url.query_pairs_mut() + .append_pair("scope", scope.api_value()) + .append_pair("limit", &REMOTE_PLUGIN_LIST_PAGE_LIMIT.to_string()); if let Some(collection) = collection { - request = request.query(&[("collection", collection)]); + url.query_pairs_mut().append_pair("collection", collection); } if let Some(page_token) = page_token { - request = request.query(&[("pageToken", page_token)]); + url.query_pairs_mut().append_pair("pageToken", page_token); } + let url = url.to_string(); + let client = config.catalog_client_for_url(&url).await?; + let request = authenticated_request(client.get(&url), auth)?; send_and_decode(request, &url).await } @@ -1847,13 +1911,16 @@ async fn get_remote_shared_workspace_plugins_page( page_token: Option<&str>, ) -> Result { let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let url = format!("{base_url}/ps/plugins/workspace/shared"); - let client = build_reqwest_client(); - let mut request = authenticated_request(client.get(&url), auth)?; - request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]); + let mut url = Url::parse(&format!("{base_url}/ps/plugins/workspace/shared")) + .map_err(RemotePluginCatalogError::InvalidBaseUrl)?; + url.query_pairs_mut() + .append_pair("limit", &REMOTE_PLUGIN_LIST_PAGE_LIMIT.to_string()); if let Some(page_token) = page_token { - request = request.query(&[("pageToken", page_token)]); + url.query_pairs_mut().append_pair("pageToken", page_token); } + let url = url.to_string(); + let client = config.catalog_client_for_url(&url).await?; + let request = authenticated_request(client.get(&url), auth)?; send_and_decode(request, &url).await } @@ -1865,16 +1932,20 @@ async fn get_remote_plugin_installed_page( include_download_urls: bool, ) -> Result { let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let url = format!("{base_url}/ps/plugins/installed"); - let client = build_reqwest_client(); - let mut request = authenticated_request(client.get(&url), auth)?; - request = request.query(&[("scope", scope.api_value())]); + let mut url = Url::parse(&format!("{base_url}/ps/plugins/installed")) + .map_err(RemotePluginCatalogError::InvalidBaseUrl)?; + url.query_pairs_mut() + .append_pair("scope", scope.api_value()); if include_download_urls { - request = request.query(&[("includeDownloadUrls", true)]); + url.query_pairs_mut() + .append_pair("includeDownloadUrls", "true"); } if let Some(page_token) = page_token { - request = request.query(&[("pageToken", page_token)]); + url.query_pairs_mut().append_pair("pageToken", page_token); } + let url = url.to_string(); + let client = config.catalog_client_for_url(&url).await?; + let request = authenticated_request(client.get(&url), auth)?; send_and_decode(request, &url).await } @@ -1885,12 +1956,15 @@ async fn fetch_plugin_detail( include_download_urls: bool, ) -> Result { let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let url = format!("{base_url}/ps/plugins/{plugin_id}"); - let client = build_reqwest_client(); - let mut request = authenticated_request(client.get(&url), auth)?; + let mut url = Url::parse(&format!("{base_url}/ps/plugins/{plugin_id}")) + .map_err(RemotePluginCatalogError::InvalidBaseUrl)?; if include_download_urls { - request = request.query(&[("includeDownloadUrls", true)]); + url.query_pairs_mut() + .append_pair("includeDownloadUrls", "true"); } + let url = url.to_string(); + let client = config.catalog_client_for_url(&url).await?; + let request = authenticated_request(client.get(&url), auth)?; send_and_decode(request, &url).await } diff --git a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs index 2a23b066ed..24704acf6a 100644 --- a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs +++ b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs @@ -248,6 +248,7 @@ pub async fn sync_remote_installed_plugin_bundles_once( }; match crate::remote_bundle::download_and_install_remote_plugin_bundle( + config, codex_home.clone(), bundle, ) @@ -542,9 +543,10 @@ mod tests { .expect(1) .mount(&server) .await; - let config = RemotePluginServiceConfig { - chatgpt_base_url: format!("{}/backend-api", server.uri()), - }; + let config = RemotePluginServiceConfig::new( + format!("{}/backend-api", server.uri()), + crate::test_support::test_http_client_factory(), + ); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let outcome = sync_remote_installed_plugin_bundles_once( diff --git a/codex-rs/core-plugins/src/remote/share.rs b/codex-rs/core-plugins/src/remote/share.rs index c400d27c47..6ef5aa88ed 100644 --- a/codex-rs/core-plugins/src/remote/share.rs +++ b/codex-rs/core-plugins/src/remote/share.rs @@ -1,17 +1,17 @@ use super::*; use crate::plugin_bundle_archive::PluginBundlePackError; use crate::plugin_bundle_archive::pack_plugin_bundle_tar_gz; +use codex_http_client::RequestBuilder; use codex_login::CodexAuth; -use codex_login::default_client::build_reqwest_client; use codex_utils_absolute_path::AbsolutePathBuf; -use reqwest::RequestBuilder; -use reqwest::StatusCode; +use http::StatusCode; use serde::Deserialize; use serde::Serialize; use std::collections::BTreeMap; use std::io; use std::path::Path; use tracing::warn; +use url::Url; mod checkout; mod local_paths; @@ -163,7 +163,7 @@ pub async fn save_remote_plugin_share( let etag = upload .etag .ok_or(RemotePluginCatalogError::MissingUploadEtag)?; - put_workspace_plugin_upload(&upload.upload_url, archive_bytes).await?; + put_workspace_plugin_upload(config, &upload.upload_url, archive_bytes).await?; let share_targets = access_policy.share_targets; let share_targets = ensure_unlisted_workspace_target(auth, access_policy.discoverability, share_targets)?; @@ -279,7 +279,7 @@ pub async fn delete_remote_plugin_share( let auth = ensure_chatgpt_auth(auth)?; let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/public/plugins/workspace/{remote_plugin_id}"); - let client = build_reqwest_client(); + let client = config.catalog_client_for_url(&url).await?; let request = authenticated_request(client.delete(&url), auth)?; send_and_expect_status(request, &url, &[StatusCode::NO_CONTENT]).await?; if let Err(err) = local_paths::remove_plugin_share_local_path(codex_home, remote_plugin_id) { @@ -312,7 +312,7 @@ pub async fn update_remote_plugin_share_targets( .unwrap_or_default(); let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/{remote_plugin_id}/shares"); - let client = build_reqwest_client(); + let client = config.catalog_client_for_url(&url).await?; let request = authenticated_request(client.put(&url), auth)?.json( &RemotePluginShareUpdateTargetsRequest { discoverability, @@ -377,13 +377,16 @@ async fn get_created_workspace_plugins_page( page_token: Option<&str>, ) -> Result { let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let url = format!("{base_url}/ps/plugins/workspace/created"); - let client = build_reqwest_client(); - let mut request = authenticated_request(client.get(&url), auth)?; - request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]); + let mut url = Url::parse(&format!("{base_url}/ps/plugins/workspace/created")) + .map_err(RemotePluginCatalogError::InvalidBaseUrl)?; + url.query_pairs_mut() + .append_pair("limit", &REMOTE_PLUGIN_LIST_PAGE_LIMIT.to_string()); if let Some(page_token) = page_token { - request = request.query(&[("pageToken", page_token)]); + url.query_pairs_mut().append_pair("pageToken", page_token); } + let url = url.to_string(); + let client = config.catalog_client_for_url(&url).await?; + let request = authenticated_request(client.get(&url), auth)?; send_and_decode(request, &url).await } @@ -396,7 +399,7 @@ async fn create_workspace_plugin_upload( ) -> Result { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/public/plugins/workspace/upload-url"); - let client = build_reqwest_client(); + let client = config.catalog_client_for_url(&url).await?; let request = authenticated_request(client.post(&url), auth)?.json( &RemoteWorkspacePluginUploadUrlRequest { filename, @@ -409,10 +412,11 @@ async fn create_workspace_plugin_upload( } async fn put_workspace_plugin_upload( + config: &RemotePluginServiceConfig, upload_url: &str, archive_bytes: Vec, ) -> Result<(), RemotePluginCatalogError> { - let client = build_reqwest_client(); + let client = config.catalog_client_for_url(upload_url).await?; let request = client .put(upload_url) .timeout(REMOTE_PLUGIN_CATALOG_TIMEOUT) @@ -450,7 +454,7 @@ async fn finalize_workspace_plugin_upload( } else { format!("{base_url}/public/plugins/workspace") }; - let client = build_reqwest_client(); + let client = config.catalog_client_for_url(&url).await?; let request = authenticated_request(client.post(&url), auth)?.json(&body); send_and_decode(request, &url).await } diff --git a/codex-rs/core-plugins/src/remote/share/checkout.rs b/codex-rs/core-plugins/src/remote/share/checkout.rs index 6b12d2588d..f846a9b90e 100644 --- a/codex-rs/core-plugins/src/remote/share/checkout.rs +++ b/codex-rs/core-plugins/src/remote/share/checkout.rs @@ -93,6 +93,7 @@ pub async fn checkout_remote_plugin_share( )) })?; crate::remote_bundle::download_and_extract_remote_plugin_bundle_to_path( + config, bundle, local_plugin_path.clone(), ) diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index e9887370c5..a92a659e63 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -23,9 +23,10 @@ use wiremock::matchers::query_param; use wiremock::matchers::query_param_is_missing; fn test_config(server: &MockServer) -> RemotePluginServiceConfig { - RemotePluginServiceConfig { - chatgpt_base_url: format!("{}/backend-api", server.uri()), - } + RemotePluginServiceConfig::new( + format!("{}/backend-api", server.uri()), + crate::test_support::test_http_client_factory(), + ) } fn test_auth() -> CodexAuth { diff --git a/codex-rs/core-plugins/src/remote_bundle.rs b/codex-rs/core-plugins/src/remote_bundle.rs index 7a302f2f36..3a0ae1ee5a 100644 --- a/codex-rs/core-plugins/src/remote_bundle.rs +++ b/codex-rs/core-plugins/src/remote_bundle.rs @@ -1,18 +1,19 @@ use crate::plugin_bundle_archive::PluginBundleUnpackError; use crate::plugin_bundle_archive::unpack_plugin_bundle_tar_gz; use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; +use crate::remote::RemotePluginServiceConfig; use crate::store::PluginInstallResult; use crate::store::PluginStore; use crate::store::PluginStoreError; use crate::store::error_context_sub_error_type; use crate::store::validate_plugin_version_segment; -use codex_login::default_client::build_reqwest_client; +use codex_http_client::HttpResponse; +use codex_http_client::RouteAwareClientPoolError; use codex_plugin::PluginId; use codex_plugin::PluginIdError; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_plugins::find_plugin_manifest_path; -use reqwest::Response; -use reqwest::StatusCode; +use http::StatusCode; use serde_json::Value as JsonValue; use std::fs; use std::io; @@ -83,11 +84,18 @@ pub enum RemotePluginBundleInstallError { source: PluginIdError, }, + #[error("failed to build HTTP client for remote plugin bundle download from {url}: {source}")] + HttpClient { + url: String, + #[source] + source: RouteAwareClientPoolError, + }, + #[error("failed to send remote plugin bundle download request to {url}: {source}")] DownloadRequest { url: String, #[source] - source: reqwest::Error, + source: codex_http_client::HttpError, }, #[error("remote plugin bundle download from {url} failed with status {status}: {body}")] @@ -101,7 +109,7 @@ pub enum RemotePluginBundleInstallError { DownloadBody { url: String, #[source] - source: reqwest::Error, + source: codex_http_client::HttpError, }, #[error("remote plugin bundle download from {url} exceeded maximum size of {max_bytes} bytes")] @@ -144,6 +152,7 @@ impl RemotePluginBundleInstallError { | Self::InvalidBundleDownloadUrl { .. } | Self::UnsupportedBundleDownloadUrlScheme { .. } | Self::InvalidPluginId { .. } + | Self::HttpClient { .. } | Self::DownloadRequest { .. } | Self::DownloadStatus { .. } | Self::DownloadBody { .. } @@ -247,10 +256,12 @@ fn is_loopback_url(url: &Url) -> bool { } pub async fn download_and_install_remote_plugin_bundle( + config: &RemotePluginServiceConfig, codex_home: PathBuf, bundle: ValidatedRemotePluginBundle, ) -> Result { let bundle_bytes = download_remote_plugin_bundle_with_limit( + config, &bundle.bundle_download_url, /*max_bytes*/ REMOTE_PLUGIN_BUNDLE_MAX_DOWNLOAD_BYTES, ) @@ -267,10 +278,12 @@ pub async fn download_and_install_remote_plugin_bundle( } pub(crate) async fn download_and_extract_remote_plugin_bundle_to_path( + config: &RemotePluginServiceConfig, bundle: ValidatedRemotePluginBundle, destination: AbsolutePathBuf, ) -> Result { let bundle_bytes = download_remote_plugin_bundle_with_limit( + config, &bundle.bundle_download_url, /*max_bytes*/ REMOTE_PLUGIN_BUNDLE_MAX_DOWNLOAD_BYTES, ) @@ -287,10 +300,17 @@ pub(crate) async fn download_and_extract_remote_plugin_bundle_to_path( } async fn download_remote_plugin_bundle_with_limit( + config: &RemotePluginServiceConfig, bundle_download_url: &str, max_bytes: u64, ) -> Result, RemotePluginBundleInstallError> { - let client = build_reqwest_client(); + let client = config + .http_client_for_url(bundle_download_url) + .await + .map_err(|source| RemotePluginBundleInstallError::HttpClient { + url: bundle_download_url.to_string(), + source, + })?; let response = client .get(bundle_download_url) .timeout(REMOTE_PLUGIN_BUNDLE_DOWNLOAD_TIMEOUT) @@ -354,7 +374,7 @@ async fn download_remote_plugin_bundle_with_limit( } async fn read_response_body_with_limit( - mut response: Response, + mut response: HttpResponse, url: &str, max_bytes: u64, ) -> Result, RemotePluginBundleInstallError> { diff --git a/codex-rs/core-plugins/src/remote_legacy.rs b/codex-rs/core-plugins/src/remote_legacy.rs index 137c33753b..6cdc4162f9 100644 --- a/codex-rs/core-plugins/src/remote_legacy.rs +++ b/codex-rs/core-plugins/src/remote_legacy.rs @@ -1,7 +1,8 @@ use crate::remote::RemotePluginServiceConfig; +use codex_http_client::RouteAwareClientPoolError; use codex_login::CodexAuth; -use codex_login::default_client::build_reqwest_client; use codex_protocol::protocol::Product; +use http::StatusCode; use serde::Deserialize; use std::time::Duration; use url::Url; @@ -35,17 +36,24 @@ pub enum RemotePluginMutationError { #[error("chatgpt base url cannot be used for plugin mutation")] InvalidBaseUrlPath, + #[error("failed to build HTTP client for remote plugin mutation request to {url}: {source}")] + HttpClient { + url: String, + #[source] + source: RouteAwareClientPoolError, + }, + #[error("failed to send remote plugin mutation request to {url}: {source}")] Request { url: String, #[source] - source: reqwest::Error, + source: codex_http_client::HttpError, }, #[error("remote plugin mutation failed with status {status} from {url}: {body}")] UnexpectedStatus { url: String, - status: reqwest::StatusCode, + status: StatusCode, body: String, }, @@ -73,17 +81,27 @@ pub enum RemotePluginMutationError { #[derive(Debug, thiserror::Error)] pub enum RemotePluginFetchError { + #[error("invalid chatgpt base url for remote featured plugin request: {0}")] + InvalidBaseUrl(#[source] url::ParseError), + + #[error("failed to build HTTP client for remote featured plugin request to {url}: {source}")] + HttpClient { + url: String, + #[source] + source: RouteAwareClientPoolError, + }, + #[error("failed to send remote featured plugin request to {url}: {source}")] Request { url: String, #[source] - source: reqwest::Error, + source: codex_http_client::HttpError, }, #[error("remote featured plugin request to {url} failed with status {status}: {body}")] UnexpectedStatus { url: String, - status: reqwest::StatusCode, + status: StatusCode, body: String, }, @@ -101,14 +119,21 @@ pub async fn fetch_remote_featured_plugin_ids( product: Option, ) -> Result, RemotePluginFetchError> { let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let url = format!("{base_url}/plugins/featured"); - let client = build_reqwest_client(); + let mut url = Url::parse(&format!("{base_url}/plugins/featured")) + .map_err(RemotePluginFetchError::InvalidBaseUrl)?; + url.query_pairs_mut().append_pair( + "platform", + product.unwrap_or(Product::Codex).to_app_platform(), + ); + let url = url.to_string(); + let client = config.http_client_for_url(&url).await.map_err(|source| { + RemotePluginFetchError::HttpClient { + url: url.clone(), + source, + } + })?; let mut request = client .get(&url) - .query(&[( - "platform", - product.unwrap_or(Product::Codex).to_app_platform(), - )]) .timeout(REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT); if let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) { @@ -173,7 +198,12 @@ async fn post_remote_plugin_mutation( ) -> Result { let auth = ensure_codex_backend_auth(auth)?; let url = remote_plugin_mutation_url(config, plugin_id, action)?; - let client = build_reqwest_client(); + let client = config.http_client_for_url(&url).await.map_err(|source| { + RemotePluginMutationError::HttpClient { + url: url.clone(), + source, + } + })?; let request = client .post(url.clone()) .timeout(REMOTE_PLUGIN_MUTATION_TIMEOUT) diff --git a/codex-rs/core-plugins/src/startup_sync.rs b/codex-rs/core-plugins/src/startup_sync.rs index 0dd437af8a..07cbbaa29a 100644 --- a/codex-rs/core-plugins/src/startup_sync.rs +++ b/codex-rs/core-plugins/src/startup_sync.rs @@ -6,16 +6,18 @@ use std::process::Output; use std::process::Stdio; use std::time::Duration; +use codex_http_client::ClientRouteClass; +use codex_http_client::HttpClient; +use codex_http_client::HttpClientFactory; +use codex_http_client::RequestBuilder; +use codex_http_client::RouteAwareClientPool; use codex_otel::CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC; use codex_otel::CURATED_PLUGINS_STARTUP_SYNC_METRIC; -use reqwest::Client; use serde::Deserialize; use tempfile::TempDir; use tracing::warn; use zip::ZipArchive; -use codex_login::default_client::build_reqwest_client; - const GITHUB_API_BASE_URL: &str = "https://api.github.com"; const GITHUB_API_ACCEPT_HEADER: &str = "application/vnd.github+json"; const GITHUB_API_VERSION_HEADER: &str = "2022-11-28"; @@ -92,7 +94,10 @@ fn curated_plugins_sha_path(codex_home: &Path) -> PathBuf { codex_home.join(CURATED_PLUGINS_SHA_FILE) } -pub fn sync_openai_plugins_repo(codex_home: &Path) -> Result { +pub fn sync_openai_plugins_repo( + codex_home: &Path, + http_client_factory: HttpClientFactory, +) -> Result { #[cfg(target_os = "macos")] let git_binary = match which::which("git") { Ok(git_path) => macos_git_binary_from_path(git_path, apple_developer_tools_available()), @@ -106,6 +111,7 @@ pub fn sync_openai_plugins_repo(codex_home: &Path) -> Result { git_binary.as_deref(), GITHUB_API_BASE_URL, CURATED_PLUGINS_BACKUP_ARCHIVE_API_URL, + &http_client_factory, ) } @@ -114,6 +120,7 @@ fn sync_openai_plugins_repo_with_transport_overrides( git_binary: Option<&Path>, api_base_url: &str, backup_archive_api_url: &str, + http_client_factory: &HttpClientFactory, ) -> Result { let _file_guard = lock_curated_plugins_startup_sync(codex_home)?; @@ -134,7 +141,7 @@ fn sync_openai_plugins_repo_with_transport_overrides( error = %err, "git sync failed for curated plugin sync; falling back to GitHub HTTP" ); - match sync_openai_plugins_repo_via_http(codex_home, api_base_url) { + match sync_openai_plugins_repo_via_http(codex_home, api_base_url, http_client_factory) { Ok(remote_sha) => { emit_curated_plugins_startup_sync_metric("http", "success"); emit_curated_plugins_startup_sync_final_metric("http", "success"); @@ -162,6 +169,7 @@ fn sync_openai_plugins_repo_with_transport_overrides( let result = sync_openai_plugins_repo_via_backup_archive( codex_home, backup_archive_api_url, + http_client_factory, ); let status = if result.is_ok() { "success" } else { "failure" }; emit_curated_plugins_startup_sync_metric("export_archive", status); @@ -319,6 +327,7 @@ fn run_git_in_repo( fn sync_openai_plugins_repo_via_http( codex_home: &Path, api_base_url: &str, + http_client_factory: &HttpClientFactory, ) -> Result { let repo_path = curated_plugins_repo_path(codex_home); let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE); @@ -326,7 +335,12 @@ fn sync_openai_plugins_repo_via_http( .enable_all() .build() .map_err(|err| format!("failed to create curated plugins sync runtime: {err}"))?; - let remote_sha = runtime.block_on(fetch_curated_repo_remote_sha(api_base_url))?; + let http_clients = RouteAwareClientPool::with_chatgpt_cloudflare_cookies( + http_client_factory.clone(), + ClientRouteClass::Api, + ); + let remote_sha = + runtime.block_on(fetch_curated_repo_remote_sha(&http_clients, api_base_url))?; let local_sha = read_sha_file(&sha_path); if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.is_dir() { @@ -334,7 +348,11 @@ fn sync_openai_plugins_repo_via_http( } let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; - let zipball_bytes = runtime.block_on(fetch_curated_repo_zipball(api_base_url, &remote_sha))?; + let zipball_bytes = runtime.block_on(fetch_curated_repo_zipball( + &http_clients, + api_base_url, + &remote_sha, + ))?; extract_zipball_to_dir(&zipball_bytes, staged_repo_dir.path())?; ensure_marketplace_manifest_exists(staged_repo_dir.path())?; activate_curated_repo(&repo_path, staged_repo_dir)?; @@ -345,6 +363,7 @@ fn sync_openai_plugins_repo_via_http( fn sync_openai_plugins_repo_via_backup_archive( codex_home: &Path, backup_archive_api_url: &str, + http_client_factory: &HttpClientFactory, ) -> Result { let repo_path = curated_plugins_repo_path(codex_home); let sha_path = curated_plugins_sha_path(codex_home); @@ -353,7 +372,12 @@ fn sync_openai_plugins_repo_via_backup_archive( .build() .map_err(|err| format!("failed to create curated plugins sync runtime: {err}"))?; let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; + let http_clients = RouteAwareClientPool::with_chatgpt_cloudflare_cookies( + http_client_factory.clone(), + ClientRouteClass::Api, + ); let zipball_bytes = runtime.block_on(fetch_curated_repo_backup_archive_zip( + &http_clients, backup_archive_api_url, ))?; extract_zipball_to_dir(&zipball_bytes, staged_repo_dir.path())?; @@ -763,11 +787,14 @@ fn ensure_git_success(output: &Output, context: &str) -> Result<(), String> { } } -async fn fetch_curated_repo_remote_sha(api_base_url: &str) -> Result { +async fn fetch_curated_repo_remote_sha( + http_clients: &RouteAwareClientPool, + api_base_url: &str, +) -> Result { let api_base_url = api_base_url.trim_end_matches('/'); let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}"); - let client = build_reqwest_client(); - let repo_body = fetch_github_text(&client, &repo_url, "get curated plugins repository").await?; + let repo_body = + fetch_github_text(http_clients, &repo_url, "get curated plugins repository").await?; let repo_summary: GitHubRepositorySummary = serde_json::from_str(&repo_body).map_err(|err| { format!("failed to parse curated plugins repository response from {repo_url}: {err}") @@ -780,7 +807,7 @@ async fn fetch_curated_repo_remote_sha(api_base_url: &str) -> Result Result Result, String> { let api_base_url = api_base_url.trim_end_matches('/'); let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}"); let zipball_url = format!("{repo_url}/zipball/{remote_sha}"); - let client = build_reqwest_client(); - fetch_github_bytes(&client, &zipball_url, "download curated plugins archive").await + fetch_github_bytes( + http_clients, + &zipball_url, + "download curated plugins archive", + ) + .await } async fn fetch_curated_repo_backup_archive_zip( + http_clients: &RouteAwareClientPool, backup_archive_api_url: &str, ) -> Result, String> { - let client = build_reqwest_client(); let export_body = fetch_public_text( - &client, + http_clients, backup_archive_api_url, "get curated plugins export archive metadata", ) @@ -827,7 +859,7 @@ async fn fetch_curated_repo_backup_archive_zip( } fetch_public_bytes( - &client, + http_clients, &export_response.download_url, "download curated plugins export archive", ) @@ -924,8 +956,13 @@ fn read_git_ref_sha(git_dir: &Path, reference: &str) -> Result { )) } -async fn fetch_github_text(client: &Client, url: &str, context: &str) -> Result { - let response = github_request(client, url) +async fn fetch_github_text( + http_clients: &RouteAwareClientPool, + url: &str, + context: &str, +) -> Result { + let client = client_for_url(http_clients, url, context).await?; + let response = github_request(&client, url) .send() .await .map_err(|err| format!("failed to {context} from {url}: {err}"))?; @@ -939,8 +976,13 @@ async fn fetch_github_text(client: &Client, url: &str, context: &str) -> Result< Ok(body) } -async fn fetch_github_bytes(client: &Client, url: &str, context: &str) -> Result, String> { - let response = github_request(client, url) +async fn fetch_github_bytes( + http_clients: &RouteAwareClientPool, + url: &str, + context: &str, +) -> Result, String> { + let client = client_for_url(http_clients, url, context).await?; + let response = github_request(&client, url) .send() .await .map_err(|err| format!("failed to {context} from {url}: {err}"))?; @@ -958,7 +1000,12 @@ async fn fetch_github_bytes(client: &Client, url: &str, context: &str) -> Result Ok(body.to_vec()) } -async fn fetch_public_text(client: &Client, url: &str, context: &str) -> Result { +async fn fetch_public_text( + http_clients: &RouteAwareClientPool, + url: &str, + context: &str, +) -> Result { + let client = client_for_url(http_clients, url, context).await?; let response = client .get(url) .timeout(CURATED_PLUGINS_BACKUP_ARCHIVE_TIMEOUT) @@ -975,7 +1022,12 @@ async fn fetch_public_text(client: &Client, url: &str, context: &str) -> Result< Ok(body) } -async fn fetch_public_bytes(client: &Client, url: &str, context: &str) -> Result, String> { +async fn fetch_public_bytes( + http_clients: &RouteAwareClientPool, + url: &str, + context: &str, +) -> Result, String> { + let client = client_for_url(http_clients, url, context).await?; let response = client .get(url) .timeout(CURATED_PLUGINS_BACKUP_ARCHIVE_TIMEOUT) @@ -996,7 +1048,18 @@ async fn fetch_public_bytes(client: &Client, url: &str, context: &str) -> Result Ok(body.to_vec()) } -fn github_request(client: &Client, url: &str) -> reqwest::RequestBuilder { +async fn client_for_url( + http_clients: &RouteAwareClientPool, + url: &str, + context: &str, +) -> Result { + http_clients + .client_for_url(url) + .await + .map_err(|err| format!("failed to build HTTP client to {context} from {url}: {err}")) +} + +fn github_request(client: &HttpClient, url: &str) -> RequestBuilder { client .get(url) .timeout(CURATED_PLUGINS_HTTP_TIMEOUT) diff --git a/codex-rs/core-plugins/src/startup_sync_tests.rs b/codex-rs/core-plugins/src/startup_sync_tests.rs index a4ea778443..b5c002e715 100644 --- a/codex-rs/core-plugins/src/startup_sync_tests.rs +++ b/codex-rs/core-plugins/src/startup_sync_tests.rs @@ -196,6 +196,7 @@ async fn run_sync_with_transport_overrides( Some(git_binary.as_path()), &api_base_url, &backup_archive_api_url, + &crate::test_support::test_http_client_factory(), ) }) .await @@ -215,6 +216,7 @@ async fn run_sync_without_git( /*git_binary*/ None, &api_base_url, &backup_archive_api_url, + &crate::test_support::test_http_client_factory(), ) }) .await @@ -227,7 +229,11 @@ async fn run_http_sync( ) -> Result { let api_base_url = api_base_url.into(); tokio::task::spawn_blocking(move || { - sync_openai_plugins_repo_via_http(codex_home.as_path(), &api_base_url) + sync_openai_plugins_repo_via_http( + codex_home.as_path(), + &api_base_url, + &crate::test_support::test_http_client_factory(), + ) }) .await .expect("sync task should join") @@ -366,6 +372,7 @@ exit 1 Some(git_path.as_path()), "http://127.0.0.1:9", "http://127.0.0.1:9/backend-api/plugins/export/curated", + &crate::test_support::test_http_client_factory(), ) }; let first = scope.spawn(run_sync); diff --git a/codex-rs/core-plugins/src/test_support.rs b/codex-rs/core-plugins/src/test_support.rs index 9e06de9d16..a10e84a5e5 100644 --- a/codex-rs/core-plugins/src/test_support.rs +++ b/codex-rs/core-plugins/src/test_support.rs @@ -8,12 +8,18 @@ use codex_config::LoaderOverrides; use codex_config::NoopThreadConfigLoader; use codex_config::loader::load_config_layers_state; use codex_exec_server::LOCAL_FS; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use toml::Value; pub(crate) const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; pub(crate) const TEST_CURATED_PLUGIN_CACHE_VERSION: &str = "01234567"; +pub(crate) fn test_http_client_factory() -> HttpClientFactory { + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault) +} + pub(crate) fn write_file(path: &Path, contents: &str) { fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); fs::write(path, contents).unwrap(); @@ -152,6 +158,7 @@ pub(crate) async fn load_plugins_config(codex_home: &Path, cwd: &Path) -> Plugin /*default_enabled*/ true, ), "https://chatgpt.com/backend-api/".to_string(), + test_http_client_factory(), ) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 5cd6748c05..64b7d00e7c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1503,6 +1503,7 @@ impl Config { self.features.enabled(Feature::Plugins), self.features.enabled(Feature::RemotePlugin), self.chatgpt_base_url.clone(), + self.http_client_factory(), ) } diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs index 53d010d190..a5472f18db 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -221,9 +221,10 @@ plugins = true let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf()); fetch_and_cache_global_remote_plugin_catalog( codex_home.path(), - &RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }, + &RemotePluginServiceConfig::new( + config.chatgpt_base_url.clone(), + config.http_client_factory(), + ), Some(&auth), ) .await diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index f4f60f0a83..5f27f5542a 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -243,7 +243,6 @@ deny = [ "codex-api", "codex-app-server", "codex-core", - "codex-core-plugins", "codex-exec-server", "codex-lmstudio", "codex-login", diff --git a/codex-rs/http-client/src/default_client.rs b/codex-rs/http-client/src/default_client.rs index b4465e7f8c..e0b9dee46d 100644 --- a/codex-rs/http-client/src/default_client.rs +++ b/codex-rs/http-client/src/default_client.rs @@ -1,4 +1,4 @@ -use http::Error as HttpError; +use http::Error as HttpRequestBuildError; use http::HeaderMap; use http::HeaderName; use http::HeaderValue; @@ -6,13 +6,15 @@ use opentelemetry::global; use opentelemetry::propagation::Injector; use reqwest::IntoUrl; use reqwest::Method; -use reqwest::Response; use serde::Serialize; use std::fmt::Display; use std::time::Duration; use tracing::Span; use tracing_opentelemetry::OpenTelemetrySpanExt; +pub type HttpError = reqwest::Error; +pub type HttpResponse = reqwest::Response; + #[derive(Clone, Debug)] pub struct HttpClient { inner: reqwest::Client, @@ -37,6 +39,20 @@ impl HttpClient { self.request(Method::POST, url) } + pub fn put(&self, url: U) -> RequestBuilder + where + U: IntoUrl, + { + self.request(Method::PUT, url) + } + + pub fn delete(&self, url: U) -> RequestBuilder + where + U: IntoUrl, + { + self.request(Method::DELETE, url) + } + pub fn request(&self, method: Method, url: U) -> RequestBuilder where U: IntoUrl, @@ -78,9 +94,9 @@ impl RequestBuilder { pub fn header(self, key: K, value: V) -> Self where HeaderName: TryFrom, - >::Error: Into, + >::Error: Into, HeaderValue: TryFrom, - >::Error: Into, + >::Error: Into, { self.map(|builder| builder.header(key, value)) } @@ -110,7 +126,7 @@ impl RequestBuilder { self.map(|builder| builder.body(body)) } - pub async fn send(self) -> Result { + pub async fn send(self) -> Result { let headers = trace_headers(); match self.builder.headers(headers).send().await { diff --git a/codex-rs/http-client/src/lib.rs b/codex-rs/http-client/src/lib.rs index 0195bb9b9f..854fd51e77 100644 --- a/codex-rs/http-client/src/lib.rs +++ b/codex-rs/http-client/src/lib.rs @@ -22,6 +22,8 @@ pub use crate::custom_ca::build_reqwest_client_with_custom_ca; pub use crate::custom_ca::build_rustls_client_config_with_custom_ca; pub use crate::custom_ca::maybe_build_rustls_client_config_with_custom_ca; pub use crate::default_client::HttpClient; +pub use crate::default_client::HttpError; +pub use crate::default_client::HttpResponse; pub use crate::default_client::RequestBuilder; pub use crate::error::StreamError; pub use crate::error::TransportError;