Extend runtime auth to account, plugin, and file requests (#59564)

## What changed

- Use the shared runtime and effective configuration for account API clients and remote plugin requests, including standalone plugin commands.
- Support dual-header and assertion-only authentication for remote plugins and MCP file uploads when runtime agent instances are enabled.
- Keep plugin credentials and caches scoped to the thread's account when resuming with different credentials. Bind request pools and multi-step plugin storage operations to the selected account so account replacement revokes stale operations.
- Protect runtime credentials on cross-origin redirects while preserving configured routing policies and legacy authentication for custom backends.
- Apply server-provided `upload_headers` to file storage uploads.

## Testing

Add coverage for shared runtime registration, both authentication modes, configuration reloads, resumed-thread account selection, account replacement during plugin uploads and downloads, and credential stripping on redirects. Extend file upload tests to verify runtime assertions and storage-only upload headers.

GitOrigin-RevId: 68aef81051a6f9bf919fffda52f372b108fce805
This commit is contained in:
cooper-oai
2026-11-05 03:49:13 +00:00
committed by copyberry
parent c90354172c
commit 5c0b1ac71f
55 changed files with 2145 additions and 252 deletions
@@ -1,23 +1,45 @@
use codex_http_client::NetworkPolicy;
use codex_http_client::OutboundProxyPolicy;
use codex_http_client::RequestBuilder;
use codex_http_client::RouteAwareClientPool;
use http::Method;
use std::fmt::Debug;
use std::sync::Arc;
/// Builds requests whose URL is also used to resolve their outbound route.
///
/// Implementations must keep route selection coupled to the request URL. Returning a transport
/// client would let callers send a different URL than the one used for route selection.
pub(crate) trait HttpClientSelector: Debug + Send + Sync {
fn for_current_account(&self) -> Arc<dyn HttpClientSelector>;
fn request(&self, method: Method, url: &str) -> RequestBuilder;
fn request_for_account(
&self,
method: Method,
url: &str,
binding: &NetworkPolicy,
) -> Option<RequestBuilder>;
fn outbound_proxy_policy(&self) -> OutboundProxyPolicy;
}
impl HttpClientSelector for RouteAwareClientPool {
fn for_current_account(&self) -> Arc<dyn HttpClientSelector> {
Arc::new(RouteAwareClientPool::for_current_account(self))
}
fn request(&self, method: Method, url: &str) -> RequestBuilder {
RouteAwareClientPool::request(self, method, url)
}
fn request_for_account(
&self,
method: Method,
url: &str,
binding: &NetworkPolicy,
) -> Option<RequestBuilder> {
Some(self.with_account_binding(binding)?.request(method, url))
}
fn outbound_proxy_policy(&self) -> OutboundProxyPolicy {
RouteAwareClientPool::outbound_proxy_policy(self)
}
+76 -19
View File
@@ -108,6 +108,7 @@ use codex_config::types::ToolSuggestDisabledTool;
use codex_config::types::ToolSuggestDiscoverableType;
use codex_connectors::ConnectorSnapshot;
use codex_connectors::PluginConnectorSource;
use codex_features::RuntimeAuthMode;
use codex_hooks::plugin_hook_declarations;
use codex_http_client::ClientRouteClass;
use codex_http_client::HttpClientFactory;
@@ -124,6 +125,7 @@ use codex_plugin::prompt_safe_plugin_description;
use codex_protocol::auth::AuthMode;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::Product;
use codex_runtime_agent_instance::RuntimeAuthSlot;
use codex_skills::SkillMetadata;
use codex_skills::SkillRootLoader;
use codex_skills::SkillRootSnapshots;
@@ -175,6 +177,8 @@ pub struct PluginsConfigInput {
pub product_sku: Option<String>,
http_client_factory: HttpClientFactory,
remote_http_clients: Arc<OnceLock<Arc<dyn HttpClientSelector>>>,
runtime_http_clients: Arc<OnceLock<Arc<dyn HttpClientSelector>>>,
runtime_auth_mode: Option<RuntimeAuthMode>,
}
impl PluginsConfigInput {
@@ -200,16 +204,40 @@ impl PluginsConfigInput {
http_client_factory,
product_sku,
remote_http_clients: Arc::new(OnceLock::new()),
runtime_http_clients: Arc::new(OnceLock::new()),
runtime_auth_mode: None,
}
}
/// Shares a lazy route-aware connection pool across this input and its clones.
/// Request metadata remains current; new config inputs own a new pool.
pub fn remote_plugin_service_config(&self) -> RemotePluginServiceConfig {
let http_clients = self.remote_http_clients.get_or_init(|| {
pub fn with_runtime_auth_mode(mut self, mode: Option<RuntimeAuthMode>) -> Self {
self.runtime_auth_mode = mode;
self
}
/// Shares lazy route-aware connection pools across this input and its clones.
/// Runtime mode strips sensitive headers on cross-origin redirects.
pub fn remote_plugin_service_config(
&self,
runtime_auth: Option<(RuntimeAuthSlot, RuntimeAuthMode)>,
) -> RemotePluginServiceConfig {
let runtime_auth = runtime_auth.filter(|_| {
codex_http_client::classify_openai_api_url(&self.chatgpt_base_url)
!= codex_http_client::OpenAiDestination::Other
});
let (http_clients, factory) = if runtime_auth.is_some() {
(
&self.runtime_http_clients,
self.http_client_factory
.clone()
.with_sensitive_header_protection(),
)
} else {
(&self.remote_http_clients, self.http_client_factory.clone())
};
let http_clients = http_clients.get_or_init(|| {
Arc::new(
RouteAwareClientPool::with_chatgpt_cloudflare_cookies_without_request_logging(
self.http_client_factory.clone(),
factory,
ClientRouteClass::Api,
),
)
@@ -221,6 +249,7 @@ impl PluginsConfigInput {
.clone()
.unwrap_or_else(|| CODEX_PRODUCT_SKU.to_string()),
http_clients: Arc::clone(http_clients),
runtime_auth,
}
}
}
@@ -424,10 +453,6 @@ struct ConfiguredMarketplaceUpgradeState {
in_flight: bool,
}
fn remote_plugin_service_config(config: &PluginsConfigInput) -> RemotePluginServiceConfig {
config.remote_plugin_service_config()
}
fn featured_plugin_ids_cache_key(
config: &PluginsConfigInput,
auth: Option<&CodexAuth>,
@@ -583,6 +608,7 @@ pub struct PluginsManager {
remote_catalog_cache_refresh_state: RwLock<RemoteCatalogCacheRefreshState>,
restriction_product: Option<Product>,
auth_manager: Arc<AuthManager>,
runtime_auth: RuntimeAuthSlot,
analytics_events_client: RwLock<Option<AnalyticsEventsClient>>,
plugin_install_source: PluginInstallSource,
}
@@ -692,6 +718,34 @@ fn target_curated_marketplace(auth_mode: Option<AuthMode>) -> TargetCuratedMarke
}
impl PluginsManager {
/// Build remote service state from the request's effective mode and this harness's runtime.
pub fn remote_plugin_service_config(
&self,
config: &PluginsConfigInput,
) -> RemotePluginServiceConfig {
config.remote_plugin_service_config(
config
.runtime_auth_mode
.map(|mode| (Arc::clone(&self.runtime_auth), mode)),
)
}
/// Reuse host configuration without sharing another actor's remote caches or credentials.
pub fn for_auth_context(
&self,
auth_manager: Arc<AuthManager>,
runtime_auth: RuntimeAuthSlot,
) -> Self {
Self::new_with_options(
self.codex_home.clone(),
self.restriction_product,
auth_manager,
runtime_auth,
Arc::clone(&self.skill_root_loader),
)
.with_plugin_install_source(self.plugin_install_source)
}
pub fn new(
codex_home: PathBuf,
auth_manager: Arc<AuthManager>,
@@ -701,6 +755,7 @@ impl PluginsManager {
codex_home,
Some(Product::Codex),
auth_manager,
RuntimeAuthSlot::default(),
skill_root_loader,
)
}
@@ -709,6 +764,7 @@ impl PluginsManager {
codex_home: PathBuf,
restriction_product: Option<Product>,
auth_manager: Arc<AuthManager>,
runtime_auth: RuntimeAuthSlot,
skill_root_loader: Arc<dyn SkillRootLoader<PluginSkillRoot>>,
) -> Self {
// Product restrictions are enforced at marketplace admission time for a given CODEX_HOME:
@@ -752,6 +808,7 @@ impl PluginsManager {
),
restriction_product,
auth_manager,
runtime_auth,
analytics_events_client: RwLock::new(None),
plugin_install_source: PluginInstallSource::Manual,
}
@@ -1333,7 +1390,7 @@ impl PluginsManager {
crate::remote::cached_global_remote_discoverable_plugins(
self.codex_home.as_path(),
&remote_plugin_service_config(config),
&self.remote_plugin_service_config(config),
auth,
)
}
@@ -1368,7 +1425,7 @@ impl PluginsManager {
.prepare_remote_installed_plugins_cache_generation(auth)
.ok_or(RemotePluginCatalogError::AuthRequired)?;
let plugins = crate::remote::fetch_remote_installed_plugins_with_display_assets(
&remote_plugin_service_config(config),
&self.remote_plugin_service_config(config),
auth,
display_assets,
)
@@ -1659,7 +1716,7 @@ impl PluginsManager {
self.schedule_remote_installed_plugins_cache_refresh(
RemoteInstalledPluginsCacheRefreshRequest {
generation,
service_config: remote_plugin_service_config(config),
service_config: self.remote_plugin_service_config(config),
auth,
notify,
on_effective_plugins_changed,
@@ -1796,7 +1853,7 @@ impl PluginsManager {
.collect::<Vec<_>>();
let result = crate::remote::sync_remote_installed_plugin_bundles_once_with_snapshot(
self.codex_home.clone(),
&remote_plugin_service_config(config),
&self.remote_plugin_service_config(config),
auth,
&previous_plugin_ids,
)
@@ -1866,7 +1923,7 @@ impl PluginsManager {
}
self.schedule_remote_catalog_cache_refresh(RemoteCatalogCacheRefreshRequest {
service_config: remote_plugin_service_config(config),
service_config: self.remote_plugin_service_config(config),
auth,
scopes,
mode,
@@ -1962,7 +2019,7 @@ impl PluginsManager {
return Ok(featured_plugin_ids);
}
let featured_plugin_ids = crate::remote_legacy::fetch_remote_featured_plugin_ids(
&remote_plugin_service_config(config),
&self.remote_plugin_service_config(config),
auth,
self.restriction_product,
)
@@ -2013,7 +2070,7 @@ impl PluginsManager {
let mode = refresh
.get_or_init(|| async {
match crate::remote::fetch_recommended_plugins(
&remote_plugin_service_config(config),
&self.remote_plugin_service_config(config),
auth,
)
.await
@@ -2208,7 +2265,7 @@ impl PluginsManager {
let plugin_id = resolved.plugin_id.as_key();
// This only forwards the backend mutation before the local install flow.
if let Err(err) = crate::remote_legacy::enable_remote_plugin(
&remote_plugin_service_config(config),
&self.remote_plugin_service_config(config),
auth,
&plugin_id,
)
@@ -2416,7 +2473,7 @@ impl PluginsManager {
let plugin_key = plugin_id.as_key();
// This only forwards the backend mutation before the local uninstall flow.
crate::remote_legacy::uninstall_remote_plugin(
&remote_plugin_service_config(config),
&self.remote_plugin_service_config(config),
auth,
&plugin_key,
)
@@ -3004,7 +3061,7 @@ impl PluginsManager {
};
let mut scopes = crate::remote::cached_remote_plugin_catalog_scopes(
manager.codex_home.as_path(),
&remote_plugin_service_config(&config_for_remote_sync),
&manager.remote_plugin_service_config(&config_for_remote_sync),
auth.as_ref(),
scopes_to_probe,
);
@@ -7,8 +7,10 @@ use crate::remote::fetch_measurement_reference_bundle;
use crate::remote_bundle::download_remote_plugin_bundle;
use crate::remote_bundle::install_remote_plugin_bundle;
use crate::script_attribution::PluginMeasurementTarget;
use codex_features::RuntimeAuthMode;
use codex_login::CodexAuth;
use codex_plugin::PluginId;
use codex_runtime_agent_instance::RuntimeAuthSlot;
use codex_utils_path_uri::PathConvention;
use std::sync::Arc;
use tempfile::TempDir;
@@ -84,6 +86,7 @@ impl RemotePluginMeasurementCache {
config: &PluginsConfigInput,
auth: &CodexAuth,
target: &PluginMeasurementTarget,
runtime_auth: Option<(RuntimeAuthSlot, RuntimeAuthMode)>,
) -> anyhow::Result<Option<Arc<PluginMeasurementReference>>> {
if !config.plugins_enabled || !config.remote_plugin_enabled || !auth.uses_codex_backend() {
return Ok(None);
@@ -92,7 +95,9 @@ impl RemotePluginMeasurementCache {
// authoritative for enabled state, including default installations.
// GLOBAL plugins have already passed server-side admission; local
// marketplace product policy and skill capability loading are separate.
let service = config.remote_plugin_service_config();
let service = config
.remote_plugin_service_config(runtime_auth)
.for_current_account();
let Some((remote_id, bundle)) = fetch_measurement_reference_bundle(&service, auth, target)
.await
.map_err(|_| anyhow::anyhow!("plugin measurement catalog lookup failed"))?
@@ -174,7 +174,9 @@ impl Fixture {
&self,
target: &PluginMeasurementTarget,
) -> Result<Option<Arc<PluginMeasurementReference>>> {
self.cache.prepare(&self.config, &self.auth, target).await
self.cache
.prepare(&self.config, &self.auth, target, /*runtime_auth*/ None)
.await
}
async fn assert_request_count(&self, expected: usize) {
@@ -254,7 +256,7 @@ async fn narrow_preparation_reuses_versions_and_keeps_old_leases_alive() -> Resu
// A newer release cannot supply an older version's declaration on a cold cache.
assert!(
RemotePluginMeasurementCache::new()
.prepare(&f.config, &f.auth, &first)
.prepare(&f.config, &f.auth, &first, /*runtime_auth*/ None)
.await?
.is_none()
);
@@ -28,12 +28,14 @@ fn repeated_service_configs_and_early_clones_share_one_lazy_pool() {
);
let clone = input.clone();
assert!(input.remote_http_clients.get().is_none());
let first = input.remote_plugin_service_config();
let first = input.remote_plugin_service_config(/*runtime_auth*/ None);
let reuse: Vec<_> = (0..220)
.map(|_| {
Arc::ptr_eq(
&first.http_clients,
&clone.remote_plugin_service_config().http_clients,
&clone
.remote_plugin_service_config(/*runtime_auth*/ None)
.http_clients,
)
})
.collect();
@@ -52,7 +54,9 @@ fn repeated_service_configs_and_early_clones_share_one_lazy_pool() {
reuse,
Arc::ptr_eq(
&first.http_clients,
&new_input.remote_plugin_service_config().http_clients,
&new_input
.remote_plugin_service_config(/*runtime_auth*/ None)
.http_clients,
),
),
(vec![true; 220], false)
@@ -95,7 +99,7 @@ async fn reused_pool_uses_current_endpoint_product_and_authentication() {
test_http_client_factory(),
/*product_sku*/ None,
);
let first = input.remote_plugin_service_config();
let first = input.remote_plugin_service_config(/*runtime_auth*/ None);
let first_auth = CodexAuth::from_external_chatgpt_tokens(
"header.e30.first",
"first-account",
@@ -108,7 +112,7 @@ async fn reused_pool_uses_current_endpoint_product_and_authentication() {
input.chatgpt_base_url = format!("{}/backend-api", second_server.uri());
input.product_sku = Some("updated".to_string());
let second = input.remote_plugin_service_config();
let second = input.remote_plugin_service_config(/*runtime_auth*/ None);
let second_auth = CodexAuth::from_external_chatgpt_tokens(
"header.e30.second",
"second-account",
@@ -1,10 +1,10 @@
use crate::PluginsConfigInput;
use crate::remote::RemotePluginCatalogError;
use crate::remote::RemotePluginServiceConfig;
use codex_login::CodexAuth;
use codex_tools::DiscoverableTool;
pub async fn hydrate_selected_recommended_plugin_install_metadata(
config: &PluginsConfigInput,
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
mut tool: DiscoverableTool,
) -> Result<Option<DiscoverableTool>, RemotePluginCatalogError> {
@@ -17,12 +17,9 @@ pub async fn hydrate_selected_recommended_plugin_install_metadata(
plugin.id
))
})?;
let Some(app_connector_ids) = crate::remote::fetch_recommended_plugin_install_metadata(
&config.remote_plugin_service_config(),
auth,
&remote_plugin_id,
)
.await?
let Some(app_connector_ids) =
crate::remote::fetch_recommended_plugin_install_metadata(config, auth, &remote_plugin_id)
.await?
else {
return Ok(None);
};
+143 -57
View File
@@ -19,6 +19,7 @@ use codex_app_server_protocol::PluginInstallPolicySource;
use codex_app_server_protocol::PluginInterface;
use codex_app_server_protocol::ScheduledTaskSummary;
use codex_app_server_protocol::SkillInterface;
use codex_features::RuntimeAuthMode;
use codex_http_client::ClientRouteClass;
use codex_http_client::HttpClientFactory;
use codex_http_client::RequestBuilder;
@@ -32,7 +33,9 @@ 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_runtime_agent_instance::RuntimeAuthSlot;
use codex_utils_absolute_path::AbsolutePathBuf;
use http::HeaderMap;
use http::Method;
use http::StatusCode;
use serde::Deserialize;
@@ -153,13 +156,20 @@ pub struct RemotePluginServiceConfig {
pub chatgpt_base_url: String,
pub(crate) product_sku: String,
pub(crate) http_clients: Arc<dyn HttpClientSelector>,
pub(crate) runtime_auth: Option<(RuntimeAuthSlot, RuntimeAuthMode)>,
}
impl RemotePluginServiceConfig {
/// Keeps metadata and subsequent storage requests on the account that starts the operation.
pub(crate) fn for_current_account(&self) -> Self {
Self {
http_clients: self.http_clients.for_current_account(),
..self.clone()
}
}
/// 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.
/// Every catalog, mutation, upload, and bundle request retains the configured proxy policy.
pub fn new(
chatgpt_base_url: String,
http_client_factory: HttpClientFactory,
@@ -170,13 +180,109 @@ impl RemotePluginServiceConfig {
http_client_factory,
ClientRouteClass::Api,
);
let http_clients: Arc<dyn HttpClientSelector> = Arc::new(http_clients);
Self {
chatgpt_base_url,
product_sku: product_sku.unwrap_or_else(|| CODEX_PRODUCT_SKU.to_string()),
http_clients: Arc::new(http_clients),
http_clients,
runtime_auth: None,
}
}
#[cfg(test)]
pub(crate) fn new_with_runtime_auth(
chatgpt_base_url: String,
http_client_factory: HttpClientFactory,
product_sku: Option<String>,
runtime_auth: (RuntimeAuthSlot, RuntimeAuthMode),
) -> Self {
if codex_http_client::classify_openai_api_url(&chatgpt_base_url)
== codex_http_client::OpenAiDestination::Other
{
return Self::new(chatgpt_base_url, http_client_factory, product_sku);
}
Self::new_with_selected_runtime_auth(
chatgpt_base_url,
http_client_factory,
product_sku,
runtime_auth,
)
}
#[cfg(test)]
pub(crate) fn new_with_selected_runtime_auth(
chatgpt_base_url: String,
http_client_factory: HttpClientFactory,
product_sku: Option<String>,
runtime_auth: (RuntimeAuthSlot, RuntimeAuthMode),
) -> Self {
let mut config = Self::new(
chatgpt_base_url,
http_client_factory.with_sensitive_header_protection(),
product_sku,
);
config.runtime_auth = Some(runtime_auth);
config
}
// Keep runtime registration out of callers' async stack frames.
pub(crate) fn authenticated_request<'a>(
&'a self,
method: Method,
url: &'a str,
auth: &'a CodexAuth,
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = Result<RequestBuilder, codex_api::AuthError>>
+ Send
+ 'a,
>,
> {
Box::pin(async move {
if self.runtime_auth.is_some() {
codex_model_provider::validate_runtime_destination(Some(&self.chatgpt_base_url))
.map_err(|error| codex_api::AuthError::Build(error.to_string()))?;
}
let runtime = self
.runtime_auth
.as_ref()
.map(|(slot, mode)| {
slot.get()
.map(|manager| (Arc::clone(manager), *mode))
.ok_or_else(|| {
codex_api::AuthError::Build(
"runtime authentication has not been initialized".to_string(),
)
})
})
.transpose()?;
let resolved = codex_model_provider::auth_provider_for_request(
auth,
runtime.as_ref(),
&HeaderMap::new(),
)
.await?;
let request = match &resolved.runtime {
Some(runtime) => self
.http_clients
.request_for_account(
method,
url,
runtime.http_client_factory().network_policy(),
)
.ok_or_else(|| {
codex_api::AuthError::Build(
"plugin credentials and transport must share the application network policy"
.to_string(),
)
})?,
None => self.http_clients.request(method, url),
};
let headers = resolved.auth.apply_auth_headers(HeaderMap::new()).await?;
Ok(request.headers(default_headers()).headers(headers))
})
}
pub(crate) fn http_request(&self, method: Method, url: &str) -> RequestBuilder {
self.http_clients
.request(method, url)
@@ -188,6 +294,13 @@ impl PartialEq for RemotePluginServiceConfig {
fn eq(&self, other: &Self) -> bool {
self.chatgpt_base_url == other.chatgpt_base_url
&& self.product_sku == other.product_sku
&& match (&self.runtime_auth, &other.runtime_auth) {
(Some((left, left_mode)), Some((right, right_mode))) => {
Arc::ptr_eq(left, right) && left_mode == right_mode
}
(None, None) => true,
_ => false,
}
&& self.http_clients.outbound_proxy_policy()
== other.http_clients.outbound_proxy_policy()
}
@@ -1045,12 +1158,9 @@ pub async fn fetch_recommended_plugins(
.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,
&config.product_sku,
)?
.timeout(RECOMMENDED_PLUGINS_TIMEOUT);
let request = authenticated_request(config, Method::GET, &url, auth)
.await?
.timeout(RECOMMENDED_PLUGINS_TIMEOUT);
let response: RecommendedPluginsResponse = send_and_decode(request, &url).await?;
Ok(recommended_plugins_mode(response))
}
@@ -1445,11 +1555,7 @@ pub async fn fetch_remote_plugin_skill_detail(
}
let url = remote_plugin_skill_detail_url(config, plugin_id, skill_name)?;
let request = authenticated_request(
config.http_request(Method::GET, &url),
auth,
&config.product_sku,
)?;
let request = authenticated_request(config, Method::GET, &url, auth).await?;
let response: RemotePluginSkillDetailResponse = send_and_decode(request, &url).await?;
if response.plugin_id != plugin_id {
return Err(RemotePluginCatalogError::UnexpectedPluginId {
@@ -1656,11 +1762,7 @@ async fn install_remote_plugin_inner(
url.query_pairs_mut()
.append_pair("includeAppsNeedingAuth", "true");
let url = url.to_string();
let request = authenticated_request(
config.http_request(Method::POST, &url),
auth,
&config.product_sku,
)?;
let request = authenticated_request(config, Method::POST, &url, auth).await?;
let request = if let Some(install_attempt_id) = install_attempt_id {
request.json(&RemotePluginInstallRequest { install_attempt_id })
} else {
@@ -1753,11 +1855,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 request = authenticated_request(
config.http_request(Method::POST, &url),
auth,
&config.product_sku,
)?;
let request = authenticated_request(config, Method::POST, &url, auth).await?;
let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?;
if response.id != remote_plugin_id {
return Err(RemotePluginCatalogError::UnexpectedPluginId {
@@ -2265,11 +2363,7 @@ async fn get_remote_plugin_list_page(
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,
&config.product_sku,
)?;
let request = authenticated_request(config, Method::GET, &url, auth).await?;
send_and_decode(request, &url).await
}
@@ -2287,11 +2381,7 @@ async fn get_remote_shared_workspace_plugins_page(
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,
&config.product_sku,
)?;
let request = authenticated_request(config, Method::GET, &url, auth).await?;
send_and_decode(request, &url).await
}
@@ -2324,11 +2414,7 @@ async fn get_remote_plugin_installed_page(
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,
&config.product_sku,
)?;
let request = authenticated_request(config, Method::GET, &url, auth).await?;
send_and_decode(request, &url).await
}
@@ -2363,12 +2449,9 @@ async fn fetch_plugin_detail_with_timeout(
.append_pair("includeDownloadUrls", "true");
}
let url = url.to_string();
let request = authenticated_request(
config.http_request(Method::GET, &url),
auth,
&config.product_sku,
)?
.timeout(timeout);
let request = authenticated_request(config, Method::GET, &url, auth)
.await?
.timeout(timeout);
send_and_decode(request, &url).await
}
@@ -2403,20 +2486,19 @@ fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePlu
Ok(auth)
}
fn authenticated_request(
request: RequestBuilder,
async fn authenticated_request(
config: &RemotePluginServiceConfig,
method: Method,
url: &str,
auth: &CodexAuth,
product_sku: &str,
) -> Result<RequestBuilder, RemotePluginCatalogError> {
Ok(request
Ok(config
.authenticated_request(method, url, auth)
.await
.map_err(std::io::Error::other)
.map_err(RemotePluginCatalogError::AuthToken)?
.timeout(REMOTE_PLUGIN_CATALOG_TIMEOUT)
.headers(
codex_model_provider::auth_provider_from_auth(auth)
.to_auth_headers()
.map_err(std::io::Error::other)
.map_err(RemotePluginCatalogError::AuthToken)?,
)
.header(OAI_PRODUCT_SKU_HEADER, product_sku))
.header(OAI_PRODUCT_SKU_HEADER, &config.product_sku))
}
async fn send_and_decode<T: for<'de> Deserialize<'de>>(
@@ -2451,3 +2533,7 @@ async fn send_and_decode<T: for<'de> Deserialize<'de>>(
source,
})
}
#[cfg(test)]
#[path = "remote_runtime_auth_tests.rs"]
mod runtime_auth_tests;
@@ -136,6 +136,7 @@ pub(crate) async fn sync_remote_installed_plugin_bundles_once_with_snapshot(
previous_plugin_ids: &[PluginId],
) -> Result<RemoteInstalledPluginBundleSyncResult, RemoteInstalledPluginBundleSyncError> {
let auth = ensure_chatgpt_auth(auth)?;
let config = &config.for_current_account();
let authenticated_account_id = auth.get_account_id();
let fetched_installed_plugins = fetch_installed_plugins(
config,
+1 -5
View File
@@ -60,11 +60,7 @@ pub async fn search_remote_plugins(
}
let url = url.to_string();
let request = authenticated_request(
config.http_request(Method::GET, &url),
auth,
&config.product_sku,
)?;
let request = authenticated_request(config, Method::GET, &url, auth).await?;
let response: RemotePluginListResponse = send_and_decode(request, &url_for_error)
.await
.map_err(|error| match error {
+20 -36
View File
@@ -149,6 +149,7 @@ pub async fn save_remote_plugin_share(
access_policy: RemotePluginShareAccessPolicy,
) -> Result<RemotePluginShareSaveResult, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let config = &config.for_current_account();
let plugin_path_for_archive = plugin_path.as_path().to_path_buf();
let (filename, archive_bytes) = tokio::task::spawn_blocking(move || {
let filename = archive_filename(&plugin_path_for_archive)?;
@@ -285,11 +286,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 request = authenticated_request(
config.http_request(Method::DELETE, &url),
auth,
&config.product_sku,
)?;
let request = authenticated_request(config, Method::DELETE, &url, auth).await?;
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!(
@@ -322,15 +319,12 @@ 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 request = authenticated_request(
config.http_request(Method::PUT, &url),
auth,
&config.product_sku,
)?
.json(&RemotePluginShareUpdateTargetsRequest {
discoverability,
targets,
});
let request = authenticated_request(config, Method::PUT, &url, auth)
.await?
.json(&RemotePluginShareUpdateTargetsRequest {
discoverability,
targets,
});
let response: RemotePluginShareUpdateTargetsResponse = send_and_decode(request, &url).await?;
Ok(RemotePluginShareUpdateTargetsResult {
principals: response.principals,
@@ -397,11 +391,7 @@ async fn get_created_workspace_plugins_page(
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,
&config.product_sku,
)?;
let request = authenticated_request(config, Method::GET, &url, auth).await?;
send_and_decode(request, &url).await
}
@@ -414,17 +404,14 @@ 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 request = authenticated_request(
config.http_request(Method::POST, &url),
auth,
&config.product_sku,
)?
.json(&RemoteWorkspacePluginUploadUrlRequest {
filename,
mime_type: "application/gzip",
size_bytes,
plugin_id: remote_plugin_id,
});
let request = authenticated_request(config, Method::POST, &url, auth)
.await?
.json(&RemoteWorkspacePluginUploadUrlRequest {
filename,
mime_type: "application/gzip",
size_bytes,
plugin_id: remote_plugin_id,
});
send_and_decode(request, &url).await
}
@@ -476,12 +463,9 @@ async fn finalize_workspace_plugin_upload(
} else {
format!("{base_url}/public/plugins/workspace")
};
let request = authenticated_request(
config.http_request(Method::POST, &url),
auth,
&config.product_sku,
)?
.json(&body);
let request = authenticated_request(config, Method::POST, &url, auth)
.await?
.json(&body);
send_and_decode(request, &url).await
}
@@ -40,6 +40,7 @@ pub async fn checkout_remote_plugin_share(
codex_home: &Path,
remote_plugin_id: &str,
) -> Result<RemotePluginShareCheckoutResult, RemotePluginCatalogError> {
let config = &config.for_current_account();
let detail = super::super::fetch_remote_plugin_detail_with_download_urls(
config,
auth,
@@ -1148,3 +1148,7 @@ mod tests {
}
}
}
#[cfg(test)]
#[path = "remote_bundle_runtime_auth_tests.rs"]
mod runtime_auth_tests;
@@ -0,0 +1,55 @@
//! Runtime-authenticated catalogs keep presigned bundle transfers unauthenticated.
use super::*;
use codex_features::RuntimeAuthMode;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use pretty_assertions::assert_eq;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::path;
#[tokio::test]
async fn runtime_bundle_download_follows_redirect_without_actor_auth() {
let origin = MockServer::start().await;
let destination = MockServer::start().await;
let final_url = format!("{}/bundle", destination.uri());
Mock::given(path("/bundle"))
.respond_with(ResponseTemplate::new(307).insert_header("location", &final_url))
.expect(1)
.mount(&origin)
.await;
Mock::given(path("/bundle"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"bundle"))
.expect(1)
.mount(&destination)
.await;
let config = RemotePluginServiceConfig::new_with_selected_runtime_auth(
origin.uri(),
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
/*product_sku*/ None,
(Default::default(), RuntimeAuthMode::AssertionOnly),
);
let error = download_remote_plugin_bundle_with_limit(
&config,
&format!("{}/bundle", origin.uri()),
/*max_bytes*/ 64,
)
.await
.expect_err("HTTP final URLs remain unsupported after following redirects");
assert!(
matches!(
&error,
RemotePluginBundleInstallError::UnsupportedBundleDownloadFinalUrl { .. }
),
"expected rejection of the HTTP redirect destination: {error}"
);
let mut requests = origin.received_requests().await.unwrap();
requests.extend(destination.received_requests().await.unwrap());
assert_eq!(requests.len(), 2);
for request in requests {
assert!(!request.headers.contains_key("authorization"));
assert!(!request.headers.contains_key("x-openai-agent-assertion"));
}
}
+13 -19
View File
@@ -135,18 +135,15 @@ pub async fn fetch_remote_featured_plugin_ids(
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()) {
request = request.headers(
codex_model_provider::auth_provider_from_auth(auth)
.to_auth_headers()
.map_err(std::io::Error::other)
.map_err(RemotePluginFetchError::AuthToken)?,
);
let request = match auth.filter(|auth| auth.uses_codex_backend()) {
Some(auth) => config
.authenticated_request(Method::GET, &url, auth)
.await
.map_err(std::io::Error::other)
.map_err(RemotePluginFetchError::AuthToken)?,
None => config.http_request(Method::GET, &url),
}
.timeout(REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT);
let response = request
.send()
@@ -206,14 +203,11 @@ async fn post_remote_plugin_mutation(
let auth = ensure_codex_backend_auth(auth)?;
let url = remote_plugin_mutation_url(config, plugin_id, action)?;
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()
.map_err(std::io::Error::other)
.map_err(RemotePluginMutationError::AuthToken)?,
);
.authenticated_request(Method::POST, &url, auth)
.await
.map_err(std::io::Error::other)
.map_err(RemotePluginMutationError::AuthToken)?
.timeout(REMOTE_PLUGIN_MUTATION_TIMEOUT);
let response = request
.send()
@@ -106,7 +106,9 @@ impl PluginsManager {
}
remote::validate_remote_plugin_id(&remote_plugin_id)
.map_err(|err| unresolved(Error::InvalidRequest(err.message)))?;
let service = config.remote_plugin_service_config();
let service = self
.remote_plugin_service_config(config)
.for_current_account();
let detail = remote::fetch_remote_plugin_detail_with_download_urls(
&service,
auth,
@@ -232,7 +234,7 @@ impl PluginsManager {
}
remote::validate_remote_plugin_id(remote_plugin_id)
.map_err(|err| unresolved(Error::InvalidRequest(err.message)))?;
let service = config.remote_plugin_service_config();
let service = self.remote_plugin_service_config(config);
let target =
remote::resolve_remote_plugin_uninstall_target(&service, auth, remote_plugin_id)
.await
@@ -1,4 +1,4 @@
//! Regression coverage for the shared mutation gate, refresh, and backend failure handling.
//! Regression coverage for remote mutations and account-bound storage transfers.
use super::*;
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
@@ -7,6 +7,7 @@ use crate::test_support::test_auth_manager;
use crate::test_support::test_plugins_manager_with_auth_manager;
use crate::test_support::write_file;
use codex_protocol::auth::AuthMode;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::sync::atomic::AtomicBool;
@@ -109,3 +110,364 @@ async fn uninstall_serializes_backend_mutations_and_preserves_cache_on_failure()
}
}
}
/// Switch only after metadata has been read, immediately before constructing the storage request.
/// Metadata-body cancellation would not exercise the separate request these tests protect.
#[derive(Clone)]
struct TransferHttpClients {
delegate: codex_http_client::RouteAwareClientPool,
controller: codex_http_client::NetworkPolicyController,
switch_before_storage: Arc<AtomicBool>,
api_origin: String,
storage_origin: String,
}
impl std::fmt::Debug for TransferHttpClients {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TransferHttpClients")
.finish_non_exhaustive()
}
}
impl crate::http_client_selector::HttpClientSelector for TransferHttpClients {
fn for_current_account(&self) -> Arc<dyn crate::http_client_selector::HttpClientSelector> {
Arc::new(Self {
delegate: self.delegate.for_current_account(),
..self.clone()
})
}
fn request(&self, method: http::Method, url: &str) -> codex_http_client::RequestBuilder {
if url.starts_with("https://storage.example/")
&& self.switch_before_storage.swap(false, Ordering::SeqCst)
{
let policy = self.controller.policy();
policy.invalidate();
assert!(self.controller.publish(
policy.revision(),
codex_http_client::DestinationPolicy::Unrestricted,
));
}
self.delegate.request(
method,
url.replace("https://storage.example", &self.storage_origin)
.replace("https://chatgpt.com", &self.api_origin),
)
}
fn request_for_account(
&self,
method: http::Method,
url: &str,
binding: &codex_http_client::NetworkPolicy,
) -> Option<codex_http_client::RequestBuilder> {
Some(
self.delegate
.with_account_binding(binding)?
.request(method, url.replace("https://chatgpt.com", &self.api_origin)),
)
}
fn outbound_proxy_policy(&self) -> codex_http_client::OutboundProxyPolicy {
self.delegate.outbound_proxy_policy()
}
}
struct PluginTransferTest {
api: MockServer,
storage: MockServer,
config: PluginsConfigInput,
manager: Arc<PluginsManager>,
auth: CodexAuth,
switch_before_storage: Arc<AtomicBool>,
}
impl PluginTransferTest {
async fn new(
home: &std::path::Path,
mode: codex_features::RuntimeAuthMode,
switch: bool,
) -> Self {
use base64::Engine;
use codex_http_client::ClientRouteClass;
use codex_http_client::DestinationPolicy;
use codex_http_client::HttpClientFactory;
use codex_http_client::NetworkPolicyController;
use codex_http_client::OutboundProxyPolicy;
use codex_http_client::RouteAwareClientPool;
use codex_runtime_agent_instance::AgentHostAuthorization;
use codex_runtime_agent_instance::RuntimeAuthManager;
use codex_runtime_agent_instance::RuntimeAuthSlot;
use codex_runtime_agent_instance::RuntimeRegistrationMetadata;
use codex_runtime_agent_instance::RuntimeRole;
let api = MockServer::start().await;
let storage = MockServer::start().await;
Mock::given(path("/agent/register"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"runtime_agent_instance_id": "runtime-plugins", "agent_host_id": "host-a",
"actor": {"kind": "chatgpt", "user_id": "user-a", "account_id": "account-a"},
})))
.expect(1)
.mount(&api)
.await;
let claims = json!({
"iss": "https://auth.openai.com/", "session_id": "login-a",
"https://api.openai.com/auth": {"user_id": "user-a", "chatgpt_account_id": "account-a"},
});
let token = format!(
"header.{}.signature",
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string()),
);
let auth = CodexAuth::from_external_chatgpt_tokens(
&token,
"account-a",
/*chatgpt_plan_type*/ None,
)
.unwrap();
let credentials = codex_login::AuthManager::from_auth_for_testing(auth.clone());
let slot = RuntimeAuthSlot::default();
slot.set(Arc::new(RuntimeAuthManager::new(
Arc::clone(&credentials),
AgentHostAuthorization::InstallationId {
installation_id: uuid::Uuid::nil(),
},
RuntimeRegistrationMetadata {
harness: Some("codex".into()),
harness_version: None,
role: RuntimeRole::Orchestrator,
},
format!("{}/agent/register", api.uri()).parse().unwrap(),
)))
.unwrap();
let controller = NetworkPolicyController::default();
assert!(controller.publish(
controller.policy().revision(),
DestinationPolicy::Unrestricted
));
let factory = HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault)
.with_network_policy(controller.policy())
.with_sensitive_header_protection();
let switch_before_storage = Arc::new(AtomicBool::new(switch));
let clients = Arc::new(TransferHttpClients {
delegate: RouteAwareClientPool::with_chatgpt_cloudflare_cookies_without_request_logging(
factory.clone(),
ClientRouteClass::Api,
),
controller,
switch_before_storage: Arc::clone(&switch_before_storage),
api_origin: api.uri(),
storage_origin: storage.uri(),
});
let config = PluginsConfigInput::new(
Default::default(),
Default::default(),
"openai".into(),
/*plugins_enabled*/ true,
/*remote_plugin_enabled*/ true,
"https://chatgpt.com/backend-api".into(),
factory,
/*product_sku*/ None,
)
.with_runtime_auth_mode(Some(mode));
config.runtime_http_clients.set(clients).unwrap();
let manager = Arc::new(crate::PluginsManager::new_with_options(
home.to_path_buf(),
/*restriction_product*/ None,
credentials,
slot,
crate::test_support::test_skill_root_loader(),
));
Self {
api,
storage,
config,
manager,
auth,
switch_before_storage,
}
}
async fn verify(&self, account_changed: bool, mode: codex_features::RuntimeAuthMode) {
assert!(!self.switch_before_storage.load(Ordering::SeqCst));
self.api.verify().await;
let storage_requests = self.storage.received_requests().await.unwrap();
assert_eq!(storage_requests.len(), usize::from(!account_changed));
for request in storage_requests {
for name in [
"authorization",
"x-openai-agent-assertion",
"chatgpt-account-id",
] {
assert!(!request.headers.contains_key(name));
}
}
for request in self.api.received_requests().await.unwrap() {
if request.url.path() == "/agent/register" {
continue;
}
let authorization = request.headers["authorization"].to_str().unwrap();
match mode {
codex_features::RuntimeAuthMode::DualHeader => {
assert!(authorization.starts_with("Bearer "));
assert!(request.headers.contains_key("x-openai-agent-assertion"));
}
codex_features::RuntimeAuthMode::AssertionOnly => {
assert!(authorization.starts_with("AgentAssertion "));
assert!(!request.headers.contains_key("x-openai-agent-assertion"));
}
}
}
}
}
#[tokio::test]
async fn plugin_share_keeps_account_binding_through_storage_upload() {
use codex_features::RuntimeAuthMode;
use codex_http_client::NetworkPolicyDenied;
use codex_http_client::RouteAwareRequestError;
for mode in [RuntimeAuthMode::DualHeader, RuntimeAuthMode::AssertionOnly] {
for account_changed in [false, true] {
let home = TempDir::new().unwrap();
let fixture = PluginTransferTest::new(home.path(), mode, account_changed).await;
let plugin_path = home.path().join("sample");
write_file(
&plugin_path.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
);
Mock::given(method("POST"))
.and(path("/backend-api/public/plugins/workspace/upload-url"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"file_id": "file_123", "upload_url": "https://storage.example/upload",
"etag": "\"metadata-etag\"",
})))
.expect(1)
.mount(&fixture.api)
.await;
Mock::given(method("PUT"))
.and(path("/upload"))
.respond_with(ResponseTemplate::new(201))
.mount(&fixture.storage)
.await;
Mock::given(method("POST"))
.and(path("/backend-api/public/plugins/workspace"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"plugin_id": "plugins_123", "share_url": null,
})))
.expect(u64::from(!account_changed))
.mount(&fixture.api)
.await;
let result = remote::save_remote_plugin_share(
&fixture
.manager
.remote_plugin_service_config(&fixture.config),
Some(&fixture.auth),
home.path(),
&AbsolutePathBuf::try_from(plugin_path).unwrap(),
/*remote_plugin_id*/ None,
remote::RemotePluginShareAccessPolicy::default(),
)
.await;
if account_changed {
assert!(
matches!(
result,
Err(RemotePluginCatalogError::Request {
source: RouteAwareRequestError::Policy(NetworkPolicyDenied::Revoked),
..
})
),
"{result:?}"
);
} else {
assert_eq!(result.unwrap().remote_plugin_id, "plugins_123");
}
fixture.verify(account_changed, mode).await;
}
}
}
#[tokio::test]
async fn plugin_install_keeps_account_binding_through_storage_download() {
use codex_features::RuntimeAuthMode;
use codex_http_client::NetworkPolicyDenied;
use codex_http_client::RouteAwareRequestError;
for mode in [RuntimeAuthMode::DualHeader, RuntimeAuthMode::AssertionOnly] {
for account_changed in [false, true] {
let home = TempDir::new().unwrap();
let fixture = PluginTransferTest::new(home.path(), mode, account_changed).await;
let remote_id = "b1234567-89ab-4cde-8f01-234567890abc";
Mock::given(method("GET"))
.and(path(format!("/backend-api/ps/plugins/{remote_id}")))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": remote_id, "name": "sample", "scope": "GLOBAL",
"installation_policy": "AVAILABLE", "authentication_policy": "ON_USE",
"release": {
"version": "1.0.0", "display_name": "Sample",
"description": "Sample plugin", "interface": {},
"bundle_download_url": "https://storage.example/bundle",
}
})))
.expect(1)
.mount(&fixture.api)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [], "pagination": {"next_page_token": null},
})))
.expect(1)
.mount(&fixture.api)
.await;
Mock::given(method("GET"))
.and(path("/bundle"))
.respond_with(ResponseTemplate::new(200).set_body_string("bundle"))
.mount(&fixture.storage)
.await;
let result = fixture
.manager
.install_remote_plugin(
&fixture.config,
Some(&fixture.auth),
RemotePluginInstallRequest {
marketplace_name: REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(),
remote_plugin_id: remote_id.to_string(),
install_attempt_id: None,
},
/*on_effective_plugins_changed*/ None,
)
.await;
let error = result.err().expect("test transfer should fail");
if account_changed {
assert!(
matches!(
*error.kind,
RemotePluginOperationErrorKind::Bundle(
RemotePluginBundleInstallError::DownloadRequest {
source: RouteAwareRequestError::Policy(
NetworkPolicyDenied::Revoked
),
..
}
)
),
"{error:?}"
);
} else {
// The local HTTP control reaches storage, then fails the final-URL HTTPS check.
assert!(
matches!(
*error.kind,
RemotePluginOperationErrorKind::Bundle(
RemotePluginBundleInstallError::UnsupportedBundleDownloadFinalUrl { .. }
)
),
"{error:?}"
);
}
fixture.verify(account_changed, mode).await;
}
}
}
@@ -0,0 +1,307 @@
//! Exercise modern and legacy plugin requests with a shared, late-initialized runtime.
use super::*;
use base64::Engine;
use codex_features::RuntimeAuthMode;
use codex_http_client::DestinationPolicy;
use codex_http_client::NetworkPolicyController;
use codex_http_client::NetworkPolicyDenied;
use codex_http_client::OutboundProxyPolicy;
use codex_login::AuthCredentialsStoreMode;
use codex_login::AuthKeyringBackendKind;
use codex_login::AuthManager;
use codex_login::AuthRouteConfig;
use codex_runtime_agent_instance::AgentHostAuthorization;
use codex_runtime_agent_instance::RuntimeAuthManager;
use codex_runtime_agent_instance::RuntimeAuthSlot;
use codex_runtime_agent_instance::RuntimeRegistrationMetadata;
use codex_runtime_agent_instance::RuntimeRole;
use pretty_assertions::assert_eq;
use serde_json::json;
use uuid::Uuid;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::path;
#[tokio::test]
async fn plugin_runtime_auth_shares_registration_and_strips_cross_origin_auth() {
for (mode, pooled) in [
(RuntimeAuthMode::DualHeader, false),
(RuntimeAuthMode::AssertionOnly, false),
(RuntimeAuthMode::DualHeader, true),
(RuntimeAuthMode::AssertionOnly, true),
] {
let server = MockServer::start().await;
let destination = MockServer::start().await;
Mock::given(path("/"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"enabled": true, "plugins": [],
})))
.expect(1)
.mount(&destination)
.await;
Mock::given(path("/agent/register"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"runtime_agent_instance_id": "runtime-plugins", "agent_host_id": "host-a",
"actor": {"kind": "chatgpt", "user_id": "user-a", "account_id": "account-a"},
})))
.expect(1)
.mount(&server)
.await;
Mock::given(path("/backend-api/ps/plugins/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [], "pagination": {"next_page_token": null},
})))
.expect(2)
.mount(&server)
.await;
Mock::given(path("/backend-api/plugins/featured"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
.expect(1)
.mount(&server)
.await;
Mock::given(path("/backend-api/ps/plugins/suggested/codex"))
.respond_with(ResponseTemplate::new(307).insert_header("location", destination.uri()))
.expect(1)
.mount(&server)
.await;
let claims = json!({
"iss": "https://auth.openai.com/", "session_id": "login-a",
"https://api.openai.com/auth": {"user_id": "user-a", "chatgpt_account_id": "account-a"},
});
let token = format!(
"header.{}.signature",
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string())
);
let auth = CodexAuth::from_external_chatgpt_tokens(
&token,
"account-a",
/*chatgpt_plan_type*/ None,
)
.unwrap();
let slot = RuntimeAuthSlot::default();
let controller = NetworkPolicyController::default();
let policy = controller.policy();
let factory = HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault)
.with_network_policy(policy.clone());
let home = tempfile::tempdir().unwrap();
codex_login::auth::login_with_chatgpt_auth_tokens(
home.path(),
&token,
"account-a",
/*chatgpt_plan_type*/ None,
)
.unwrap();
let credentials = Arc::new(
AuthManager::new(
home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::Ephemeral,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
AuthRouteConfig::from_http_client_factory(factory.clone()),
)
.await,
);
assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted));
let mut config = if pooled {
crate::manager::PluginsConfigInput::new(
Default::default(),
Default::default(),
"openai".into(),
/*plugins_enabled*/ true,
/*remote_plugin_enabled*/ true,
"https://chatgpt.com/backend-api".into(),
factory,
/*product_sku*/ None,
)
.remote_plugin_service_config(Some((Arc::clone(&slot), mode)))
} else {
RemotePluginServiceConfig::new_with_selected_runtime_auth(
"https://chatgpt.com/backend-api".into(),
factory,
/*product_sku*/ None,
(Arc::clone(&slot), mode),
)
};
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
// Enabled requests cannot silently fall back before the harness initializes the slot.
assert!(
fetch_recommended_plugins(&config, Some(&auth))
.await
.is_err()
);
assert!(server.received_requests().await.unwrap().is_empty());
slot.set(Arc::new(RuntimeAuthManager::new(
credentials,
AgentHostAuthorization::InstallationId {
installation_id: Uuid::nil(),
},
RuntimeRegistrationMetadata {
harness: Some("codex".into()),
harness_version: None,
role: RuntimeRole::Orchestrator,
},
format!("{}/agent/register", server.uri()).parse().unwrap(),
)))
.unwrap();
get_remote_plugin_list_page(
&config,
&auth,
RemotePluginScope::Global,
/*page_token*/ None,
/*collection*/ None,
)
.await
.unwrap();
crate::remote_legacy::fetch_remote_featured_plugin_ids(
&config,
Some(&auth),
/*product*/ None,
)
.await
.unwrap();
fetch_recommended_plugins(&config, Some(&auth))
.await
.unwrap();
let redirected = destination.received_requests().await.unwrap();
assert_eq!(redirected.len(), 1);
assert!(!redirected[0].headers.contains_key("authorization"));
assert!(
!redirected[0]
.headers
.contains_key("x-openai-agent-assertion")
);
destination.verify().await;
let url = format!("{}/backend-api/ps/plugins/list", server.uri());
let pending = config
.authenticated_request(Method::GET, &url, &auth)
.await
.unwrap();
policy.invalidate();
assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted));
assert!(matches!(
pending.send().await,
Err(RouteAwareRequestError::Policy(NetworkPolicyDenied::Revoked))
));
let response = config
.authenticated_request(Method::GET, &url, &auth)
.await
.unwrap()
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let requests = server.received_requests().await.unwrap();
let mut proof_ids = HashSet::new();
for request in requests
.iter()
.filter(|r| r.url.path() != "/agent/register")
{
let authorization = request.headers["authorization"].to_str().unwrap();
let proof = match mode {
RuntimeAuthMode::DualHeader => {
assert_eq!(authorization, format!("Bearer {token}"));
request.headers["x-openai-agent-assertion"]
.to_str()
.unwrap()
}
RuntimeAuthMode::AssertionOnly => {
assert!(!request.headers.contains_key("x-openai-agent-assertion"));
authorization.strip_prefix("AgentAssertion ").unwrap()
}
};
assert_eq!(request.headers["chatgpt-account-id"], "account-a");
let payload: JsonValue = serde_json::from_slice(
&base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(proof.split('.').nth(2).unwrap())
.unwrap(),
)
.unwrap();
assert_eq!(payload["runtime_agent_instance"], "runtime-plugins");
assert!(proof_ids.insert(payload["id"].as_str().unwrap().to_string()));
}
assert_eq!(proof_ids.len(), 4);
server.verify().await;
}
}
#[tokio::test]
async fn custom_plugin_backend_keeps_legacy_auth_with_runtime_enabled() {
for mode in [RuntimeAuthMode::DualHeader, RuntimeAuthMode::AssertionOnly] {
let server = MockServer::start().await;
Mock::given(path("/backend-api/plugins/featured"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
.expect(1)
.mount(&server)
.await;
let config = RemotePluginServiceConfig::new_with_runtime_auth(
format!("{}/backend-api", server.uri()),
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
/*product_sku*/ None,
(RuntimeAuthSlot::default(), mode),
);
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
crate::remote_legacy::fetch_remote_featured_plugin_ids(
&config,
Some(&auth),
/*product*/ None,
)
.await
.unwrap();
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 1);
assert!(
requests[0].headers["authorization"]
.to_str()
.unwrap()
.starts_with("Bearer ")
);
assert!(!requests[0].headers.contains_key("x-openai-agent-assertion"));
server.verify().await;
}
}
#[tokio::test]
async fn unsupported_openai_plugin_backend_fails_before_registration() {
for mode in [RuntimeAuthMode::DualHeader, RuntimeAuthMode::AssertionOnly] {
let server = MockServer::start().await;
let auth = CodexAuth::from_api_key("key-a");
let runtime = (
Arc::new(std::sync::OnceLock::from(Arc::new(
RuntimeAuthManager::new(
AuthManager::from_auth_for_testing(auth.clone()),
AgentHostAuthorization::InstallationId {
installation_id: Uuid::nil(),
},
RuntimeRegistrationMetadata {
harness: Some("codex".into()),
harness_version: None,
role: RuntimeRole::Orchestrator,
},
format!("{}/register", server.uri()).parse().unwrap(),
),
))),
mode,
);
let config = RemotePluginServiceConfig::new_with_runtime_auth(
"https://future.openai.com/backend-api".to_string(),
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
/*product_sku*/ None,
runtime,
);
assert!(
config
.authenticated_request(
Method::GET,
"https://future.openai.com/backend-api/plugins/featured",
&auth,
)
.await
.is_err()
);
assert!(server.received_requests().await.unwrap().is_empty());
}
}
+29 -4
View File
@@ -33,6 +33,7 @@ use codex_login::test_support::auth_manager_from_optional_auth;
use codex_protocol::auth::AuthMode;
use codex_protocol::protocol::Product;
use codex_protocol::protocol::SkillScope;
use codex_runtime_agent_instance::RuntimeAuthSlot;
use codex_skills::LoadedSkillRoot;
use codex_skills::LoadedSkills;
use codex_skills::SkillError;
@@ -81,6 +82,7 @@ pub(crate) fn test_plugins_manager_with_options(
codex_home,
restriction_product,
test_auth_manager(auth_mode),
RuntimeAuthSlot::default(),
test_skill_root_loader(),
)
}
@@ -94,6 +96,7 @@ pub(crate) fn test_plugins_manager_with_auth_manager(
codex_home,
restriction_product,
auth_manager,
RuntimeAuthSlot::default(),
test_skill_root_loader(),
)
}
@@ -327,6 +330,13 @@ pub(crate) struct RecordingHttpClientSelector {
}
impl RecordingHttpClientSelector {
fn record_url(&self, url: &str) {
match self.selected_urls.lock() {
Ok(mut selected_urls) => selected_urls.push(url.to_string()),
Err(error) => panic!("selected URL recorder lock should not be poisoned: {error}"),
}
}
pub(crate) fn new() -> (Arc<Self>, Arc<Mutex<Vec<String>>>) {
let selected_urls = Arc::new(Mutex::new(Vec::new()));
let delegate = RouteAwareClientPool::with_chatgpt_cloudflare_cookies(
@@ -344,13 +354,27 @@ impl RecordingHttpClientSelector {
}
impl HttpClientSelector for RecordingHttpClientSelector {
fn for_current_account(&self) -> Arc<dyn HttpClientSelector> {
Arc::new(Self {
selected_urls: Arc::clone(&self.selected_urls),
delegate: self.delegate.for_current_account(),
})
}
fn request(&self, method: Method, url: &str) -> RequestBuilder {
match self.selected_urls.lock() {
Ok(mut selected_urls) => selected_urls.push(url.to_string()),
Err(error) => panic!("selected URL recorder lock should not be poisoned: {error}"),
}
self.record_url(url);
self.delegate.request(method, url)
}
fn request_for_account(
&self,
method: Method,
url: &str,
binding: &codex_http_client::NetworkPolicy,
) -> Option<RequestBuilder> {
self.record_url(url);
self.delegate.request_for_account(method, url, binding)
}
fn outbound_proxy_policy(&self) -> OutboundProxyPolicy {
self.delegate.outbound_proxy_policy()
}
@@ -362,6 +386,7 @@ pub(crate) fn recording_remote_plugin_service_config(
let (http_clients, selected_urls) = RecordingHttpClientSelector::new();
(
RemotePluginServiceConfig {
runtime_auth: None,
chatgpt_base_url,
product_sku: crate::remote::CODEX_PRODUCT_SKU.to_string(),
http_clients,