[plugins] Enforce marketplace source policy at runtime (#29691)

## Summary

- project effective marketplace/plugin config through the enterprise
source policy so blocked installed plugins become inactive
- filter plugin list/read/discovery and CLI marketplace source/snapshot
reporting using the same policy
- enforce source admission for background marketplace cache refreshes
- continue refreshing/upgrading independent marketplaces and plugins
when one entry fails, returning per-entry errors
- include policy-projected plugin state in cache and refresh keys so
requirement changes invalidate stale results

## Stack

This is PR 2 of 2 and is based on #29690. Review the admission model and
source matcher in #29690 first; this PR contains only runtime
enforcement.

## Test plan

- `just test -p codex-core-plugins` (287 tests)
- `just test -p codex-cli
plugin_list_ignores_implicit_system_marketplace_roots_without_manifests`
- `cargo check -p codex-cli -p codex-app-server --tests`
This commit is contained in:
xl-openai
2026-06-27 15:22:05 -07:00
committed by GitHub
parent e2398d0b16
commit 9dbdb4e2c0
12 changed files with 897 additions and 144 deletions
+166 -63
View File
@@ -16,8 +16,8 @@ use crate::loader::log_plugin_load_errors;
use crate::loader::materialize_marketplace_plugin_source;
use crate::loader::plugin_capability_summary_from_root;
use crate::loader::refresh_curated_plugin_cache;
use crate::loader::refresh_non_curated_plugin_cache;
use crate::loader::refresh_non_curated_plugin_cache_force_reinstall;
use crate::loader::refresh_non_curated_plugin_cache_detailed;
use crate::loader::refresh_non_curated_plugin_cache_force_reinstall_detailed;
use crate::loader::remote_installed_plugins_to_config;
use crate::manifest::PluginManifestInterface;
use crate::manifest::load_plugin_manifest;
@@ -32,9 +32,12 @@ use crate::marketplace::MarketplacePluginSource;
use crate::marketplace::ResolvedMarketplacePlugin;
use crate::marketplace::find_installable_marketplace_plugin;
use crate::marketplace::find_marketplace_plugin;
use crate::marketplace::list_marketplaces;
use crate::marketplace::home_dir;
use crate::marketplace::list_marketplaces_with_home;
use crate::marketplace::plugin_interface_with_marketplace_category;
use crate::marketplace_policy::MarketplacePolicy;
use crate::marketplace_policy::allowed_configured_marketplace_names;
use crate::marketplace_policy::configured_plugins_from_stack;
use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeError;
use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome;
use crate::marketplace_upgrade::upgrade_configured_git_marketplaces;
@@ -84,6 +87,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_plugins::PluginSkillRoot;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::RwLock;
@@ -195,6 +199,7 @@ pub struct PluginListBackgroundTaskOptions {
#[derive(Clone, PartialEq, Eq)]
struct NonCuratedCacheRefreshRequest {
roots: Vec<AbsolutePathBuf>,
configured_plugin_keys: Vec<String>,
mode: NonCuratedCacheRefreshMode,
}
@@ -388,9 +393,16 @@ struct PluginLoadCacheKey {
}
impl PluginLoadCacheKey {
fn from_config(config: &PluginsConfigInput, remote_global_catalog_active: bool) -> Self {
fn from_config(
config: &PluginsConfigInput,
codex_home: &Path,
remote_global_catalog_active: bool,
) -> Self {
Self {
configured_plugins: configured_plugins_from_stack(&config.config_layer_stack),
configured_plugins: configured_plugins_from_stack(
&config.config_layer_stack,
codex_home,
),
skill_config_rules: skill_config_rules_from_stack(&config.config_layer_stack),
remote_global_catalog_active,
}
@@ -494,8 +506,11 @@ impl PluginsManager {
if !config.plugins_enabled {
return None;
}
let key =
PluginLoadCacheKey::from_config(config, self.remote_global_catalog_active(config));
let key = PluginLoadCacheKey::from_config(
config,
self.codex_home.as_path(),
self.remote_global_catalog_active(config),
);
self.loaded_plugins_cache
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
@@ -525,7 +540,11 @@ impl PluginsManager {
}
let remote_global_catalog_active = self.remote_global_catalog_active(config);
let cache_key = PluginLoadCacheKey::from_config(config, remote_global_catalog_active);
let cache_key = PluginLoadCacheKey::from_config(
config,
self.codex_home.as_path(),
remote_global_catalog_active,
);
if !force_reload && let Some(plugins) = self.cached_loaded_plugins(&cache_key) {
return self.resolve_loaded_plugins_for_auth(plugins);
}
@@ -1007,7 +1026,7 @@ impl PluginsManager {
options: PluginListBackgroundTaskOptions,
on_effective_plugins_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
) {
self.maybe_start_non_curated_plugin_cache_refresh(roots);
self.maybe_start_non_curated_plugin_cache_refresh(config, roots);
if options.refresh_global_remote_catalog_cache {
self.maybe_start_global_remote_catalog_cache_refresh(config, auth.clone());
}
@@ -1547,7 +1566,7 @@ impl PluginsManager {
let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config);
let marketplace_roots =
self.marketplace_roots(config, additional_roots, include_openai_curated);
let marketplace_outcome = list_marketplaces(&marketplace_roots)?;
let marketplace_outcome = self.list_marketplaces_with_policy(config, &marketplace_roots)?;
let mut seen_plugin_keys = HashSet::new();
let marketplaces = marketplace_outcome
.marketplaces
@@ -1636,11 +1655,12 @@ impl PluginsManager {
return Ok(MarketplaceListOutcome::default());
}
list_marketplaces(&self.marketplace_roots(
let marketplace_roots = self.marketplace_roots(
config,
additional_roots,
/*include_openai_curated*/ true,
))
);
self.list_marketplaces_with_policy(config, &marketplace_roots)
}
pub(crate) async fn tool_suggest_metadata_for_marketplace_plugin(
@@ -1666,6 +1686,17 @@ impl PluginsManager {
}
let plugin = find_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?;
MarketplacePolicy::from_requirements(config.config_layer_stack.requirements())
.validate_install(
&config.config_layer_stack,
self.codex_home.as_path(),
&request.marketplace_path,
&plugin.plugin_id.marketplace_name,
)
.map_err(|message| MarketplaceError::InvalidMarketplaceFile {
path: request.marketplace_path.to_path_buf(),
message,
})?;
if !self.restriction_product_matches(plugin.policy.products.as_deref()) {
return Err(MarketplaceError::PluginNotFound {
plugin_name: plugin.plugin_id.plugin_name,
@@ -2023,12 +2054,30 @@ impl PluginsManager {
));
}
if !outcome.upgraded_roots.is_empty() {
match refresh_non_curated_plugin_cache_force_reinstall(
let mut configured_plugin_keys = configured_plugins_from_stack(
&config.config_layer_stack,
self.codex_home.as_path(),
)
.into_keys()
.collect::<Vec<_>>();
configured_plugin_keys.sort_unstable();
match refresh_non_curated_plugin_cache_force_reinstall_detailed(
self.codex_home.as_path(),
&outcome.upgraded_roots,
&configured_plugin_keys,
) {
Ok(cache_refreshed) => {
self.clear_caches_after_marketplace_source_refresh(cache_refreshed);
Ok(refresh_outcome) => {
self.clear_caches_after_marketplace_source_refresh(
refresh_outcome.cache_refreshed,
);
outcome
.errors
.extend(refresh_outcome.errors.into_iter().map(|error| {
ConfiguredMarketplaceUpgradeError {
marketplace_name: error.marketplace_name,
message: error.message,
}
}));
}
Err(err) => {
self.clear_cache();
@@ -2048,9 +2097,11 @@ impl PluginsManager {
pub fn maybe_start_non_curated_plugin_cache_refresh(
self: &Arc<Self>,
config: &PluginsConfigInput,
roots: &[AbsolutePathBuf],
) {
self.schedule_non_curated_plugin_cache_refresh(
config,
roots,
NonCuratedCacheRefreshMode::IfVersionChanged,
);
@@ -2127,38 +2178,81 @@ impl PluginsManager {
fn schedule_non_curated_plugin_cache_refresh(
self: &Arc<Self>,
config: &PluginsConfigInput,
roots: &[AbsolutePathBuf],
mode: NonCuratedCacheRefreshMode,
) {
let mut roots = roots.to_vec();
let marketplace_roots =
self.marketplace_roots(config, roots, /*include_openai_curated*/ false);
let outcome = match self.list_marketplaces_with_policy(config, &marketplace_roots) {
Ok(outcome) => outcome,
Err(err) => {
warn!("failed to prepare non-curated plugin cache refresh: {err}");
return;
}
};
let policy = MarketplacePolicy::from_requirements(config.config_layer_stack.requirements());
let mut roots = outcome
.marketplaces
.into_iter()
.filter(|marketplace| !is_openai_curated_marketplace_name(&marketplace.name))
.filter_map(|marketplace| {
match policy.validate_install(
&config.config_layer_stack,
self.codex_home.as_path(),
&marketplace.path,
&marketplace.name,
) {
Ok(()) => Some(marketplace.path),
Err(err) => {
warn!(
marketplace = marketplace.name,
path = %marketplace.path.display(),
error = %err,
"skipping marketplace source during plugin cache refresh"
);
None
}
}
})
.collect::<Vec<_>>();
roots.sort_unstable();
roots.dedup();
if roots.is_empty() {
let mut configured_plugin_keys =
configured_plugins_from_stack(&config.config_layer_stack, self.codex_home.as_path())
.into_keys()
.collect::<Vec<_>>();
configured_plugin_keys.sort_unstable();
if roots.is_empty() || configured_plugin_keys.is_empty() {
return;
}
let request = NonCuratedCacheRefreshRequest { roots, mode };
let mut request = NonCuratedCacheRefreshRequest {
roots,
configured_plugin_keys,
mode,
};
let should_spawn = {
let mut state = match self.non_curated_cache_refresh_state.write() {
Ok(state) => state,
Err(err) => err.into_inner(),
};
// Collapse repeated plugin/list requests onto one worker and only queue another pass
// when the requested roots set actually changes. Forced reinstall requests are not
// deduped against the last completed pass because the same marketplace root path can
// point at newly activated files after an auto-upgrade.
if state.requested.as_ref() == Some(&request)
|| (mode == NonCuratedCacheRefreshMode::IfVersionChanged
&& !state.in_flight
&& state.last_refreshed.as_ref() == Some(&request))
{
return;
}
if mode == NonCuratedCacheRefreshMode::IfVersionChanged
if request.mode == NonCuratedCacheRefreshMode::IfVersionChanged
&& state.requested.as_ref().is_some_and(|requested| {
requested.mode == NonCuratedCacheRefreshMode::ForceReinstall
&& requested.roots == request.roots
})
{
request.mode = NonCuratedCacheRefreshMode::ForceReinstall;
}
// Collapse repeated plugin/list requests onto one worker and only queue another pass
// when the roots or configured plugin set changes. Forced reinstall requests are not
// deduped against the last completed pass because the same marketplace root path can
// point at newly activated files after an auto-upgrade.
if state.requested.as_ref() == Some(&request)
|| (request.mode == NonCuratedCacheRefreshMode::IfVersionChanged
&& !state.in_flight
&& state.last_refreshed.as_ref() == Some(&request))
{
return;
}
@@ -2349,21 +2443,33 @@ impl PluginsManager {
let refresh_result = match request.mode {
NonCuratedCacheRefreshMode::IfVersionChanged => {
refresh_non_curated_plugin_cache(self.codex_home.as_path(), &request.roots)
}
NonCuratedCacheRefreshMode::ForceReinstall => {
refresh_non_curated_plugin_cache_force_reinstall(
refresh_non_curated_plugin_cache_detailed(
self.codex_home.as_path(),
&request.roots,
&request.configured_plugin_keys,
)
}
NonCuratedCacheRefreshMode::ForceReinstall => {
refresh_non_curated_plugin_cache_force_reinstall_detailed(
self.codex_home.as_path(),
&request.roots,
&request.configured_plugin_keys,
)
}
};
let refreshed = match refresh_result {
Ok(cache_refreshed) => {
if cache_refreshed {
Ok(refresh_outcome) => {
if refresh_outcome.cache_refreshed {
self.clear_cache();
}
true
for error in &refresh_outcome.errors {
warn!(
marketplace = error.marketplace_name,
error = %error.message,
"failed to refresh configured plugin cache"
);
}
refresh_outcome.errors.is_empty()
}
Err(err) => {
self.clear_cache();
@@ -2391,7 +2497,8 @@ impl PluginsManager {
&self,
config: &PluginsConfigInput,
) -> (HashSet<String>, HashSet<String>) {
let configured_plugins = configured_plugins_from_stack(&config.config_layer_stack);
let configured_plugins =
configured_plugins_from_stack(&config.config_layer_stack, self.codex_home.as_path());
let installed_plugins = configured_plugins
.keys()
.filter(|plugin_key| {
@@ -2448,6 +2555,27 @@ impl PluginsManager {
roots.dedup();
roots
}
fn list_marketplaces_with_policy(
&self,
config: &PluginsConfigInput,
roots: &[AbsolutePathBuf],
) -> Result<MarketplaceListOutcome, MarketplaceError> {
let mut outcome = list_marketplaces_with_home(roots, home_dir().as_deref())?;
let policy = MarketplacePolicy::from_requirements(config.config_layer_stack.requirements());
if !policy.is_restricted() {
return Ok(outcome);
}
let allowed_marketplace_names = allowed_configured_marketplace_names(
&config.config_layer_stack,
self.codex_home.as_path(),
);
outcome.marketplaces.retain(|marketplace| {
is_openai_curated_marketplace_name(&marketplace.name)
|| allowed_marketplace_names.contains(&marketplace.name)
});
Ok(outcome)
}
}
pub(crate) fn remote_plugin_install_required_description(
@@ -2612,31 +2740,6 @@ impl PluginUninstallError {
}
}
pub(crate) fn configured_plugins_from_stack(
config_layer_stack: &ConfigLayerStack,
) -> HashMap<String, PluginConfig> {
// Plugin entries remain persisted user config only.
let Some(user_config) = config_layer_stack.effective_user_config() else {
return HashMap::new();
};
configured_plugins_from_user_config_value(&user_config)
}
fn configured_plugins_from_user_config_value(
user_config: &toml::Value,
) -> HashMap<String, PluginConfig> {
let Some(plugins_value) = user_config.get("plugins") else {
return HashMap::new();
};
match plugins_value.clone().try_into() {
Ok(plugins) => plugins,
Err(err) => {
warn!("invalid plugins config: {err}");
HashMap::new()
}
}
}
#[cfg(test)]
#[path = "manager_tests.rs"]
mod tests;