[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
Unverified
parent e2398d0b16
commit 9dbdb4e2c0
12 changed files with 897 additions and 144 deletions
+1 -1
View File
@@ -341,7 +341,7 @@ fn configured_marketplace_sources_by_root(
codex_home: &Path,
plugins_input: &PluginsConfigInput,
) -> HashMap<PathBuf, JsonMarketplaceSource> {
let marketplace_sources = configured_marketplace_sources(plugins_input);
let marketplace_sources = configured_marketplace_sources(plugins_input, codex_home);
let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else {
return HashMap::new();
};
+11 -1
View File
@@ -10,6 +10,7 @@ use codex_core_plugins::PluginInstallOutcome;
use codex_core_plugins::PluginInstallRequest;
use codex_core_plugins::PluginsConfigInput;
use codex_core_plugins::PluginsManager;
use codex_core_plugins::allowed_configured_marketplace_names;
use codex_core_plugins::installed_marketplaces::marketplace_install_root;
use codex_core_plugins::installed_marketplaces::resolve_configured_marketplace_root;
use codex_core_plugins::marketplace::MarketplaceListError;
@@ -228,7 +229,7 @@ pub async fn run_plugin_list(
.is_none_or(|name| marketplace.name == *name)
})
.collect::<Vec<_>>();
let marketplace_sources = configured_marketplace_sources(&plugins_input);
let marketplace_sources = configured_marketplace_sources(&plugins_input, codex_home.as_path());
if args.json {
let output = JsonPluginListOutput::from_marketplaces(
@@ -480,6 +481,7 @@ pub(crate) struct JsonMarketplaceSource {
pub(crate) fn configured_marketplace_sources(
plugins_input: &PluginsConfigInput,
codex_home: &Path,
) -> HashMap<String, JsonMarketplaceSource> {
let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else {
return HashMap::new();
@@ -490,9 +492,12 @@ pub(crate) fn configured_marketplace_sources(
else {
return HashMap::new();
};
let allowed_marketplace_names =
allowed_configured_marketplace_names(&plugins_input.config_layer_stack, codex_home);
marketplaces
.iter()
.filter(|(marketplace_name, _)| allowed_marketplace_names.contains(*marketplace_name))
.filter_map(|(marketplace_name, marketplace)| {
let source_type = marketplace
.get("source_type")
@@ -746,11 +751,16 @@ pub(crate) fn configured_marketplace_snapshot_issues(
else {
return Vec::new();
};
let allowed_marketplace_names =
allowed_configured_marketplace_names(&plugins_input.config_layer_stack, codex_home);
let default_install_root = marketplace_install_root(codex_home);
let mut manifest_paths = Vec::new();
let mut issues = Vec::new();
for (configured_name, marketplace) in configured_marketplaces {
if !allowed_marketplace_names.contains(configured_name) {
continue;
}
if marketplace_name.is_some_and(|name| configured_name != name) {
continue;
}
+3 -1
View File
@@ -205,6 +205,7 @@ fn setup_local_marketplace_with_implicit_system_roots() -> Result<(TempDir, Temp
let cache_home = TempDir::new()?;
let runtime_root = cache_home
.path()
.join(".cache")
.join("codex-runtimes")
.join("codex-primary-runtime")
.join("plugins")
@@ -844,7 +845,8 @@ async fn plugin_list_ignores_implicit_system_marketplace_roots_without_manifests
let (codex_home, source, cache_home) = setup_local_marketplace_with_implicit_system_roots()?;
codex_command(codex_home.path())?
.env("XDG_CACHE_HOME", cache_home.path())
.env("HOME", cache_home.path())
.env("USERPROFILE", cache_home.path())
.args(["plugin", "list"])
.assert()
.success()
@@ -7,6 +7,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use tracing::warn;
use crate::marketplace::find_marketplace_manifest_path;
use crate::marketplace_policy::project_effective_user_config;
pub const INSTALLED_MARKETPLACES_DIR: &str = ".tmp/marketplaces";
@@ -18,7 +19,7 @@ pub fn installed_marketplace_roots_from_layer_stack(
config_layer_stack: &ConfigLayerStack,
codex_home: &Path,
) -> Vec<AbsolutePathBuf> {
let Some(user_config) = config_layer_stack.effective_user_config() else {
let Some(user_config) = project_effective_user_config(config_layer_stack, codex_home) else {
return Vec::new();
};
let Some(marketplaces_value) = user_config.get("marketplaces") else {
+1
View File
@@ -55,6 +55,7 @@ pub use manager::PluginUninstallError;
pub use manager::PluginsConfigInput;
pub use manager::PluginsManager;
pub use manager::RecommendedPluginCandidatesInput;
pub use marketplace_policy::allowed_configured_marketplace_names;
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError;
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome;
pub use provider::ExecutorPluginProvider;
+142 -71
View File
@@ -8,8 +8,9 @@ use crate::manifest::PluginManifestPaths;
use crate::manifest::load_plugin_manifest;
use crate::marketplace::MarketplacePluginSource;
use crate::marketplace::find_marketplace_plugin;
use crate::marketplace::list_marketplaces;
use crate::marketplace::list_marketplaces_with_home;
use crate::marketplace::load_marketplace;
use crate::marketplace_policy::configured_plugins_from_stack;
use crate::npm_source::materialize_npm_plugin_source;
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
use crate::remote::RemoteInstalledPlugin;
@@ -84,6 +85,18 @@ enum NonCuratedCacheRefreshMode {
ForceReinstall,
}
#[derive(Debug)]
pub(crate) struct NonCuratedCacheRefreshOutcome {
pub(crate) cache_refreshed: bool,
pub(crate) errors: Vec<NonCuratedCacheRefreshError>,
}
#[derive(Debug)]
pub(crate) struct NonCuratedCacheRefreshError {
pub(crate) marketplace_name: String,
pub(crate) message: String,
}
pub(crate) fn log_plugin_load_errors(plugins: &[LoadedPlugin<McpServerConfig>]) {
for plugin in plugins.iter().filter(|plugin| plugin.error.is_some()) {
if let Some(error) = plugin.error.as_deref() {
@@ -129,7 +142,7 @@ async fn load_plugins_from_layer_stack_with_scope(
scope: PluginLoadScope<'_>,
) -> Vec<LoadedPlugin<McpServerConfig>> {
let configured_plugins = merge_configured_plugins_with_remote_installed(
configured_plugins_from_stack(config_layer_stack),
configured_plugins_from_stack(config_layer_stack, store.codex_home().as_path()),
extra_plugins,
store,
remote_global_catalog_active,
@@ -410,24 +423,54 @@ pub fn curated_plugin_cache_version(plugin_version: &str) -> String {
}
}
pub fn refresh_non_curated_plugin_cache(
#[cfg(test)]
pub(crate) fn refresh_non_curated_plugin_cache(
codex_home: &Path,
additional_roots: &[AbsolutePathBuf],
configured_plugin_keys: &[String],
) -> Result<bool, String> {
collapse_non_curated_cache_refresh(refresh_non_curated_plugin_cache_detailed(
codex_home,
additional_roots,
configured_plugin_keys,
))
}
pub(crate) fn refresh_non_curated_plugin_cache_detailed(
codex_home: &Path,
additional_roots: &[AbsolutePathBuf],
configured_plugin_keys: &[String],
) -> Result<NonCuratedCacheRefreshOutcome, String> {
refresh_non_curated_plugin_cache_with_mode(
codex_home,
additional_roots,
configured_plugin_keys,
NonCuratedCacheRefreshMode::IfVersionChanged,
)
}
pub fn refresh_non_curated_plugin_cache_force_reinstall(
#[cfg(test)]
pub(crate) fn refresh_non_curated_plugin_cache_force_reinstall(
codex_home: &Path,
additional_roots: &[AbsolutePathBuf],
configured_plugin_keys: &[String],
) -> Result<bool, String> {
collapse_non_curated_cache_refresh(refresh_non_curated_plugin_cache_force_reinstall_detailed(
codex_home,
additional_roots,
configured_plugin_keys,
))
}
pub(crate) fn refresh_non_curated_plugin_cache_force_reinstall_detailed(
codex_home: &Path,
additional_roots: &[AbsolutePathBuf],
configured_plugin_keys: &[String],
) -> Result<NonCuratedCacheRefreshOutcome, String> {
refresh_non_curated_plugin_cache_with_mode(
codex_home,
additional_roots,
configured_plugin_keys,
NonCuratedCacheRefreshMode::ForceReinstall,
)
}
@@ -435,16 +478,32 @@ pub fn refresh_non_curated_plugin_cache_force_reinstall(
fn refresh_non_curated_plugin_cache_with_mode(
codex_home: &Path,
additional_roots: &[AbsolutePathBuf],
configured_plugin_keys: &[String],
mode: NonCuratedCacheRefreshMode,
) -> Result<bool, String> {
let configured_non_curated_plugin_ids =
non_curated_plugin_ids_from_config_keys(configured_plugins_from_codex_home(
codex_home,
"failed to read user config while refreshing non-curated plugin cache",
"failed to parse user config while refreshing non-curated plugin cache",
));
) -> Result<NonCuratedCacheRefreshOutcome, String> {
let mut configured_non_curated_plugin_ids = configured_plugin_keys
.iter()
.filter_map(|plugin_key| match PluginId::parse(plugin_key) {
Ok(plugin_id) if !is_openai_curated_marketplace_name(&plugin_id.marketplace_name) => {
Some(plugin_id)
}
Ok(_) => None,
Err(err) => {
warn!(
plugin_key,
error = %err,
"ignoring invalid plugin key during non-curated cache refresh setup"
);
None
}
})
.collect::<Vec<_>>();
configured_non_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key);
if configured_non_curated_plugin_ids.is_empty() {
return Ok(false);
return Ok(NonCuratedCacheRefreshOutcome {
cache_refreshed: false,
errors: Vec::new(),
});
}
let configured_non_curated_plugin_keys = configured_non_curated_plugin_ids
.iter()
@@ -452,7 +511,7 @@ fn refresh_non_curated_plugin_cache_with_mode(
.collect::<HashSet<_>>();
let store = PluginStore::try_new(codex_home.to_path_buf()).map_err(|err| err.to_string())?;
let marketplace_outcome = list_marketplaces(additional_roots)
let marketplace_outcome = list_marketplaces_with_home(additional_roots, /*home_dir*/ None)
.map_err(|err| format!("failed to discover marketplaces for cache refresh: {err}"))?;
let mut plugin_sources = HashMap::<String, (MarketplacePluginSource, Option<String>)>::new();
@@ -462,14 +521,18 @@ fn refresh_non_curated_plugin_cache_with_mode(
}
for plugin in marketplace.plugins {
let plugin_id =
PluginId::new(plugin.name.clone(), marketplace.name.clone()).map_err(|err| {
match err {
PluginIdError::Invalid(message) => {
format!("failed to prepare non-curated plugin cache refresh: {message}")
}
}
})?;
let plugin_id = match PluginId::new(plugin.name.clone(), marketplace.name.clone()) {
Ok(plugin_id) => plugin_id,
Err(PluginIdError::Invalid(message)) => {
warn!(
plugin = plugin.name,
marketplace = marketplace.name,
error = %message,
"ignoring invalid plugin entry during cache refresh"
);
continue;
}
};
let plugin_key = plugin_id.as_key();
if !configured_non_curated_plugin_keys.contains(&plugin_key) {
continue;
@@ -504,6 +567,7 @@ fn refresh_non_curated_plugin_cache_with_mode(
}
let mut cache_refreshed = false;
let mut refresh_errors = Vec::new();
for plugin_id in configured_non_curated_plugin_ids {
let plugin_key = plugin_id.as_key();
let Some((source, manifest_fallback_contents)) = plugin_sources.get(&plugin_key).cloned()
@@ -515,49 +579,70 @@ fn refresh_non_curated_plugin_cache_with_mode(
);
continue;
};
let materialized =
materialize_marketplace_plugin_source(codex_home, &source).map_err(|err| {
format!("failed to materialize plugin source for {plugin_key}: {err}")
})?;
let source_path = materialized.path.clone();
let plugin_version = match manifest_fallback_contents.as_deref() {
Some(manifest_contents) => plugin_version_for_source_with_fallback_manifest(
source_path.as_path(),
manifest_contents,
),
None => plugin_version_for_source(source_path.as_path()),
}
.map_err(|err| format!("failed to read plugin version for {plugin_key}: {err}"))?;
let refresh_result = (|| -> Result<bool, String> {
let materialized =
materialize_marketplace_plugin_source(codex_home, &source).map_err(|err| {
format!("failed to materialize plugin source for {plugin_key}: {err}")
})?;
let source_path = materialized.path;
let plugin_version = match manifest_fallback_contents.as_deref() {
Some(manifest_contents) => plugin_version_for_source_with_fallback_manifest(
source_path.as_path(),
manifest_contents,
),
None => plugin_version_for_source(source_path.as_path()),
}
.map_err(|err| format!("failed to read plugin version for {plugin_key}: {err}"))?;
if mode == NonCuratedCacheRefreshMode::IfVersionChanged
&& store.active_plugin_version(&plugin_id).as_deref() == Some(plugin_version.as_str())
{
continue;
}
if mode == NonCuratedCacheRefreshMode::IfVersionChanged
&& store.active_plugin_version(&plugin_id).as_deref()
== Some(plugin_version.as_str())
{
return Ok(false);
}
match manifest_fallback_contents.as_deref() {
Some(manifest_contents) => store.install_with_version_and_fallback_manifest(
source_path,
plugin_id.clone(),
plugin_version,
manifest_contents,
),
None => store.install_with_version(source_path, plugin_id.clone(), plugin_version),
match manifest_fallback_contents.as_deref() {
Some(manifest_contents) => store.install_with_version_and_fallback_manifest(
source_path,
plugin_id.clone(),
plugin_version,
manifest_contents,
),
None => store.install_with_version(source_path, plugin_id.clone(), plugin_version),
}
.map_err(|err| format!("failed to refresh plugin cache for {plugin_key}: {err}"))?;
Ok(true)
})();
match refresh_result {
Ok(refreshed) => cache_refreshed |= refreshed,
Err(message) => refresh_errors.push(NonCuratedCacheRefreshError {
marketplace_name: plugin_id.marketplace_name,
message,
}),
}
.map_err(|err| format!("failed to refresh plugin cache for {plugin_key}: {err}"))?;
cache_refreshed = true;
}
Ok(cache_refreshed)
Ok(NonCuratedCacheRefreshOutcome {
cache_refreshed,
errors: refresh_errors,
})
}
fn configured_plugins_from_stack(
config_layer_stack: &ConfigLayerStack,
) -> HashMap<String, PluginConfig> {
let Some(user_config) = config_layer_stack.effective_user_config() else {
return HashMap::new();
};
configured_plugins_from_user_config_value(&user_config)
#[cfg(test)]
fn collapse_non_curated_cache_refresh(
outcome: Result<NonCuratedCacheRefreshOutcome, String>,
) -> Result<bool, String> {
let outcome = outcome?;
if outcome.errors.is_empty() {
Ok(outcome.cache_refreshed)
} else {
Err(outcome
.errors
.into_iter()
.map(|error| error.message)
.collect::<Vec<_>>()
.join("; "))
}
}
fn is_full_git_sha(value: &str) -> bool {
@@ -647,20 +732,6 @@ fn curated_plugin_ids_from_config_keys(
configured_curated_plugin_ids
}
fn non_curated_plugin_ids_from_config_keys(
configured_plugins: HashMap<String, PluginConfig>,
) -> Vec<PluginId> {
let mut configured_non_curated_plugin_ids = configured_plugin_ids(
configured_plugins,
"ignoring invalid plugin key during non-curated cache refresh setup",
)
.into_iter()
.filter(|plugin_id| !is_openai_curated_marketplace_name(&plugin_id.marketplace_name))
.collect::<Vec<_>>();
configured_non_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key);
configured_non_curated_plugin_ids
}
pub fn configured_curated_plugin_ids_from_codex_home(codex_home: &Path) -> Vec<PluginId> {
curated_plugin_ids_from_config_keys(configured_plugins_from_codex_home(
codex_home,
+1 -1
View File
@@ -43,7 +43,7 @@ fn configured_plugins_from_stack_merges_user_layers() {
)
.expect("valid config layer stack");
let plugins = configured_plugins_from_stack(&stack);
let plugins = configured_plugins_from_stack(&stack, temp_dir.path());
assert_eq!(
plugins,
+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;
+249 -3
View File
@@ -100,6 +100,19 @@ fn config_layer_stack_with_requirements(
.expect("build config layer stack")
}
fn plugins_config_input_with_requirements(
codex_home: &Path,
user_config: &str,
requirements: &str,
) -> PluginsConfigInput {
PluginsConfigInput::new(
config_layer_stack_with_requirements(codex_home, user_config, requirements),
/*plugins_enabled*/ true,
/*remote_plugin_enabled*/ false,
String::new(),
)
}
#[test]
fn plugins_manager_tracks_auth_mode() {
let tmp = TempDir::new().unwrap();
@@ -122,6 +135,184 @@ fn plugins_manager_tracks_auth_mode() {
assert_eq!(manager_with_auth.auth_mode(), Some(AuthMode::Chatgpt));
}
#[tokio::test]
async fn marketplace_policy_projection_disables_installed_plugin_and_invalidates_cache() {
let codex_home = TempDir::new().expect("create Codex home");
write_plugin(
&codex_home.path().join("plugins/cache/company"),
"sample/local",
"sample",
);
let user_config = r#"
[marketplaces.company]
source_type = "git"
source = "https://github.com/example/company.git"
[plugins."sample@company"]
enabled = true
"#;
let allowed = plugins_config_input_with_requirements(
codex_home.path(),
user_config,
r#"
[marketplaces]
restrict_to_allowed_sources = true
[marketplaces.allowed_sources.company]
source = "git"
url = "https://github.com/example/company.git"
"#,
);
let blocked = plugins_config_input_with_requirements(
codex_home.path(),
user_config,
r#"
[marketplaces]
restrict_to_allowed_sources = true
[marketplaces.allowed_sources.other]
source = "git"
url = "https://github.com/example/other.git"
"#,
);
let manager = PluginsManager::new(codex_home.path().to_path_buf());
let allowed_outcome = manager.plugins_for_config(&allowed).await;
assert_eq!(allowed_outcome.plugins().len(), 1);
assert_eq!(allowed_outcome.plugins()[0].config_name, "sample@company");
let blocked_outcome = manager.plugins_for_config(&blocked).await;
assert_eq!(blocked_outcome, PluginLoadOutcome::default());
}
#[tokio::test]
async fn plugin_read_rejects_marketplace_blocked_by_requirements() {
let codex_home = TempDir::new().expect("create Codex home");
let marketplace_root = codex_home.path().join("marketplace");
write_plugin(&marketplace_root, "sample", "sample");
write_file(
&marketplace_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "company",
"plugins": [
{
"name": "sample",
"source": {"source": "local", "path": "./sample"}
}
]
}"#,
);
let config = plugins_config_input_with_requirements(
codex_home.path(),
"",
r#"
[marketplaces]
restrict_to_allowed_sources = true
"#,
);
let marketplace_path =
AbsolutePathBuf::try_from(marketplace_root.join(".agents/plugins/marketplace.json"))
.expect("absolute marketplace path");
let err = PluginsManager::new(codex_home.path().to_path_buf())
.read_plugin_for_config(
&config,
&PluginReadRequest {
plugin_name: "sample".to_string(),
marketplace_path,
},
)
.await
.expect_err("blocked marketplace should not be readable");
assert!(matches!(
err,
MarketplaceError::InvalidMarketplaceFile { .. }
));
}
#[test]
fn marketplace_policy_filters_discovered_marketplaces_by_configured_name() {
let codex_home = TempDir::new().expect("create Codex home");
let repo_root = codex_home.path().join("repo");
let subdirectory = repo_root.join("worktree/subdirectory");
fs::create_dir_all(&subdirectory).expect("create input subdirectory");
write_plugin(&repo_root, "sample", "sample");
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "company",
"plugins": [
{
"name": "sample",
"source": {"source": "local", "path": "./sample"}
}
]
}"#,
);
init_git_repo(&repo_root);
let repo_root = AbsolutePathBuf::try_from(repo_root).expect("absolute repository root");
let subdirectory =
AbsolutePathBuf::try_from(subdirectory).expect("absolute input subdirectory");
let manager = PluginsManager::new(codex_home.path().to_path_buf());
let user_config = format!(
r#"
[marketplaces.company]
source_type = "local"
source = {:?}
"#,
repo_root.as_path()
);
let allowed = plugins_config_input_with_requirements(
codex_home.path(),
&user_config,
&format!(
r#"
[marketplaces]
restrict_to_allowed_sources = true
[marketplaces.allowed_sources.company]
source = "local"
path = {:?}
"#,
repo_root.as_path()
),
);
let blocked = plugins_config_input_with_requirements(
codex_home.path(),
&user_config,
&format!(
r#"
[marketplaces]
restrict_to_allowed_sources = true
[marketplaces.allowed_sources.subdirectory]
source = "local"
path = {:?}
"#,
subdirectory.as_path()
),
);
let allowed_outcome = manager
.list_marketplaces_for_config(
&allowed,
std::slice::from_ref(&subdirectory),
/*include_openai_curated*/ false,
)
.expect("list allowed marketplace");
assert_eq!(allowed_outcome.marketplaces.len(), 1);
assert_eq!(allowed_outcome.marketplaces[0].name, "company");
let blocked_outcome = manager
.list_marketplaces_for_config(
&blocked,
std::slice::from_ref(&subdirectory),
/*include_openai_curated*/ false,
)
.expect("list blocked marketplace");
assert_eq!(blocked_outcome.marketplaces, Vec::new());
}
fn write_auth_projection_plugin(codex_home: &Path, name: &str, include_app: bool) {
let plugin_root = codex_home
.join("plugins/cache")
@@ -3348,7 +3539,11 @@ plugins = true
.unwrap()
.marketplaces
.into_iter()
.find(|marketplace| marketplace.name == "debug")
.find(|marketplace| {
marketplace.path
== AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json"))
.unwrap()
})
.unwrap()
.plugins
.into_iter()
@@ -3785,7 +3980,7 @@ enabled = true
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(
&config,
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
/*include_openai_curated*/ true,
)
.unwrap()
@@ -3793,7 +3988,11 @@ enabled = true
let marketplace = marketplaces
.into_iter()
.find(|marketplace| marketplace.name == "debug")
.find(|marketplace| {
marketplace.path
== AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json"))
.unwrap()
})
.expect("debug marketplace should be listed");
let mut plugins = marketplace.plugins;
@@ -5256,6 +5455,7 @@ enabled = true
refresh_non_curated_plugin_cache(
tmp.path(),
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
&["sample-plugin@debug".to_string()],
)
.expect("cache refresh should succeed")
);
@@ -5308,6 +5508,7 @@ enabled = true
refresh_non_curated_plugin_cache(
tmp.path(),
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
&["sample-plugin@debug".to_string()],
)
.expect("cache refresh should reinstall missing configured plugin")
);
@@ -5367,6 +5568,7 @@ enabled = true
refresh_non_curated_plugin_cache(
tmp.path(),
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
&["sample-plugin@debug".to_string()],
)
.expect("cache refresh should materialize configured Git plugin")
);
@@ -5420,6 +5622,7 @@ enabled = true
!refresh_non_curated_plugin_cache(
tmp.path(),
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
&["sample-plugin@debug".to_string()],
)
.expect("cache refresh should be a no-op when configured plugins are current")
);
@@ -5473,6 +5676,7 @@ enabled = true
refresh_non_curated_plugin_cache_force_reinstall(
tmp.path(),
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
&["sample-plugin@debug".to_string()],
)
.expect("cache refresh should reinstall unchanged local version")
);
@@ -5531,6 +5735,7 @@ enabled = true
refresh_non_curated_plugin_cache(
tmp.path(),
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
&["sample-plugin@debug".to_string()],
)
.expect("cache refresh should ignore unrelated invalid plugin manifests")
);
@@ -5542,6 +5747,47 @@ enabled = true
);
}
#[test]
fn refresh_non_curated_plugin_cache_continues_after_plugin_error() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_plugin_with_version(&repo_root, "z-good", "z-good", Some("1.2.3"));
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "a-broken",
"source": {
"source": "local",
"path": "./missing"
}
},
{
"name": "z-good",
"source": {
"source": "local",
"path": "./z-good"
}
}
]
}"#,
);
let err = refresh_non_curated_plugin_cache(
tmp.path(),
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
&["a-broken@debug".to_string(), "z-good@debug".to_string()],
)
.expect_err("broken plugin should be reported after refreshing the remaining plugins");
assert!(err.contains("a-broken@debug"));
assert!(tmp.path().join("plugins/cache/debug/z-good/1.2.3").is_dir());
}
#[tokio::test]
async fn load_plugins_ignores_project_config_files() {
let codex_home = TempDir::new().unwrap();
+118 -1
View File
@@ -18,9 +18,13 @@ use codex_config::MarketplaceAllowedSourceToml;
use codex_config::RequirementSource;
use codex_config::types::MarketplaceConfig;
use codex_config::types::MarketplaceSourceType;
use codex_config::types::PluginConfig;
use codex_plugin::PluginId;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path::paths_match_after_normalization;
use regex::Regex;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use url::Url;
@@ -70,7 +74,7 @@ impl MarketplacePolicy {
}
}
fn is_restricted(&self) -> bool {
pub(crate) fn is_restricted(&self) -> bool {
self.restricted.is_some()
}
@@ -198,6 +202,104 @@ impl AllowedMarketplaceSource {
}
}
pub(crate) fn project_effective_user_config(
config_layer_stack: &ConfigLayerStack,
codex_home: &Path,
) -> Option<toml::Value> {
let mut user_config = config_layer_stack.effective_user_config()?;
let policy = MarketplacePolicy::from_requirements(config_layer_stack.requirements());
if !policy.is_restricted() {
return Some(user_config);
}
let allowed_marketplace_names =
allowed_configured_marketplace_names_with_policy(&user_config, &policy, codex_home);
let configured_marketplace_names = user_config
.get("marketplaces")
.and_then(toml::Value::as_table)
.map(|marketplaces| marketplaces.keys().cloned().collect::<HashSet<_>>())
.unwrap_or_default();
if let Some(marketplaces) = user_config
.get_mut("marketplaces")
.and_then(toml::Value::as_table_mut)
{
marketplaces
.retain(|marketplace_name, _| allowed_marketplace_names.contains(marketplace_name));
}
if let Some(plugins) = user_config
.get_mut("plugins")
.and_then(toml::Value::as_table_mut)
{
plugins.retain(|plugin_key, _| {
let Ok(plugin_id) = PluginId::parse(plugin_key) else {
return false;
};
(is_openai_curated_marketplace_name(&plugin_id.marketplace_name)
&& !configured_marketplace_names.contains(&plugin_id.marketplace_name))
|| allowed_marketplace_names.contains(&plugin_id.marketplace_name)
});
}
Some(user_config)
}
pub fn allowed_configured_marketplace_names(
config_layer_stack: &ConfigLayerStack,
codex_home: &Path,
) -> HashSet<String> {
let Some(user_config) = config_layer_stack.effective_user_config() else {
return HashSet::new();
};
let policy = MarketplacePolicy::from_requirements(config_layer_stack.requirements());
allowed_configured_marketplace_names_with_policy(&user_config, &policy, codex_home)
}
fn allowed_configured_marketplace_names_with_policy(
user_config: &toml::Value,
policy: &MarketplacePolicy,
codex_home: &Path,
) -> HashSet<String> {
let Some(marketplaces) = user_config
.get("marketplaces")
.and_then(toml::Value::as_table)
else {
return HashSet::new();
};
if !policy.is_restricted() {
return marketplaces.keys().cloned().collect();
}
marketplaces
.iter()
.filter_map(|(marketplace_name, marketplace)| {
let allowed = match managed_marketplace_config_name(codex_home, marketplace) {
Some(expected_name) => expected_name == marketplace_name,
None => policy
.validate_configured_marketplace(marketplace_name, marketplace)
.is_ok(),
};
allowed.then(|| marketplace_name.clone())
})
.collect()
}
pub(crate) fn configured_plugins_from_stack(
config_layer_stack: &ConfigLayerStack,
codex_home: &Path,
) -> HashMap<String, PluginConfig> {
let Some(user_config) = project_effective_user_config(config_layer_stack, codex_home) else {
return HashMap::new();
};
let Some(plugins_value) = user_config.get("plugins") else {
return HashMap::new();
};
match plugins_value.clone().try_into() {
Ok(plugins) => plugins,
Err(err) => {
tracing::warn!("invalid plugins config: {err}");
HashMap::new()
}
}
}
pub(crate) fn validate_marketplace_source_for_add(
codex_home: &Path,
requirements: &ConfigRequirements,
@@ -358,6 +460,21 @@ fn managed_marketplace_name(
managed_local_marketplace_name(codex_home, root.as_path())
}
fn managed_marketplace_config_name(
codex_home: &Path,
marketplace: &toml::Value,
) -> Option<&'static str> {
if marketplace.get("source_type").and_then(toml::Value::as_str) != Some("local") {
return None;
}
let path = marketplace
.get("source")
.and_then(toml::Value::as_str)
.map(Path::new)
.filter(|path| path.is_absolute())?;
managed_local_marketplace_name(codex_home, path)
}
fn managed_local_marketplace_name(codex_home: &Path, root: &Path) -> Option<&'static str> {
for marketplace_name in [
OPENAI_BUNDLED_MARKETPLACE_NAME,
@@ -383,6 +383,197 @@ restrict_to_allowed_sources = true
assert!(validate_marketplace_name_for_add(expected_name, "other").is_err());
}
#[test]
fn projected_user_config_removes_blocked_marketplaces_and_plugins() {
let codex_home = TempDir::new().expect("create Codex home");
let config_file = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))
.expect("absolute config path");
let stack = config_layer_stack_with_user_config(
r#"
[marketplaces]
restrict_to_allowed_sources = true
[marketplaces.allowed_sources.company]
source = "git"
url = "https://github.com/example/allowed.git"
"#,
Some((
r#"
[marketplaces.allowed]
source_type = "git"
source = "https://github.com/example/allowed.git"
[marketplaces.blocked]
source_type = "git"
source = "https://github.com/example/blocked.git"
[plugins."sample@allowed"]
enabled = true
[plugins."sample@blocked"]
enabled = true
"#,
config_file,
)),
);
let projected =
project_effective_user_config(&stack, codex_home.path()).expect("project user config");
assert_eq!(
projected["marketplaces"]
.as_table()
.expect("projected marketplaces")
.keys()
.cloned()
.collect::<Vec<_>>(),
vec!["allowed".to_string()]
);
assert_eq!(
configured_plugins_from_stack(&stack, codex_home.path())
.into_keys()
.collect::<Vec<_>>(),
vec!["sample@allowed".to_string()]
);
let raw = stack.effective_user_config().expect("raw user config");
assert!(raw["marketplaces"]["blocked"].is_table());
assert!(raw["plugins"]["sample@blocked"].is_table());
}
#[test]
fn managed_bundled_config_is_retained_only_at_its_owned_path() {
let codex_home = TempDir::new().expect("create Codex home");
let bundled_root = codex_home
.path()
.join(".tmp/bundled-marketplaces")
.join(crate::OPENAI_BUNDLED_MARKETPLACE_NAME);
let config_file = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))
.expect("absolute config path");
let stack = config_layer_stack_with_user_config(
r#"
[marketplaces]
restrict_to_allowed_sources = true
"#,
Some((
&format!(
r#"
[marketplaces.openai-bundled]
source_type = "local"
source = {bundled_root:?}
[marketplaces.openai-bundled-alpha]
source_type = "local"
source = "/tmp/not-managed"
[marketplaces.evil]
source_type = "local"
source = {bundled_root:?}
[plugins."sample@openai-bundled"]
enabled = true
[plugins."sample@openai-bundled-alpha"]
enabled = true
[plugins."sample@evil"]
enabled = true
"#
),
config_file,
)),
);
let projected =
project_effective_user_config(&stack, codex_home.path()).expect("project user config");
assert_eq!(
projected["marketplaces"]
.as_table()
.expect("projected marketplaces")
.keys()
.cloned()
.collect::<Vec<_>>(),
vec![crate::OPENAI_BUNDLED_MARKETPLACE_NAME.to_string()]
);
assert_eq!(
projected["plugins"]
.as_table()
.expect("projected plugins")
.keys()
.cloned()
.collect::<Vec<_>>(),
vec![format!("sample@{}", crate::OPENAI_BUNDLED_MARKETPLACE_NAME)]
);
}
#[test]
fn allowlisted_config_names_are_not_globally_reserved() {
let codex_home = TempDir::new().expect("create Codex home");
let source_root = TempDir::new().expect("create marketplace root");
let source_root = source_root
.path()
.canonicalize()
.expect("canonical marketplace root");
let config_file = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))
.expect("absolute config path");
let stack = config_layer_stack_with_user_config(
&format!(
r#"
[marketplaces]
restrict_to_allowed_sources = true
[marketplaces.allowed_sources.local]
source = "local"
path = {source_root:?}
"#
),
Some((
&format!(
r#"
[marketplaces.openai-bundled]
source_type = "local"
source = {source_root:?}
[marketplaces.openai-curated]
source_type = "local"
source = {source_root:?}
[plugins."sample@openai-bundled"]
enabled = true
[plugins."sample@openai-curated"]
enabled = true
"#
),
config_file,
)),
);
let projected =
project_effective_user_config(&stack, codex_home.path()).expect("project user config");
assert_eq!(
projected["marketplaces"]
.as_table()
.expect("projected marketplaces")
.keys()
.cloned()
.collect::<Vec<_>>(),
vec!["openai-bundled".to_string(), "openai-curated".to_string()]
);
assert_eq!(
projected["plugins"]
.as_table()
.expect("projected plugins")
.keys()
.cloned()
.collect::<Vec<_>>(),
vec![
"sample@openai-bundled".to_string(),
"sample@openai-curated".to_string()
]
);
}
#[test]
fn blocked_upgrade_is_rejected_before_marketplace_installation() {
let codex_home = TempDir::new().expect("create Codex home");
+12 -1
View File
@@ -37,6 +37,7 @@ pub struct PluginInstallResult {
#[derive(Debug, Clone)]
pub struct PluginStore {
codex_home: AbsolutePathBuf,
root: AbsolutePathBuf,
data_root: AbsolutePathBuf,
}
@@ -59,14 +60,24 @@ impl PluginStore {
let data_root =
AbsolutePathBuf::from_absolute_path_checked(codex_home.join(PLUGINS_DATA_DIR))
.map_err(|err| PluginStoreError::io("failed to resolve plugin data root", err))?;
let codex_home = AbsolutePathBuf::from_absolute_path_checked(codex_home)
.map_err(|err| PluginStoreError::io("failed to resolve Codex home", err))?;
Ok(Self { root, data_root })
Ok(Self {
codex_home,
root,
data_root,
})
}
pub fn root(&self) -> &AbsolutePathBuf {
&self.root
}
pub(crate) fn codex_home(&self) -> &AbsolutePathBuf {
&self.codex_home
}
pub fn plugin_base_root(&self, plugin_id: &PluginId) -> AbsolutePathBuf {
self.root
.join(&plugin_id.marketplace_name)