Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Honor system proxy settings for remote plugins (#34509)
## Why

Remote plugin requests did not use Codex's effective outbound proxy policy, so
they could bypass configured system proxy and PAC routing.

## What changed

- Route remote plugin catalog, mutation, sharing, upload, and bundle download
  requests through the configured route-aware HTTP client.
- Select routes using the complete request URL, including encoded query
  parameters and backend-provided signed upload or download URLs.
- Preserve standard Codex headers and suppress diagnostics for URLs or headers
  that may contain credentials.

## Testing

Add coverage for route selection of catalog queries, workspace plugin uploads,
and backend-provided bundle download URLs.

GitOrigin-RevId: 106936659e21decf145b6ab4d4be84992e386861
  • Loading branch information
bolinfest authored and copyberry committed Jul 21, 2026
commit d937bfac84786b453ddb2d3fdb2712c1eca830ea
1 change: 0 additions & 1 deletion codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions codex-rs/app-server/src/request_processors/plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1583,6 +1583,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,
)
Expand Down
1 change: 0 additions & 1 deletion codex-rs/core-plugins/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,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"] }
Expand Down
112 changes: 63 additions & 49 deletions codex-rs/core-plugins/src/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,19 @@ 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_login::default_client::default_headers;
use codex_plugin::AppConnectorId;
use codex_plugin::AppDeclaration;
use codex_plugin::PluginCapabilitySummary;
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;
Expand Down Expand Up @@ -147,6 +150,12 @@ impl RemotePluginServiceConfig {
http_clients: Arc::new(http_clients),
}
}

pub(crate) fn http_request(&self, method: Method, url: &str) -> RouteAwareRequestBuilder {
self.http_clients
.request(method, url)
.headers(default_headers())
}
}

impl PartialEq for RemotePluginServiceConfig {
Expand All @@ -158,7 +167,6 @@ impl PartialEq for RemotePluginServiceConfig {
}

impl Eq for RemotePluginServiceConfig {}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemotePluginUninstallTarget {
pub plugin_id: PluginId,
Expand Down Expand Up @@ -348,13 +356,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,
},

Expand Down Expand Up @@ -892,11 +900,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.http_request(Method::GET, &url), auth)
.timeout(RECOMMENDED_PLUGINS_TIMEOUT);
let response: RecommendedPluginsResponse = send_and_decode(request, &url).await?;
Ok(recommended_plugins_mode(response))
}
Expand Down Expand Up @@ -1203,8 +1212,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.http_request(Method::GET, &url), auth);
let response: RemotePluginSkillDetailResponse = send_and_decode(request, &url).await?;
if response.plugin_id != plugin_id {
return Err(RemotePluginCatalogError::UnexpectedPluginId {
Expand Down Expand Up @@ -1359,14 +1367,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.http_request(Method::POST, &url), auth);
let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?;
if response.id != plugin_id {
return Err(RemotePluginCatalogError::UnexpectedPluginId {
Expand Down Expand Up @@ -1453,8 +1459,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.http_request(Method::POST, &url), auth);
let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?;
if response.id != remote_plugin_id {
return Err(RemotePluginCatalogError::UnexpectedPluginId {
Expand Down Expand Up @@ -1873,17 +1878,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.http_request(Method::GET, &url), auth);
send_and_decode(request, &url).await
}

Expand All @@ -1893,13 +1900,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.http_request(Method::GET, &url), auth);
send_and_decode(request, &url).await
}

Expand All @@ -1911,16 +1920,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.http_request(Method::GET, &url), auth);
send_and_decode(request, &url).await
}

Expand All @@ -1931,12 +1943,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.http_request(Method::GET, &url), auth);
send_and_decode(request, &url).await
}

Expand Down Expand Up @@ -1972,17 +1986,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,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,
)
Expand Down
41 changes: 20 additions & 21 deletions codex-rs/core-plugins/src/remote/share.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -164,7 +165,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)?;
Expand Down Expand Up @@ -280,8 +281,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.http_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!(
Expand Down Expand Up @@ -314,8 +314,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.http_request(Method::PUT, &url), auth).json(
&RemotePluginShareUpdateTargetsRequest {
discoverability,
targets,
Expand Down Expand Up @@ -379,13 +378,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.http_request(Method::GET, &url), auth);
send_and_decode(request, &url).await
}

Expand All @@ -398,8 +399,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.http_request(Method::POST, &url), auth).json(
&RemoteWorkspacePluginUploadUrlRequest {
filename,
mime_type: "application/gzip",
Expand All @@ -411,12 +411,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
.http_request(Method::PUT, upload_url)
.timeout(REMOTE_PLUGIN_CATALOG_TIMEOUT)
.header("x-ms-blob-type", "BlockBlob")
.header("Content-Type", "application/gzip")
Expand Down Expand Up @@ -452,8 +452,7 @@ 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.http_request(Method::POST, &url), auth).json(&body);
send_and_decode(request, &url).await
}

Expand Down Expand Up @@ -491,7 +490,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> {
Expand Down
1 change: 1 addition & 0 deletions codex-rs/core-plugins/src/remote/share/checkout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
Expand Down
Loading
Loading