core-plugins: route remote requests through HTTP client factory

This commit is contained in:
Michael Bolin
2026-07-09 15:41:45 -07:00
parent 664cffa372
commit fd903b7d2d
17 changed files with 325 additions and 146 deletions
-1
View File
@@ -2805,7 +2805,6 @@ dependencies = [
"libc",
"pretty_assertions",
"regex",
"reqwest 0.12.28",
"semver",
"serde",
"serde_json",
@@ -127,6 +127,13 @@ fn load_shared_plugin_ids_by_local_path(
})
}
fn remote_plugin_service_config(config: &Config) -> RemotePluginServiceConfig {
RemotePluginServiceConfig::new(
config.chatgpt_base_url.clone(),
config.http_client_factory(),
)
}
fn share_context_for_source(
source: &MarketplacePluginSource,
shared_plugin_ids_by_local_path: &std::collections::BTreeMap<AbsolutePathBuf, String>,
@@ -581,9 +588,7 @@ 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 = remote_plugin_service_config(&config);
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 +1035,7 @@ 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 = remote_plugin_service_config(&config);
match codex_core_plugins::remote::fetch_remote_plugin_share_context(
&remote_plugin_service_config,
auth.as_ref(),
@@ -1138,9 +1141,7 @@ 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 = remote_plugin_service_config(&config);
validate_remote_plugin_id(&plugin_name)?;
let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail(
&remote_plugin_service_config,
@@ -1196,9 +1197,7 @@ 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 = remote_plugin_service_config(&config);
let remote_skill_detail = codex_core_plugins::remote::fetch_remote_plugin_skill_detail(
&remote_plugin_service_config,
auth.as_ref(),
@@ -1249,9 +1248,7 @@ 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 = remote_plugin_service_config(&config);
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 +1289,7 @@ 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 = remote_plugin_service_config(&config);
let result = codex_core_plugins::remote::update_remote_plugin_share_targets(
&remote_plugin_service_config,
auth.as_ref(),
@@ -1322,9 +1317,7 @@ impl PluginRequestProcessor {
_params: PluginShareListParams,
) -> Result<PluginShareListResponse, JSONRPCErrorError> {
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 = remote_plugin_service_config(&config);
let data = codex_core_plugins::remote::list_remote_plugin_shares(
&remote_plugin_service_config,
auth.as_ref(),
@@ -1361,9 +1354,7 @@ 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 = remote_plugin_service_config(&config);
let result = codex_core_plugins::remote::checkout_remote_plugin_share(
&remote_plugin_service_config,
auth.as_ref(),
@@ -1394,9 +1385,7 @@ 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 = remote_plugin_service_config(&config);
codex_core_plugins::remote::delete_remote_plugin_share(
&remote_plugin_service_config,
auth.as_ref(),
@@ -1531,9 +1520,7 @@ 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 = remote_plugin_service_config(&config);
let remote_detail =
codex_core_plugins::remote::fetch_remote_plugin_detail_with_download_urls(
&remote_plugin_service_config,
@@ -1606,6 +1593,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 +2008,7 @@ 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 = remote_plugin_service_config(&config);
let uninstall_target = codex_core_plugins::remote::resolve_remote_plugin_uninstall_target(
&remote_plugin_service_config,
auth.as_ref(),
-1
View File
@@ -38,7 +38,6 @@ chrono = { workspace = true }
dirs = { workspace = true }
flate2 = { workspace = true }
http = { workspace = true }
reqwest = { workspace = true }
regex = { workspace = true }
semver = { workspace = true }
serde = { workspace = true, features = ["derive"] }
@@ -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
@@ -1,3 +1,4 @@
use codex_http_client::OutboundProxyPolicy;
use codex_http_client::RouteAwareClientPool;
use codex_http_client::RouteAwareRequestBuilder;
use http::Method;
@@ -9,10 +10,15 @@ use std::fmt::Debug;
/// client would let callers send a different URL than the one used for route selection.
pub(crate) trait HttpClientSelector: Debug + Send + Sync {
fn request(&self, method: Method, url: &str) -> RouteAwareRequestBuilder;
fn outbound_proxy_policy(&self) -> OutboundProxyPolicy;
}
impl HttpClientSelector for RouteAwareClientPool {
fn request(&self, method: Method, url: &str) -> RouteAwareRequestBuilder {
RouteAwareClientPool::request(self, method, url)
}
fn outbound_proxy_policy(&self) -> OutboundProxyPolicy {
RouteAwareClientPool::outbound_proxy_policy(self)
}
}
+9 -3
View File
@@ -130,6 +130,14 @@ impl PluginsConfigInput {
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.
@@ -227,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(
+99 -49
View File
@@ -1,4 +1,5 @@
use crate::app_mcp_routing::apply_app_mcp_routing_policy;
use crate::http_client_selector::HttpClientSelector;
use crate::loader::plugin_app_declarations_from_value;
use crate::store::PLUGINS_CACHE_DIR;
use crate::store::PluginStore;
@@ -9,8 +10,12 @@ 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::HttpClientFactory;
use codex_http_client::RouteAwareClientPool;
use codex_http_client::RouteAwareRequestBuilder;
use codex_http_client::RouteAwareRequestError;
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,8 @@ 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::Method;
use http::StatusCode;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
@@ -29,6 +35,7 @@ use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tracing::instrument;
use url::Url;
@@ -118,11 +125,48 @@ 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,
pub(crate) http_clients: Arc<dyn HttpClientSelector>,
}
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_without_request_logging(
http_client_factory,
ClientRouteClass::Api,
);
Self {
chatgpt_base_url,
http_clients: Arc::new(http_clients),
}
}
pub(crate) fn http_request(&self, method: Method, url: &str) -> RouteAwareRequestBuilder {
self.http_clients.request(method, url)
}
fn catalog_request(&self, method: Method, url: &str) -> RouteAwareRequestBuilder {
self.http_request(method, url)
}
}
impl PartialEq for RemotePluginServiceConfig {
fn eq(&self, other: &Self) -> bool {
self.chatgpt_base_url == other.chatgpt_base_url
&& self.http_clients.outbound_proxy_policy()
== other.http_clients.outbound_proxy_policy()
}
}
impl Eq for RemotePluginServiceConfig {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemotePluginUninstallTarget {
pub plugin_id: PluginId,
@@ -309,13 +353,13 @@ pub enum RemotePluginCatalogError {
Request {
url: String,
#[source]
source: reqwest::Error,
source: RouteAwareRequestError,
},
#[error("remote plugin catalog request to {url} failed with status {status}: {body}")]
UnexpectedStatus {
url: String,
status: reqwest::StatusCode,
status: StatusCode,
body: String,
},
@@ -850,11 +894,12 @@ pub async fn fetch_recommended_plugins(
) -> Result<RecommendedPluginsMode, RemotePluginCatalogError> {
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 request = authenticated_request(config.catalog_request(Method::GET, &url), auth)
.timeout(RECOMMENDED_PLUGINS_TIMEOUT);
let response: RecommendedPluginsResponse = send_and_decode(request, &url).await?;
Ok(recommended_plugins_mode(response))
}
@@ -1160,8 +1205,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 request = authenticated_request(client.get(&url), auth)?;
let request = authenticated_request(config.catalog_request(Method::GET, &url), auth);
let response: RemotePluginSkillDetailResponse = send_and_decode(request, &url).await?;
if response.plugin_id != plugin_id {
return Err(RemotePluginCatalogError::UnexpectedPluginId {
@@ -1315,14 +1359,12 @@ 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 request = authenticated_request(config.catalog_request(Method::POST, &url), auth);
let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?;
if response.id != plugin_id {
return Err(RemotePluginCatalogError::UnexpectedPluginId {
@@ -1409,8 +1451,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 request = authenticated_request(client.post(&url), auth)?;
let request = authenticated_request(config.catalog_request(Method::POST, &url), auth);
let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?;
if response.id != remote_plugin_id {
return Err(RemotePluginCatalogError::UnexpectedPluginId {
@@ -1827,17 +1868,19 @@ async fn get_remote_plugin_list_page(
collection: Option<&str>,
) -> Result<RemotePluginListResponse, RemotePluginCatalogError> {
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 request = authenticated_request(config.catalog_request(Method::GET, &url), auth);
send_and_decode(request, &url).await
}
@@ -1847,13 +1890,15 @@ async fn get_remote_shared_workspace_plugins_page(
page_token: Option<&str>,
) -> Result<RemotePluginListResponse, RemotePluginCatalogError> {
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 request = authenticated_request(config.catalog_request(Method::GET, &url), auth);
send_and_decode(request, &url).await
}
@@ -1865,16 +1910,19 @@ async fn get_remote_plugin_installed_page(
include_download_urls: bool,
) -> Result<RemotePluginInstalledResponse, RemotePluginCatalogError> {
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 request = authenticated_request(config.catalog_request(Method::GET, &url), auth);
send_and_decode(request, &url).await
}
@@ -1885,12 +1933,14 @@ async fn fetch_plugin_detail(
include_download_urls: bool,
) -> Result<RemotePluginDirectoryItem, RemotePluginCatalogError> {
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 request = authenticated_request(config.catalog_request(Method::GET, &url), auth);
send_and_decode(request, &url).await
}
@@ -1926,17 +1976,17 @@ fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePlu
}
fn authenticated_request(
request: RequestBuilder,
request: RouteAwareRequestBuilder,
auth: &CodexAuth,
) -> Result<RequestBuilder, RemotePluginCatalogError> {
Ok(request
) -> RouteAwareRequestBuilder {
request
.timeout(REMOTE_PLUGIN_CATALOG_TIMEOUT)
.headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers())
.header(OAI_PRODUCT_SKU_HEADER, CODEX_PRODUCT_SKU))
.header(OAI_PRODUCT_SKU_HEADER, CODEX_PRODUCT_SKU)
}
async fn send_and_decode<T: for<'de> Deserialize<'de>>(
request: RequestBuilder,
request: RouteAwareRequestBuilder,
url: &str,
) -> Result<T, RemotePluginCatalogError> {
let response = request
@@ -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(
+21 -21
View File
@@ -1,17 +1,18 @@
use super::*;
use crate::plugin_bundle_archive::PluginBundlePackError;
use crate::plugin_bundle_archive::pack_plugin_bundle_tar_gz;
use codex_http_client::RouteAwareRequestBuilder;
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::Method;
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 +164,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,8 +280,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 request = authenticated_request(client.delete(&url), auth)?;
let request = authenticated_request(config.catalog_request(Method::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) {
warn!(
@@ -312,8 +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 request = authenticated_request(client.put(&url), auth)?.json(
let request = authenticated_request(config.catalog_request(Method::PUT, &url), auth).json(
&RemotePluginShareUpdateTargetsRequest {
discoverability,
targets,
@@ -377,13 +376,15 @@ async fn get_created_workspace_plugins_page(
page_token: Option<&str>,
) -> Result<RemotePluginListResponse, RemotePluginCatalogError> {
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 request = authenticated_request(config.catalog_request(Method::GET, &url), auth);
send_and_decode(request, &url).await
}
@@ -396,8 +397,7 @@ async fn create_workspace_plugin_upload(
) -> Result<RemoteWorkspacePluginUploadUrlResponse, RemotePluginCatalogError> {
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 request = authenticated_request(client.post(&url), auth)?.json(
let request = authenticated_request(config.catalog_request(Method::POST, &url), auth).json(
&RemoteWorkspacePluginUploadUrlRequest {
filename,
mime_type: "application/gzip",
@@ -409,12 +409,12 @@ async fn create_workspace_plugin_upload(
}
async fn put_workspace_plugin_upload(
config: &RemotePluginServiceConfig,
upload_url: &str,
archive_bytes: Vec<u8>,
) -> Result<(), RemotePluginCatalogError> {
let client = build_reqwest_client();
let request = client
.put(upload_url)
let request = config
.catalog_request(Method::PUT, upload_url)
.timeout(REMOTE_PLUGIN_CATALOG_TIMEOUT)
.header("x-ms-blob-type", "BlockBlob")
.header("Content-Type", "application/gzip")
@@ -450,8 +450,8 @@ async fn finalize_workspace_plugin_upload(
} else {
format!("{base_url}/public/plugins/workspace")
};
let client = build_reqwest_client();
let request = authenticated_request(client.post(&url), auth)?.json(&body);
let request =
authenticated_request(config.catalog_request(Method::POST, &url), auth).json(&body);
send_and_decode(request, &url).await
}
@@ -489,7 +489,7 @@ fn archive_plugin_for_upload_with_limit(
}
async fn send_and_expect_status(
request: RequestBuilder,
request: RouteAwareRequestBuilder,
url_for_error: &str,
expected_statuses: &[StatusCode],
) -> Result<(), RemotePluginCatalogError> {
@@ -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(),
)
@@ -1,4 +1,6 @@
use super::*;
use crate::test_support::recorded_http_client_urls;
use crate::test_support::recording_remote_plugin_service_config;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginInterface;
@@ -23,9 +25,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 {
@@ -174,7 +177,8 @@ async fn save_remote_plugin_share_creates_workspace_plugin() {
.unwrap()
.len();
let server = MockServer::start().await;
let config = test_config(&server);
let (config, selected_urls) =
recording_remote_plugin_service_config(format!("{}/backend-api", server.uri()));
let auth = test_auth();
Mock::given(method("POST"))
@@ -260,6 +264,17 @@ async fn save_remote_plugin_share_creates_workspace_plugin() {
local_paths::load_plugin_share_local_paths(codex_home.path()).unwrap(),
BTreeMap::from([("plugins_123".to_string(), plugin_path)])
);
assert_eq!(
recorded_http_client_urls(&selected_urls),
vec![
format!(
"{}/backend-api/public/plugins/workspace/upload-url",
server.uri()
),
format!("{}/upload/file_123", server.uri()),
format!("{}/backend-api/public/plugins/workspace", server.uri()),
]
);
let requests = server.received_requests().await.unwrap_or_default();
let upload_request = requests
+52 -11
View File
@@ -1,18 +1,20 @@
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::RouteAwareRequestError;
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::Method;
use http::StatusCode;
use serde_json::Value as JsonValue;
use std::fs;
use std::io;
@@ -87,7 +89,7 @@ pub enum RemotePluginBundleInstallError {
DownloadRequest {
url: String,
#[source]
source: reqwest::Error,
source: RouteAwareRequestError,
},
#[error("remote plugin bundle download from {url} failed with status {status}: {body}")]
@@ -101,7 +103,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")]
@@ -247,10 +249,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<PluginInstallResult, RemotePluginBundleInstallError> {
let bundle_bytes = download_remote_plugin_bundle_with_limit(
config,
&bundle.bundle_download_url,
/*max_bytes*/ REMOTE_PLUGIN_BUNDLE_MAX_DOWNLOAD_BYTES,
)
@@ -267,10 +271,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<AbsolutePathBuf, RemotePluginBundleInstallError> {
let bundle_bytes = download_remote_plugin_bundle_with_limit(
config,
&bundle.bundle_download_url,
/*max_bytes*/ REMOTE_PLUGIN_BUNDLE_MAX_DOWNLOAD_BYTES,
)
@@ -287,12 +293,12 @@ 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<Vec<u8>, RemotePluginBundleInstallError> {
let client = build_reqwest_client();
let response = client
.get(bundle_download_url)
let response = config
.http_request(Method::GET, bundle_download_url)
.timeout(REMOTE_PLUGIN_BUNDLE_DOWNLOAD_TIMEOUT)
.send()
.await
@@ -302,8 +308,8 @@ async fn download_remote_plugin_bundle_with_limit(
})?;
let final_url = response.url().clone();
// reqwest may already have followed redirects here. For backend-issued bundle URLs, keep the
// shared client policy and fail unsupported final schemes before caching.
// The shared client has already followed redirects here. Reject an unsupported final scheme
// before caching a backend-issued bundle.
if !is_allowed_bundle_download_url(&final_url, allow_test_loopback_http_bundle_downloads()) {
return Err(
RemotePluginBundleInstallError::UnsupportedBundleDownloadFinalUrl {
@@ -354,7 +360,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<Vec<u8>, RemotePluginBundleInstallError> {
@@ -623,11 +629,18 @@ fn is_standard_plugin_root(path: &Path) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::recorded_http_client_urls;
use crate::test_support::recording_remote_plugin_service_config;
use flate2::Compression;
use flate2::write::GzEncoder;
use pretty_assertions::assert_eq;
use std::io::Write;
use tempfile::tempdir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
const REMOTE_PLUGIN_ID: &str = "plugins~Plugin_00000000000000000000000000000000";
@@ -739,6 +752,34 @@ mod tests {
));
}
#[tokio::test]
async fn bundle_download_routes_the_backend_supplied_url() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/signed/plugin-bundle"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"bundle"))
.expect(1)
.mount(&server)
.await;
let (config, selected_urls) =
recording_remote_plugin_service_config(format!("{}/backend-api", server.uri()));
let download_url = format!("{}/signed/plugin-bundle?sig=signed-token", server.uri());
let err =
download_remote_plugin_bundle_with_limit(&config, &download_url, /*max_bytes*/ 64)
.await
.expect_err("plain HTTP final URL should remain unsupported");
assert!(matches!(
err,
RemotePluginBundleInstallError::UnsupportedBundleDownloadFinalUrl { .. }
));
assert_eq!(
recorded_http_client_urls(&selected_urls),
vec![download_url]
);
}
#[test]
fn install_rejects_invalid_tar_gz_bundle() {
let codex_home = tempdir().expect("tempdir");
+21 -16
View File
@@ -1,7 +1,9 @@
use crate::remote::RemotePluginServiceConfig;
use codex_http_client::RouteAwareRequestError;
use codex_login::CodexAuth;
use codex_login::default_client::build_reqwest_client;
use codex_protocol::protocol::Product;
use http::Method;
use http::StatusCode;
use serde::Deserialize;
use std::time::Duration;
use url::Url;
@@ -39,13 +41,13 @@ pub enum RemotePluginMutationError {
Request {
url: String,
#[source]
source: reqwest::Error,
source: RouteAwareRequestError,
},
#[error("remote plugin mutation failed with status {status} from {url}: {body}")]
UnexpectedStatus {
url: String,
status: reqwest::StatusCode,
status: StatusCode,
body: String,
},
@@ -73,17 +75,20 @@ 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 send remote featured plugin request to {url}: {source}")]
Request {
url: String,
#[source]
source: reqwest::Error,
source: RouteAwareRequestError,
},
#[error("remote featured plugin request to {url} failed with status {status}: {body}")]
UnexpectedStatus {
url: String,
status: reqwest::StatusCode,
status: StatusCode,
body: String,
},
@@ -101,14 +106,15 @@ pub async fn fetch_remote_featured_plugin_ids(
product: Option<Product>,
) -> Result<Vec<String>, 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 request = client
.get(&url)
.query(&[(
"platform",
product.unwrap_or(Product::Codex).to_app_platform(),
)])
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 mut request = config
.http_request(Method::GET, &url)
.timeout(REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT);
if let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) {
@@ -173,9 +179,8 @@ async fn post_remote_plugin_mutation(
) -> Result<RemotePluginMutationResponse, RemotePluginMutationError> {
let auth = ensure_codex_backend_auth(auth)?;
let url = remote_plugin_mutation_url(config, plugin_id, action)?;
let client = build_reqwest_client();
let request = client
.post(url.clone())
let request = config
.http_request(Method::POST, &url)
.timeout(REMOTE_PLUGIN_MUTATION_TIMEOUT)
.headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers());
+42
View File
@@ -1,5 +1,47 @@
use super::*;
use crate::test_support::recorded_http_client_urls;
use crate::test_support::recording_remote_plugin_service_config;
use pretty_assertions::assert_eq;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[tokio::test]
async fn remote_plugin_list_routes_the_complete_query_url() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"plugins": [],
"pagination": {"next_page_token": null},
})))
.expect(1)
.mount(&server)
.await;
let (config, selected_urls) =
recording_remote_plugin_service_config(format!("{}/backend-api", server.uri()));
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
get_remote_plugin_list_page(
&config,
&auth,
RemotePluginScope::Global,
Some("next page/+"),
Some("vertical & special"),
)
.await
.expect("plugin list request should succeed");
assert_eq!(
recorded_http_client_urls(&selected_urls),
vec![format!(
"{}/backend-api/ps/plugins/list?scope=GLOBAL&limit=200&collection=vertical+%26+special&pageToken=next+page%2F%2B",
server.uri()
)]
);
}
#[test]
fn build_remote_marketplace_preserves_directory_order_and_appends_installed_only_plugins() {
+18
View File
@@ -7,6 +7,7 @@ use crate::OPENAI_API_CURATED_MARKETPLACE_NAME;
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
use crate::PluginsConfigInput;
use crate::http_client_selector::HttpClientSelector;
use crate::remote::RemotePluginServiceConfig;
use codex_config::LoaderOverrides;
use codex_config::NoopThreadConfigLoader;
use codex_config::loader::load_config_layers_state;
@@ -58,6 +59,23 @@ impl HttpClientSelector for RecordingHttpClientSelector {
}
self.delegate.request(method, url)
}
fn outbound_proxy_policy(&self) -> OutboundProxyPolicy {
self.delegate.outbound_proxy_policy()
}
}
pub(crate) fn recording_remote_plugin_service_config(
chatgpt_base_url: String,
) -> (RemotePluginServiceConfig, Arc<Mutex<Vec<String>>>) {
let (http_clients, selected_urls) = RecordingHttpClientSelector::new();
(
RemotePluginServiceConfig {
chatgpt_base_url,
http_clients,
},
selected_urls,
)
}
pub(crate) fn recorded_http_client_urls(selected_urls: &Mutex<Vec<String>>) -> Vec<String> {
+9
View File
@@ -1594,6 +1594,15 @@ respect_system_proxy = true
config.http_client_factory().outbound_proxy_policy(),
codex_http_client::OutboundProxyPolicy::RespectSystemProxy
);
assert_eq!(
config.plugins_config_input().remote_plugin_service_config(),
codex_core_plugins::remote::RemotePluginServiceConfig::new(
config.chatgpt_base_url,
codex_http_client::HttpClientFactory::new(
codex_http_client::OutboundProxyPolicy::RespectSystemProxy,
),
)
);
Ok(())
}
-1
View File
@@ -243,7 +243,6 @@ deny = [
"codex-api",
"codex-app-server",
"codex-core",
"codex-core-plugins",
"codex-exec-server",
"codex-lmstudio",
"codex-login",