Prefer remote Sites over the bundled plugin (#42100)

## Why

When the remote Sites plugin is installed and its local bundle is available, exposing the bundled copy as well creates duplicate catalog entries and can load the wrong skills.

## What changed

- Persist an account- and backend-scoped exclusion for `sites@openai-bundled` once the remote replacement is installed and loadable.
- Apply the exclusion to plugin catalogs, search, direct reads and installs, runtime loading, hooks, and skill caches while preserving the remote plugin's server-owned enabled state.
- Restore the bundled fallback when the remote plugin is absent or its local files are unavailable, and throttle repeated availability checks for 60 seconds.

## Testing

- Added manager tests for persistence across restarts, account/backend isolation, fallback restoration, concurrent checks, and refresh throttling.
- Added app-server and agent-turn tests confirming that catalog, search, and skill loading prefer remote Sites.

GitOrigin-RevId: bc1154f79d3107910fee9fa27389a0ddb48ce6cf
This commit is contained in:
Jiwon Kim
2026-09-01 16:58:54 +00:00
committed by copyberry
parent a17ee5705c
commit b6ab99ed87
16 changed files with 864 additions and 27 deletions
@@ -0,0 +1,201 @@
//! Account/backend-scoped exclusions for bundled plugins replaced by remote plugins.
//! Only plugin IDs are persisted; remote installation and enablement remain server-owned.
// TODO(sites-migration): Remove this module when bundled Sites is retired and rollback support ends.
use super::*;
use serde::Deserialize;
use serde::Serialize;
use sha2::Digest;
use sha2::Sha256;
const BUNDLED_SITES_PLUGIN_ID: &str = "sites@openai-bundled";
const REMOTE_SITES_PLUGIN_ID: &str = "sites@openai-curated-remote";
fn is_remote_sites(plugin: &RemoteInstalledPlugin) -> bool {
plugin.name == "sites"
&& plugin.marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME
&& plugin.id.strip_prefix("plugins~").unwrap_or(&plugin.id)
== "plugin_connector_1p_689987207de08191979cf68eca2941c6"
}
#[derive(Default, Deserialize, Serialize)]
struct BundledPluginExclusions {
#[serde(rename = "disabled-bundled-plugin-ids")]
ids: BTreeSet<String>,
}
fn read_exclusions(path: &Path) -> BundledPluginExclusions {
std::fs::read(path)
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
impl PluginsManager {
// Temporary direct-access guard using the same exclusion state as catalog/runtime loading.
pub(super) fn bundled_sites_is_hidden(
&self,
config: &PluginsConfigInput,
plugin_id: &str,
) -> bool {
plugin_id == BUNDLED_SITES_PLUGIN_ID
&& self.excluded_bundled_plugin_ids(config).contains(plugin_id)
}
fn bundled_plugin_exclusion_path(
&self,
base_url: &str,
auth: Option<&CodexAuth>,
) -> Option<PathBuf> {
if self.restriction_product != Some(Product::Codex) {
return None;
}
let auth = auth.filter(|auth| auth.uses_codex_backend())?;
let account_id = auth.get_account_id().filter(|id| !id.is_empty())?;
let key = serde_json::to_vec(&(
base_url,
account_id,
auth.get_chatgpt_user_id(),
auth.is_workspace_account(),
))
.ok()?;
let digest = Sha256::digest(key);
Some(
self.codex_home
.join("cache/bundled_plugin_exclusions")
.join(format!("{digest:x}.json")),
)
}
fn remote_sites_bundle_is_loadable(&self) -> bool {
PluginId::parse(REMOTE_SITES_PLUGIN_ID)
.ok()
.is_some_and(|id| self.store.active_plugin_root(&id).is_some())
}
pub(super) fn excluded_bundled_plugin_ids(
&self,
config: &PluginsConfigInput,
) -> BTreeSet<String> {
if !config.plugins_enabled || !self.remote_global_catalog_active(config) {
return BTreeSet::new();
}
let auth = self.auth_manager.auth_cached();
let Some(path) =
self.bundled_plugin_exclusion_path(&config.chatgpt_base_url, auth.as_ref())
else {
return BTreeSet::new();
};
let mut exclusions = read_exclusions(&path).ids;
// Preserve the bundled fallback if the replacement's local files are missing.
if !self.remote_sites_bundle_is_loadable() {
exclusions.remove(BUNDLED_SITES_PLUGIN_ID);
}
exclusions
}
// Called only after a current, successful installed-snapshot fetch. Failed requests never
// erase the last known exclusion; successful absence restores the bundled fallback.
pub(super) fn update_sites_exclusion(
&self,
base_url: &str,
auth: Option<&CodexAuth>,
plugins: &[RemoteInstalledPlugin],
) -> bool {
let Some(path) = self.bundled_plugin_exclusion_path(base_url, auth) else {
return false;
};
let mut exclusions = read_exclusions(&path);
let remote_sites_ready =
plugins.iter().any(is_remote_sites) && self.remote_sites_bundle_is_loadable();
let changed = if remote_sites_ready {
exclusions.ids.insert(BUNDLED_SITES_PLUGIN_ID.to_string())
} else {
exclusions.ids.remove(BUNDLED_SITES_PLUGIN_ID)
};
if changed {
let result = serde_json::to_string(&exclusions)
.map_err(std::io::Error::other)
.and_then(|contents| codex_utils_path::write_atomically(&path, &contents));
if let Err(err) = result {
warn!(error = %err, "failed to persist bundled Sites exclusion");
}
}
changed
}
fn sites_migration_needed(
&self,
config: &PluginsConfigInput,
auth: Option<&CodexAuth>,
) -> bool {
if !config.plugins_enabled
|| !self.remote_global_catalog_active(config)
|| self
.excluded_bundled_plugin_ids(config)
.contains(BUNDLED_SITES_PLUGIN_ID)
{
return false;
}
let Some(path) = self.bundled_plugin_exclusion_path(&config.chatgpt_base_url, auth) else {
return false;
};
!self
.sites_migration_checked_at
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.is_some_and(|(checked_path, checked_at)| {
checked_path == &path && checked_at.elapsed() < Duration::from_secs(60)
})
}
/// Coordinates the first Sites migration at the shared inventory boundary. A persisted
/// exclusion skips migration on restart without restoring any remote installed metadata.
pub async fn ensure_sites_migration_ready(
&self,
config: &PluginsConfigInput,
auth: Option<&CodexAuth>,
) -> Result<Option<EffectivePluginsChange>, RemoteInstalledPluginBundleSyncError> {
if !self.sites_migration_needed(config, auth) {
return Ok(None);
}
// Sites is implicitly installed by Plugin Service. Never create an explicit install or
// overwrite account-wide preferences from bundled config; /installed owns both decisions.
let plugins = crate::remote::fetch_remote_installed_plugins(
&remote_plugin_service_config(config),
auth,
)
.await?;
let remote_sites_available = plugins.iter().any(is_remote_sites);
// A rollout-off check must not wait behind unrelated startup bundle downloads.
let _guard = if remote_sites_available {
let guard = self.acquire_remote_installed_plugin_sync_guard().await?;
// Another catalog request or background sync may have completed while we waited.
if !self.sites_migration_needed(config, auth) {
return Ok(None);
}
Some(guard)
} else {
None
};
*self
.sites_migration_checked_at
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = self
.bundled_plugin_exclusion_path(&config.chatgpt_base_url, auth)
.map(|path| (path, Instant::now()));
if !remote_sites_available {
return Ok(None);
}
let (outcome, changed) =
Box::pin(self.reconcile_remote_installed_plugins_after_acquiring_gate(config, auth))
.await?;
// Preserve materialization details so the existing callback can trust eligible hooks.
Ok(
(changed || !outcome.changed_plugins.is_empty()).then_some(EffectivePluginsChange {
materialized_remote_plugins: outcome.materialized_remote_plugins,
}),
)
}
}
@@ -1081,7 +1081,7 @@ async fn install_marketplace_plugin(codex_home: &Path, marketplace_root: &Path,
let config = load_plugins_config(codex_home, marketplace_root).await;
test_plugins_manager(codex_home.to_path_buf())
.install_plugin(
&config.config_layer_stack,
&config,
PluginInstallRequest {
plugin_name: plugin_name.to_string(),
marketplace_path: AbsolutePathBuf::try_from(
+10 -1
View File
@@ -53,6 +53,7 @@ use codex_utils_plugins::SkillDiscoveryMode;
use codex_utils_plugins::find_plugin_manifest_path;
use codex_utils_plugins::migrated_command_skills_root;
use serde_json::Value as JsonValue;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::collections::HashSet;
use std::fs;
@@ -128,6 +129,8 @@ pub(crate) fn log_plugin_load_errors(plugins: &[LoadedPlugin<McpServerConfig>])
}
/// Load configured plugins without applying auth-dependent runtime policies.
// TODO(sites-migration): Remove the exclusion parameter and lint allowance when bundled Sites is retired.
#[allow(clippy::too_many_arguments)]
#[instrument(level = "trace", skip_all)]
pub(crate) async fn load_plugins_from_layer_stack(
config_layer_stack: &ConfigLayerStack,
@@ -137,6 +140,7 @@ pub(crate) async fn load_plugins_from_layer_stack(
restriction_product: Option<Product>,
remote_global_catalog_active: bool,
skill_root_loader: &dyn SkillRootLoader<PluginSkillRoot>,
excluded_plugin_ids: &BTreeSet<String>,
) -> Vec<LoadedPlugin<McpServerConfig>> {
let skill_config_rules = skill_config_rules_from_stack(config_layer_stack);
let RemoteInstalledPluginsSnapshot {
@@ -146,6 +150,7 @@ pub(crate) async fn load_plugins_from_layer_stack(
load_plugins_from_layer_stack_with_scope(
config_layer_stack,
extra_plugins,
excluded_plugin_ids,
store,
remote_global_catalog_active,
PluginLoadScope::AllCapabilities {
@@ -162,16 +167,18 @@ pub(crate) async fn load_plugins_from_layer_stack(
async fn load_plugins_from_layer_stack_with_scope(
config_layer_stack: &ConfigLayerStack,
extra_plugins: HashMap<String, PluginConfig>,
excluded_plugin_ids: &BTreeSet<String>,
store: &PluginStore,
remote_global_catalog_active: bool,
scope: PluginLoadScope<'_>,
) -> Vec<LoadedPlugin<McpServerConfig>> {
let configured_plugins = merge_configured_plugins_with_remote_installed(
let mut configured_plugins = merge_configured_plugins_with_remote_installed(
configured_plugins_from_stack(config_layer_stack, store.codex_home().as_path()),
extra_plugins,
store,
remote_global_catalog_active,
);
configured_plugins.retain(|id, _| !excluded_plugin_ids.contains(id));
let mut configured_plugins: Vec<_> = configured_plugins.into_iter().collect();
configured_plugins.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
@@ -201,6 +208,7 @@ async fn load_plugins_from_layer_stack_with_scope(
pub async fn load_plugin_hooks_from_layer_stack(
config_layer_stack: &ConfigLayerStack,
extra_plugins: HashMap<String, PluginConfig>,
excluded_plugin_ids: &BTreeSet<String>,
store: &PluginStore,
target_curated_marketplace: TargetCuratedMarketplace,
remote_global_catalog_active: bool,
@@ -208,6 +216,7 @@ pub async fn load_plugin_hooks_from_layer_stack(
let mut plugins = load_plugins_from_layer_stack_with_scope(
config_layer_stack,
extra_plugins,
excluded_plugin_ids,
store,
remote_global_catalog_active,
PluginLoadScope::HooksOnly,
@@ -284,6 +284,7 @@ async fn installed_agent_plugin_uses_isolated_data_root_for_stdio_mcp() {
Some(Product::Codex),
/*remote_global_catalog_active*/ false,
test_skill_root_loader().as_ref(),
&BTreeSet::new(),
)
.await;
@@ -494,11 +495,13 @@ enabled = true
Some(Product::Codex),
/*remote_global_catalog_active*/ false,
test_skill_root_loader().as_ref(),
&BTreeSet::new(),
)
.await;
let hooks_only = load_plugins_from_layer_stack_with_scope(
&stack,
HashMap::new(),
&BTreeSet::new(),
&store,
/*remote_global_catalog_active*/ false,
PluginLoadScope::HooksOnly,
+49 -7
View File
@@ -1,3 +1,5 @@
#[path = "bundled_plugin_exclusions.rs"]
mod bundled_plugin_exclusions;
#[path = "marketplace_context.rs"]
mod marketplace_context;
pub use marketplace_context::PluginMarketplaceContext;
@@ -528,6 +530,8 @@ pub struct PluginsManager {
skill_root_loader: Arc<dyn SkillRootLoader<PluginSkillRoot>>,
tool_suggest_metadata_cache: ToolSuggestMetadataCache,
remote_installed_plugins_cache: RwLock<RemoteInstalledPluginsCache>,
// TODO(sites-migration): Remove this throttle together with the bundled Sites migration.
sites_migration_checked_at: Mutex<Option<(PathBuf, Instant)>>,
remote_installed_plugin_bundle_sync_gate: Arc<Semaphore>,
remote_installed_plugins_cache_refresh_state: RwLock<RemoteInstalledPluginsCacheRefreshState>,
remote_catalog_cache_refresh_state: RwLock<RemoteCatalogCacheRefreshState>,
@@ -566,6 +570,8 @@ struct PluginLoadCacheKey {
skill_config_rules: SkillConfigRules,
remote_global_catalog_active: bool,
auth_identity: Option<RemoteInstalledPluginsAuthIdentity>,
// TODO(sites-migration): Remove once bundled Sites no longer needs scoped exclusions.
excluded_plugin_ids: BTreeSet<String>,
}
impl PluginLoadCacheKey {
@@ -574,6 +580,7 @@ impl PluginLoadCacheKey {
codex_home: &Path,
remote_global_catalog_active: bool,
auth_identity: RemoteInstalledPluginsAuthIdentity,
excluded_plugin_ids: BTreeSet<String>,
) -> Self {
Self {
configured_plugins: configured_plugins_from_stack(
@@ -584,6 +591,7 @@ impl PluginLoadCacheKey {
remote_global_catalog_active,
// Local curated loads are auth-independent; only remote snapshots vary by account.
auth_identity: remote_global_catalog_active.then_some(auth_identity),
excluded_plugin_ids,
}
}
}
@@ -645,6 +653,7 @@ impl PluginsManager {
skill_root_loader,
tool_suggest_metadata_cache: ToolSuggestMetadataCache::new(),
remote_installed_plugins_cache: RwLock::new(RemoteInstalledPluginsCache::default()),
sites_migration_checked_at: Mutex::new(None),
remote_installed_plugin_bundle_sync_gate,
remote_installed_plugins_cache_refresh_state: RwLock::new(
RemoteInstalledPluginsCacheRefreshState::default(),
@@ -723,6 +732,7 @@ impl PluginsManager {
self.codex_home.as_path(),
self.remote_global_catalog_active(config),
RemoteInstalledPluginsAuthIdentity::from_auth(auth.as_ref()),
self.excluded_bundled_plugin_ids(config),
);
self.loaded_plugins_cache
.lock()
@@ -758,11 +768,12 @@ impl PluginsManager {
let auth = self.auth_manager.auth_cached();
let auth_mode = auth.as_ref().map(CodexAuth::api_auth_mode);
let auth_identity = RemoteInstalledPluginsAuthIdentity::from_auth(auth.as_ref());
let cache_key = PluginLoadCacheKey::from_config(
let mut cache_key = PluginLoadCacheKey::from_config(
config,
self.codex_home.as_path(),
remote_global_catalog_active,
auth_identity.clone(),
self.excluded_bundled_plugin_ids(config),
);
if let Some(plugins) = self.cached_loaded_plugins(&cache_key) {
let outcome = self.resolve_loaded_plugins_for_auth(plugins, auth_mode);
@@ -795,6 +806,8 @@ impl PluginsManager {
}
continue;
}
// Migration may have changed the effective exclusion while this load was queued.
cache_key.excluded_plugin_ids = self.excluded_bundled_plugin_ids(config);
if let Some(plugins) = self.cached_loaded_plugins(&cache_key) {
let outcome = self.resolve_loaded_plugins_for_auth(plugins, auth_mode);
if *auth_change_receiver.borrow() == auth_revision {
@@ -818,6 +831,7 @@ impl PluginsManager {
self.restriction_product,
remote_global_catalog_active,
self.skill_root_loader.as_ref(),
&cache_key.excluded_plugin_ids,
)
.await;
loaded_cache_metrics::record_duration(
@@ -934,6 +948,7 @@ impl PluginsManager {
load_plugin_hooks_from_layer_stack(
config_layer_stack,
self.remote_installed_plugin_configs(),
&self.excluded_bundled_plugin_ids(config),
&self.store,
target_curated_marketplace,
self.remote_global_catalog_active(config),
@@ -1212,6 +1227,7 @@ impl PluginsManager {
generation,
plugins,
auth,
&config.chatgpt_base_url,
RemoteInstalledPluginsCachePublication::Refresh,
) else {
let auth_identity = RemoteInstalledPluginsAuthIdentity::from_auth(auth);
@@ -1315,6 +1331,7 @@ impl PluginsManager {
generation: u64,
plugins: Vec<RemoteInstalledPlugin>,
auth: Option<&CodexAuth>,
service_base_url: &str,
publication: RemoteInstalledPluginsCachePublication,
) -> Option<bool> {
let auth_identity = RemoteInstalledPluginsAuthIdentity::from_auth(auth);
@@ -1347,8 +1364,11 @@ impl PluginsManager {
cache.reconciliation_generation = None;
cache.generation = cache.generation.wrapping_add(1);
}
let needs_effective_plugins_refresh =
is_reconcile && std::mem::take(&mut cache.needs_effective_plugins_refresh);
// TODO(sites-migration): Remove exclusion publication once bundled Sites is retired.
let exclusion_changed = self.update_sites_exclusion(service_base_url, auth, &plugins);
let needs_effective_plugins_refresh = (is_reconcile
&& std::mem::take(&mut cache.needs_effective_plugins_refresh))
|| exclusion_changed;
if cache.plugins.as_ref() == Some(&plugins) {
drop(cache);
if needs_effective_plugins_refresh {
@@ -1372,6 +1392,7 @@ impl PluginsManager {
generation,
plugins,
auth.as_ref(),
"",
RemoteInstalledPluginsCachePublication::Refresh,
)
.expect("test cache generation should remain current")
@@ -1656,6 +1677,7 @@ impl PluginsManager {
generation,
result.installed_plugins,
auth,
&config.chatgpt_base_url,
RemoteInstalledPluginsCachePublication::Reconcile,
) else {
return Err(RemoteInstalledPluginBundleSyncError::Superseded);
@@ -1956,10 +1978,10 @@ impl PluginsManager {
pub async fn install_plugin(
&self,
config_layer_stack: &ConfigLayerStack,
config: &PluginsConfigInput,
request: PluginInstallRequest,
) -> Result<PluginInstallOutcome, PluginInstallError> {
let resolved = self.resolve_installable_plugin(config_layer_stack, &request)?;
let resolved = self.resolve_installable_plugin(config, &request)?;
let plugin_id = resolved.plugin_id.clone();
match self.install_resolved_plugin(resolved).await {
Ok(outcome) => Ok(outcome),
@@ -1977,7 +1999,7 @@ impl PluginsManager {
fn resolve_installable_plugin(
&self,
config_layer_stack: &ConfigLayerStack,
config: &PluginsConfigInput,
request: &PluginInstallRequest,
) -> Result<ResolvedMarketplacePlugin, PluginInstallError> {
let resolved = match find_installable_marketplace_plugin(
@@ -1991,6 +2013,15 @@ impl PluginsManager {
return Err(err.into());
}
};
if self.bundled_sites_is_hidden(config, &resolved.plugin_id.as_key()) {
let err = MarketplaceError::PluginNotFound {
plugin_name: resolved.plugin_id.plugin_name,
marketplace_name: resolved.plugin_id.marketplace_name,
};
self.track_plugin_install_resolution_failed(&err);
return Err(err.into());
}
let config_layer_stack = &config.config_layer_stack;
if let Err(message) =
MarketplacePolicy::from_requirements(config_layer_stack.requirements())
.validate_install(
@@ -2016,7 +2047,7 @@ impl PluginsManager {
auth: Option<&CodexAuth>,
request: PluginInstallRequest,
) -> Result<PluginInstallOutcome, PluginInstallError> {
let resolved = self.resolve_installable_plugin(&config.config_layer_stack, &request)?;
let resolved = self.resolve_installable_plugin(config, &request)?;
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(
@@ -2303,6 +2334,7 @@ impl PluginsManager {
include_openai_curated: bool,
plugin_states: &ConfiguredPluginStates,
) -> Result<ConfiguredMarketplaceListOutcome, MarketplaceError> {
let excluded_plugin_ids = self.excluded_bundled_plugin_ids(config);
let marketplace_roots =
self.marketplace_roots(config, additional_roots, include_openai_curated);
let marketplace_outcome = self.list_marketplaces_with_policy(config, &marketplace_roots)?;
@@ -2317,6 +2349,9 @@ impl PluginsManager {
.into_iter()
.filter_map(|plugin| {
let plugin_key = format!("{}@{marketplace_name}", plugin.name);
if excluded_plugin_ids.contains(&plugin_key) {
return None;
}
if !seen_plugin_keys.insert(plugin_key.clone()) {
return None;
}
@@ -2430,6 +2465,12 @@ impl PluginsManager {
}
let plugin = find_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?;
if self.bundled_sites_is_hidden(config, &plugin.plugin_id.as_key()) {
return Err(MarketplaceError::PluginNotFound {
plugin_name: plugin.plugin_id.plugin_name,
marketplace_name: plugin.plugin_id.marketplace_name,
});
}
MarketplacePolicy::from_requirements(config.config_layer_stack.requirements())
.validate_install(
&config.config_layer_stack,
@@ -3239,6 +3280,7 @@ impl PluginsManager {
request.generation,
plugins,
request.auth.as_ref(),
&request.service_config.chatgpt_base_url,
RemoteInstalledPluginsCachePublication::Refresh,
) else {
continue;
+283 -8
View File
@@ -72,6 +72,7 @@ use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
use wiremock::matchers::query_param_is_missing;
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
@@ -82,6 +83,17 @@ fn unrestricted_config_layer_stack() -> ConfigLayerStack {
ConfigLayerStack::default()
}
fn unrestricted_plugins_config_input() -> PluginsConfigInput {
PluginsConfigInput::new(
unrestricted_config_layer_stack(),
String::new(),
/*plugins_enabled*/ true,
/*remote_plugin_enabled*/ false,
String::new(),
test_http_client_factory(),
)
}
fn config_layer_stack_with_requirements(
codex_home: &Path,
user_config: &str,
@@ -3105,6 +3117,7 @@ fn loaded_plugins_cache_evicts_least_recently_used_configuration() {
skill_config_rules: SkillConfigRules::default(),
remote_global_catalog_active: false,
auth_identity: None,
excluded_plugin_ids: BTreeSet::new(),
})
.collect::<Vec<_>>();
let generation = manager.loaded_plugins_cache_generation();
@@ -3149,6 +3162,7 @@ fn loaded_plugins_cache_invalidation_rejects_stale_load_completion() {
skill_config_rules: SkillConfigRules::default(),
remote_global_catalog_active: false,
auth_identity: None,
excluded_plugin_ids: BTreeSet::new(),
};
let stale_generation = manager.loaded_plugins_cache_generation();
@@ -3287,7 +3301,7 @@ async fn install_plugin_updates_config_with_relative_path_and_plugin_key() {
let result = test_plugins_manager(tmp.path().to_path_buf())
.install_plugin(
&unrestricted_config_layer_stack(),
&unrestricted_plugins_config_input(),
PluginInstallRequest {
plugin_name: "sample-plugin".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
@@ -3345,7 +3359,7 @@ source = "local"
path = {marketplace_root:?}
"#
);
let config = config_layer_stack_with_requirements(codex_home.path(), "", &requirements);
let config = plugins_config_input_with_requirements(codex_home.path(), "", &requirements);
let marketplace_path =
AbsolutePathBuf::try_from(marketplace_root.join(".agents/plugins/marketplace.json"))
.expect("absolute marketplace path");
@@ -3377,7 +3391,7 @@ source = {marketplace_root:?}
);
write_file(&codex_home.path().join(CONFIG_TOML_FILE), &user_config);
let config =
config_layer_stack_with_requirements(codex_home.path(), &user_config, &requirements);
plugins_config_input_with_requirements(codex_home.path(), &user_config, &requirements);
let outcome = manager
.install_plugin(
&config,
@@ -3403,7 +3417,7 @@ async fn install_openai_curated_plugin_uses_short_sha_cache_version() {
let result = test_plugins_manager(tmp.path().to_path_buf())
.install_plugin(
&unrestricted_config_layer_stack(),
&unrestricted_plugins_config_input(),
PluginInstallRequest {
plugin_name: "slack".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
@@ -3464,7 +3478,7 @@ async fn install_plugin_uses_manifest_version_for_non_curated_plugins() {
let result = test_plugins_manager(tmp.path().to_path_buf())
.install_plugin(
&unrestricted_config_layer_stack(),
&unrestricted_plugins_config_input(),
PluginInstallRequest {
plugin_name: "sample-plugin".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
@@ -3532,7 +3546,7 @@ async fn install_plugin_writes_marketplace_manifest_fallback_when_missing_plugin
let result = test_plugins_manager(tmp.path().to_path_buf())
.install_plugin(
&unrestricted_config_layer_stack(),
&unrestricted_plugins_config_input(),
PluginInstallRequest {
plugin_name: "quality-review".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
@@ -3632,7 +3646,7 @@ async fn install_plugin_supports_git_subdir_marketplace_sources() {
let result = test_plugins_manager(tmp.path().to_path_buf())
.install_plugin(
&unrestricted_config_layer_stack(),
&unrestricted_plugins_config_input(),
PluginInstallRequest {
plugin_name: "toolkit".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
@@ -3686,7 +3700,7 @@ async fn install_plugin_supports_relative_git_subdir_marketplace_sources() {
let result = test_plugins_manager(tmp.path().to_path_buf())
.install_plugin(
&unrestricted_config_layer_stack(),
&unrestricted_plugins_config_input(),
PluginInstallRequest {
plugin_name: "toolkit".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
@@ -5095,6 +5109,13 @@ plugins = true
marketplaces
.iter()
.map(|marketplace| marketplace.name.as_str())
// Personal marketplaces do not affect which curated catalog auth selects.
.filter(|name| {
matches!(
*name,
OPENAI_CURATED_MARKETPLACE_NAME | OPENAI_API_CURATED_MARKETPLACE_NAME
)
})
.collect::<Vec<_>>(),
vec![expected_marketplace],
"unexpected curated catalog for provider `{resolved_provider}` with auth {auth_mode:?}"
@@ -6923,6 +6944,7 @@ async fn load_plugins_uses_project_config_files() {
Some(Product::Codex),
/*remote_global_catalog_active*/ false,
test_skill_root_loader().as_ref(),
&BTreeSet::new(),
)
.await;
@@ -7210,6 +7232,255 @@ remote_plugin = true
server.verify().await;
}
#[tokio::test]
async fn sites_migration_throttles_absence_without_blocking_normal_sync() {
let home = TempDir::new().unwrap();
write_file(
&home.path().join(CONFIG_TOML_FILE),
"[features]\nplugins = true\nremote_plugin = true\n",
);
let server = MockServer::start().await;
let mut config = load_config(home.path(), home.path()).await;
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
let manager = test_plugins_manager_with_options(
home.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::Chatgpt),
);
let auth = manager.auth_manager.auth_cached().unwrap();
let installed_path = "/backend-api/ps/plugins/installed";
let failed_check = Mock::given(method("GET"))
.and(path(installed_path))
.respond_with(ResponseTemplate::new(503))
.mount_as_scoped(&server)
.await;
assert!(
manager
.ensure_sites_migration_ready(&config, Some(&auth))
.await
.is_err()
);
drop(failed_check);
Mock::given(method("GET"))
.and(path(installed_path))
.and(query_param_is_missing("includeDownloadUrls"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"plugins": [], "pagination": {"next_page_token": null}
})))
.expect(1)
.mount(&server)
.await;
// An unrelated startup download must not block a successful Sites-absence check.
let unrelated_sync = manager
.acquire_remote_installed_plugin_sync_guard()
.await
.unwrap();
for _ in 0..2 {
assert!(
tokio::time::timeout(
Duration::from_secs(1),
manager.ensure_sites_migration_ready(&config, Some(&auth)),
)
.await
.expect("Sites absence must not wait for unrelated bundle downloads")
.unwrap()
.is_none()
);
}
drop(unrelated_sync);
Mock::given(method("GET"))
.and(path(installed_path))
.and(query_param("includeDownloadUrls", "true"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"plugins": [], "pagination": {"next_page_token": null}
})))
.expect(1)
.mount(&server)
.await;
manager
.reconcile_remote_installed_plugins(&config, Some(&auth))
.await
.unwrap();
}
#[tokio::test]
async fn sites_migration_persists_only_exclusion_without_repeating_rollout() {
let home = TempDir::new().unwrap();
let marketplace_root = home.path().join(".tmp/bundled-marketplaces/openai-bundled");
write_file(
&home.path().join(CONFIG_TOML_FILE),
&format!(
"[features]\nplugins = true\nremote_plugin = true\n[plugins.\"sites@openai-bundled\"]\nenabled = true\n[marketplaces.openai-bundled]\nsource_type = \"local\"\nsource = {marketplace_root:?}\n"
),
);
write_cached_plugin(home.path(), "openai-bundled", "sites");
write_plugin(&marketplace_root, "sites", "sites");
write_file(
&marketplace_root.join(".agents/plugins/marketplace.json"),
r#"{"name":"openai-bundled","plugins":[{"name":"sites","source":{"source":"local","path":"./sites"}}]}"#,
);
let server = MockServer::start().await;
let mut config = load_config(home.path(), home.path()).await;
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
let auth_manager = test_auth_manager(Some(AuthMode::Chatgpt));
let auth = auth_manager.auth_cached().unwrap();
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"plugins": [{
"id": "plugins~plugin_connector_1p_689987207de08191979cf68eca2941c6",
"name": "sites", "scope": "GLOBAL", "enabled": false,
"installation_policy": "AVAILABLE", "authentication_policy": "ON_USE",
"release": {"version": "local", "display_name": "Sites", "description": "Sites", "interface": {}}
}],
"pagination": {"next_page_token": null}
})))
.mount(&server).await;
let manager = test_plugins_manager_with_auth_manager(
home.path().to_path_buf(),
Some(Product::Codex),
auth_manager.clone(),
);
let has_bundled_sites = |manager: &PluginsManager| {
manager
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ false)
.unwrap()
.marketplaces
.into_iter()
.flat_map(|marketplace| marketplace.plugins)
.any(|plugin| plugin.id == "sites@openai-bundled")
};
// An installed remote entry without local files must preserve the bundled runtime fallback.
manager
.reconcile_remote_installed_plugins(&config, Some(&auth))
.await
.unwrap();
assert!(has_bundled_sites(&manager));
assert_eq!(
loaded_plugin_names(&manager, &config).await,
vec!["sites@openai-bundled"]
);
write_cached_plugin(home.path(), REMOTE_GLOBAL_MARKETPLACE_NAME, "sites");
let read_request = PluginReadRequest {
plugin_name: "sites".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
marketplace_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
};
let install_request = PluginInstallRequest {
plugin_name: read_request.plugin_name.clone(),
marketplace_path: read_request.marketplace_path.clone(),
};
let (first, concurrent) = tokio::join!(
manager.ensure_sites_migration_ready(&config, Some(&auth)),
manager.ensure_sites_migration_ready(&config, Some(&auth)),
);
assert!(first.unwrap().is_some() ^ concurrent.unwrap().is_some());
assert!(!manager.remote_installed_plugin_configs()["sites@openai-curated-remote"].enabled);
assert!(
server
.received_requests()
.await
.unwrap()
.iter()
.all(|request| request.method == "GET")
);
let config_before_rejected_install = fs::read(home.path().join(CONFIG_TOML_FILE)).unwrap();
assert!(matches!(
manager.read_plugin_for_config(&config, &read_request).await,
Err(MarketplaceError::PluginNotFound { plugin_name, marketplace_name })
if plugin_name == "sites" && marketplace_name == "openai-bundled"
));
assert!(matches!(
manager.install_plugin(&config, install_request.clone()).await,
Err(PluginInstallError::Marketplace(MarketplaceError::PluginNotFound { plugin_name, marketplace_name }))
if plugin_name == "sites" && marketplace_name == "openai-bundled"
));
assert_eq!(
fs::read(home.path().join(CONFIG_TOML_FILE)).unwrap(),
config_before_rejected_install
);
let exclusion_file = std::fs::read_dir(home.path().join("cache/bundled_plugin_exclusions"))
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&std::fs::read(exclusion_file).unwrap())
.unwrap(),
serde_json::json!({"disabled-bundled-plugin-ids": ["sites@openai-bundled"]})
);
let requests = server.received_requests().await.unwrap().len();
let restarted = test_plugins_manager_with_auth_manager(
home.path().to_path_buf(),
Some(Product::Codex),
auth_manager.clone(),
);
assert!(
restarted
.ensure_sites_migration_ready(&config, Some(&auth))
.await
.unwrap()
.is_none()
);
assert_eq!(server.received_requests().await.unwrap().len(), requests);
assert!(!has_bundled_sites(&restarted));
assert!(restarted.remote_installed_plugin_configs().is_empty());
assert!(
!loaded_plugin_names(&restarted, &config)
.await
.contains(&"sites@openai-bundled".to_string())
);
// A warm runtime/skill cache must not carry this backend's exclusion into another backend.
let mut other_backend = config.clone();
other_backend.chatgpt_base_url = format!("{}/other-backend", server.uri());
assert!(
restarted
.excluded_bundled_plugin_ids(&other_backend)
.is_empty()
);
assert!(
restarted
.plugin_skill_snapshots_for_config(&config)
.is_some()
);
assert!(
restarted
.plugin_skill_snapshots_for_config(&other_backend)
.is_none()
);
assert_eq!(
loaded_plugin_names(&restarted, &other_backend).await,
vec!["sites@openai-bundled"]
);
assert!(loaded_plugin_names(&restarted, &config).await.is_empty());
server.reset().await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"plugins": [], "pagination": {"next_page_token": null}
})))
.mount(&server)
.await;
restarted
.reconcile_remote_installed_plugins(&config, Some(&auth))
.await
.unwrap();
assert!(has_bundled_sites(&restarted));
restarted
.read_plugin_for_config(&config, &read_request)
.await
.unwrap();
restarted
.install_plugin(&config, install_request)
.await
.unwrap();
}
#[tokio::test]
async fn reconcile_remote_installed_plugins_reports_cached_state_changes() {
let codex_home = TempDir::new().unwrap();
@@ -7469,6 +7740,7 @@ fn reconciliation_fences_refresh_publication_and_recovers_after_cancellation() {
stale_refresh_generation,
Vec::new(),
/*auth*/ None,
"",
RemoteInstalledPluginsCachePublication::Refresh,
),
None
@@ -7478,6 +7750,7 @@ fn reconciliation_fences_refresh_publication_and_recovers_after_cancellation() {
reconcile_generation,
vec![remote_installed_linear_plugin()],
/*auth*/ None,
"",
RemoteInstalledPluginsCachePublication::Reconcile,
),
Some(true)
@@ -7501,6 +7774,7 @@ fn reconciliation_fences_refresh_publication_and_recovers_after_cancellation() {
refresh_generation,
vec![remote_installed_plugin("beta")],
/*auth*/ None,
"",
RemoteInstalledPluginsCachePublication::Refresh,
),
Some(true)
@@ -7513,6 +7787,7 @@ fn reconciliation_fences_refresh_publication_and_recovers_after_cancellation() {
retry_generation,
vec![remote_installed_plugin("beta")],
/*auth*/ None,
"",
RemoteInstalledPluginsCachePublication::Reconcile,
),
Some(true)
@@ -77,7 +77,7 @@ host_pattern = '^github[.]com$'
assert_eq!(
manager
.install_plugin(
&config.config_layer_stack,
config,
PluginInstallRequest {
plugin_name: "sample".to_string(),
marketplace_path: AbsolutePathBuf::try_from(manifest.clone()).unwrap(),