mirror of
https://github.com/openai/codex.git
synced 2026-10-26 18:38:47 +00:00
[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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user