mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +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:
1 parent
e2398d0b16
commit
9dbdb4e2c0
12 files changed
+874
-121
No files matched your search
@@ -341,7 +341,7 @@ fn configured_marketplace_sources_by_root(
|
|||||||
codex_home: &Path,
|
codex_home: &Path,
|
||||||
plugins_input: &PluginsConfigInput,
|
plugins_input: &PluginsConfigInput,
|
||||||
) -> HashMap<PathBuf, JsonMarketplaceSource> {
|
) -> 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 {
|
let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else {
|
||||||
return HashMap::new();
|
return HashMap::new();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use codex_core_plugins::PluginInstallOutcome;
|
|||||||
use codex_core_plugins::PluginInstallRequest;
|
use codex_core_plugins::PluginInstallRequest;
|
||||||
use codex_core_plugins::PluginsConfigInput;
|
use codex_core_plugins::PluginsConfigInput;
|
||||||
use codex_core_plugins::PluginsManager;
|
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::marketplace_install_root;
|
||||||
use codex_core_plugins::installed_marketplaces::resolve_configured_marketplace_root;
|
use codex_core_plugins::installed_marketplaces::resolve_configured_marketplace_root;
|
||||||
use codex_core_plugins::marketplace::MarketplaceListError;
|
use codex_core_plugins::marketplace::MarketplaceListError;
|
||||||
@@ -228,7 +229,7 @@ pub async fn run_plugin_list(
|
|||||||
.is_none_or(|name| marketplace.name == *name)
|
.is_none_or(|name| marketplace.name == *name)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.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 {
|
if args.json {
|
||||||
let output = JsonPluginListOutput::from_marketplaces(
|
let output = JsonPluginListOutput::from_marketplaces(
|
||||||
@@ -480,6 +481,7 @@ pub(crate) struct JsonMarketplaceSource {
|
|||||||
|
|
||||||
pub(crate) fn configured_marketplace_sources(
|
pub(crate) fn configured_marketplace_sources(
|
||||||
plugins_input: &PluginsConfigInput,
|
plugins_input: &PluginsConfigInput,
|
||||||
|
codex_home: &Path,
|
||||||
) -> HashMap<String, JsonMarketplaceSource> {
|
) -> HashMap<String, JsonMarketplaceSource> {
|
||||||
let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else {
|
let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else {
|
||||||
return HashMap::new();
|
return HashMap::new();
|
||||||
@@ -490,9 +492,12 @@ pub(crate) fn configured_marketplace_sources(
|
|||||||
else {
|
else {
|
||||||
return HashMap::new();
|
return HashMap::new();
|
||||||
};
|
};
|
||||||
|
let allowed_marketplace_names =
|
||||||
|
allowed_configured_marketplace_names(&plugins_input.config_layer_stack, codex_home);
|
||||||
|
|
||||||
marketplaces
|
marketplaces
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|(marketplace_name, _)| allowed_marketplace_names.contains(*marketplace_name))
|
||||||
.filter_map(|(marketplace_name, marketplace)| {
|
.filter_map(|(marketplace_name, marketplace)| {
|
||||||
let source_type = marketplace
|
let source_type = marketplace
|
||||||
.get("source_type")
|
.get("source_type")
|
||||||
@@ -746,11 +751,16 @@ pub(crate) fn configured_marketplace_snapshot_issues(
|
|||||||
else {
|
else {
|
||||||
return Vec::new();
|
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 default_install_root = marketplace_install_root(codex_home);
|
||||||
let mut manifest_paths = Vec::new();
|
let mut manifest_paths = Vec::new();
|
||||||
let mut issues = Vec::new();
|
let mut issues = Vec::new();
|
||||||
for (configured_name, marketplace) in configured_marketplaces {
|
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) {
|
if marketplace_name.is_some_and(|name| configured_name != name) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ fn setup_local_marketplace_with_implicit_system_roots() -> Result<(TempDir, Temp
|
|||||||
let cache_home = TempDir::new()?;
|
let cache_home = TempDir::new()?;
|
||||||
let runtime_root = cache_home
|
let runtime_root = cache_home
|
||||||
.path()
|
.path()
|
||||||
|
.join(".cache")
|
||||||
.join("codex-runtimes")
|
.join("codex-runtimes")
|
||||||
.join("codex-primary-runtime")
|
.join("codex-primary-runtime")
|
||||||
.join("plugins")
|
.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()?;
|
let (codex_home, source, cache_home) = setup_local_marketplace_with_implicit_system_roots()?;
|
||||||
|
|
||||||
codex_command(codex_home.path())?
|
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"])
|
.args(["plugin", "list"])
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.success()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
|
|||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::marketplace::find_marketplace_manifest_path;
|
use crate::marketplace::find_marketplace_manifest_path;
|
||||||
|
use crate::marketplace_policy::project_effective_user_config;
|
||||||
|
|
||||||
pub const INSTALLED_MARKETPLACES_DIR: &str = ".tmp/marketplaces";
|
pub const INSTALLED_MARKETPLACES_DIR: &str = ".tmp/marketplaces";
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ pub fn installed_marketplace_roots_from_layer_stack(
|
|||||||
config_layer_stack: &ConfigLayerStack,
|
config_layer_stack: &ConfigLayerStack,
|
||||||
codex_home: &Path,
|
codex_home: &Path,
|
||||||
) -> Vec<AbsolutePathBuf> {
|
) -> 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();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
let Some(marketplaces_value) = user_config.get("marketplaces") else {
|
let Some(marketplaces_value) = user_config.get("marketplaces") else {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ pub use manager::PluginUninstallError;
|
|||||||
pub use manager::PluginsConfigInput;
|
pub use manager::PluginsConfigInput;
|
||||||
pub use manager::PluginsManager;
|
pub use manager::PluginsManager;
|
||||||
pub use manager::RecommendedPluginCandidatesInput;
|
pub use manager::RecommendedPluginCandidatesInput;
|
||||||
|
pub use marketplace_policy::allowed_configured_marketplace_names;
|
||||||
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError;
|
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError;
|
||||||
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome;
|
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome;
|
||||||
pub use provider::ExecutorPluginProvider;
|
pub use provider::ExecutorPluginProvider;
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ use crate::manifest::PluginManifestPaths;
|
|||||||
use crate::manifest::load_plugin_manifest;
|
use crate::manifest::load_plugin_manifest;
|
||||||
use crate::marketplace::MarketplacePluginSource;
|
use crate::marketplace::MarketplacePluginSource;
|
||||||
use crate::marketplace::find_marketplace_plugin;
|
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::load_marketplace;
|
||||||
|
use crate::marketplace_policy::configured_plugins_from_stack;
|
||||||
use crate::npm_source::materialize_npm_plugin_source;
|
use crate::npm_source::materialize_npm_plugin_source;
|
||||||
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
|
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
|
||||||
use crate::remote::RemoteInstalledPlugin;
|
use crate::remote::RemoteInstalledPlugin;
|
||||||
@@ -84,6 +85,18 @@ enum NonCuratedCacheRefreshMode {
|
|||||||
ForceReinstall,
|
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>]) {
|
pub(crate) fn log_plugin_load_errors(plugins: &[LoadedPlugin<McpServerConfig>]) {
|
||||||
for plugin in plugins.iter().filter(|plugin| plugin.error.is_some()) {
|
for plugin in plugins.iter().filter(|plugin| plugin.error.is_some()) {
|
||||||
if let Some(error) = plugin.error.as_deref() {
|
if let Some(error) = plugin.error.as_deref() {
|
||||||
@@ -129,7 +142,7 @@ async fn load_plugins_from_layer_stack_with_scope(
|
|||||||
scope: PluginLoadScope<'_>,
|
scope: PluginLoadScope<'_>,
|
||||||
) -> Vec<LoadedPlugin<McpServerConfig>> {
|
) -> Vec<LoadedPlugin<McpServerConfig>> {
|
||||||
let configured_plugins = merge_configured_plugins_with_remote_installed(
|
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,
|
extra_plugins,
|
||||||
store,
|
store,
|
||||||
remote_global_catalog_active,
|
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,
|
codex_home: &Path,
|
||||||
additional_roots: &[AbsolutePathBuf],
|
additional_roots: &[AbsolutePathBuf],
|
||||||
|
configured_plugin_keys: &[String],
|
||||||
) -> Result<bool, 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(
|
refresh_non_curated_plugin_cache_with_mode(
|
||||||
codex_home,
|
codex_home,
|
||||||
additional_roots,
|
additional_roots,
|
||||||
|
configured_plugin_keys,
|
||||||
NonCuratedCacheRefreshMode::IfVersionChanged,
|
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,
|
codex_home: &Path,
|
||||||
additional_roots: &[AbsolutePathBuf],
|
additional_roots: &[AbsolutePathBuf],
|
||||||
|
configured_plugin_keys: &[String],
|
||||||
) -> Result<bool, 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(
|
refresh_non_curated_plugin_cache_with_mode(
|
||||||
codex_home,
|
codex_home,
|
||||||
additional_roots,
|
additional_roots,
|
||||||
|
configured_plugin_keys,
|
||||||
NonCuratedCacheRefreshMode::ForceReinstall,
|
NonCuratedCacheRefreshMode::ForceReinstall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -435,16 +478,32 @@ pub fn refresh_non_curated_plugin_cache_force_reinstall(
|
|||||||
fn refresh_non_curated_plugin_cache_with_mode(
|
fn refresh_non_curated_plugin_cache_with_mode(
|
||||||
codex_home: &Path,
|
codex_home: &Path,
|
||||||
additional_roots: &[AbsolutePathBuf],
|
additional_roots: &[AbsolutePathBuf],
|
||||||
|
configured_plugin_keys: &[String],
|
||||||
mode: NonCuratedCacheRefreshMode,
|
mode: NonCuratedCacheRefreshMode,
|
||||||
) -> Result<bool, String> {
|
) -> Result<NonCuratedCacheRefreshOutcome, String> {
|
||||||
let configured_non_curated_plugin_ids =
|
let mut configured_non_curated_plugin_ids = configured_plugin_keys
|
||||||
non_curated_plugin_ids_from_config_keys(configured_plugins_from_codex_home(
|
.iter()
|
||||||
codex_home,
|
.filter_map(|plugin_key| match PluginId::parse(plugin_key) {
|
||||||
"failed to read user config while refreshing non-curated plugin cache",
|
Ok(plugin_id) if !is_openai_curated_marketplace_name(&plugin_id.marketplace_name) => {
|
||||||
"failed to parse user config while refreshing non-curated plugin cache",
|
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() {
|
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
|
let configured_non_curated_plugin_keys = configured_non_curated_plugin_ids
|
||||||
.iter()
|
.iter()
|
||||||
@@ -452,7 +511,7 @@ fn refresh_non_curated_plugin_cache_with_mode(
|
|||||||
.collect::<HashSet<_>>();
|
.collect::<HashSet<_>>();
|
||||||
|
|
||||||
let store = PluginStore::try_new(codex_home.to_path_buf()).map_err(|err| err.to_string())?;
|
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}"))?;
|
.map_err(|err| format!("failed to discover marketplaces for cache refresh: {err}"))?;
|
||||||
let mut plugin_sources = HashMap::<String, (MarketplacePluginSource, Option<String>)>::new();
|
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 {
|
for plugin in marketplace.plugins {
|
||||||
let plugin_id =
|
let plugin_id = match PluginId::new(plugin.name.clone(), marketplace.name.clone()) {
|
||||||
PluginId::new(plugin.name.clone(), marketplace.name.clone()).map_err(|err| {
|
Ok(plugin_id) => plugin_id,
|
||||||
match err {
|
Err(PluginIdError::Invalid(message)) => {
|
||||||
PluginIdError::Invalid(message) => {
|
warn!(
|
||||||
format!("failed to prepare non-curated plugin cache refresh: {message}")
|
plugin = plugin.name,
|
||||||
|
marketplace = marketplace.name,
|
||||||
|
error = %message,
|
||||||
|
"ignoring invalid plugin entry during cache refresh"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
})?;
|
|
||||||
let plugin_key = plugin_id.as_key();
|
let plugin_key = plugin_id.as_key();
|
||||||
if !configured_non_curated_plugin_keys.contains(&plugin_key) {
|
if !configured_non_curated_plugin_keys.contains(&plugin_key) {
|
||||||
continue;
|
continue;
|
||||||
@@ -504,6 +567,7 @@ fn refresh_non_curated_plugin_cache_with_mode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut cache_refreshed = false;
|
let mut cache_refreshed = false;
|
||||||
|
let mut refresh_errors = Vec::new();
|
||||||
for plugin_id in configured_non_curated_plugin_ids {
|
for plugin_id in configured_non_curated_plugin_ids {
|
||||||
let plugin_key = plugin_id.as_key();
|
let plugin_key = plugin_id.as_key();
|
||||||
let Some((source, manifest_fallback_contents)) = plugin_sources.get(&plugin_key).cloned()
|
let Some((source, manifest_fallback_contents)) = plugin_sources.get(&plugin_key).cloned()
|
||||||
@@ -515,11 +579,12 @@ fn refresh_non_curated_plugin_cache_with_mode(
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
let refresh_result = (|| -> Result<bool, String> {
|
||||||
let materialized =
|
let materialized =
|
||||||
materialize_marketplace_plugin_source(codex_home, &source).map_err(|err| {
|
materialize_marketplace_plugin_source(codex_home, &source).map_err(|err| {
|
||||||
format!("failed to materialize plugin source for {plugin_key}: {err}")
|
format!("failed to materialize plugin source for {plugin_key}: {err}")
|
||||||
})?;
|
})?;
|
||||||
let source_path = materialized.path.clone();
|
let source_path = materialized.path;
|
||||||
let plugin_version = match manifest_fallback_contents.as_deref() {
|
let plugin_version = match manifest_fallback_contents.as_deref() {
|
||||||
Some(manifest_contents) => plugin_version_for_source_with_fallback_manifest(
|
Some(manifest_contents) => plugin_version_for_source_with_fallback_manifest(
|
||||||
source_path.as_path(),
|
source_path.as_path(),
|
||||||
@@ -530,9 +595,10 @@ fn refresh_non_curated_plugin_cache_with_mode(
|
|||||||
.map_err(|err| format!("failed to read plugin version for {plugin_key}: {err}"))?;
|
.map_err(|err| format!("failed to read plugin version for {plugin_key}: {err}"))?;
|
||||||
|
|
||||||
if mode == NonCuratedCacheRefreshMode::IfVersionChanged
|
if mode == NonCuratedCacheRefreshMode::IfVersionChanged
|
||||||
&& store.active_plugin_version(&plugin_id).as_deref() == Some(plugin_version.as_str())
|
&& store.active_plugin_version(&plugin_id).as_deref()
|
||||||
|
== Some(plugin_version.as_str())
|
||||||
{
|
{
|
||||||
continue;
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
match manifest_fallback_contents.as_deref() {
|
match manifest_fallback_contents.as_deref() {
|
||||||
@@ -545,19 +611,38 @@ fn refresh_non_curated_plugin_cache_with_mode(
|
|||||||
None => store.install_with_version(source_path, plugin_id.clone(), plugin_version),
|
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}"))?;
|
.map_err(|err| format!("failed to refresh plugin cache for {plugin_key}: {err}"))?;
|
||||||
cache_refreshed = true;
|
Ok(true)
|
||||||
|
})();
|
||||||
|
match refresh_result {
|
||||||
|
Ok(refreshed) => cache_refreshed |= refreshed,
|
||||||
|
Err(message) => refresh_errors.push(NonCuratedCacheRefreshError {
|
||||||
|
marketplace_name: plugin_id.marketplace_name,
|
||||||
|
message,
|
||||||
|
}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(cache_refreshed)
|
Ok(NonCuratedCacheRefreshOutcome {
|
||||||
|
cache_refreshed,
|
||||||
|
errors: refresh_errors,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn configured_plugins_from_stack(
|
#[cfg(test)]
|
||||||
config_layer_stack: &ConfigLayerStack,
|
fn collapse_non_curated_cache_refresh(
|
||||||
) -> HashMap<String, PluginConfig> {
|
outcome: Result<NonCuratedCacheRefreshOutcome, String>,
|
||||||
let Some(user_config) = config_layer_stack.effective_user_config() else {
|
) -> Result<bool, String> {
|
||||||
return HashMap::new();
|
let outcome = outcome?;
|
||||||
};
|
if outcome.errors.is_empty() {
|
||||||
configured_plugins_from_user_config_value(&user_config)
|
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 {
|
fn is_full_git_sha(value: &str) -> bool {
|
||||||
@@ -647,20 +732,6 @@ fn curated_plugin_ids_from_config_keys(
|
|||||||
configured_curated_plugin_ids
|
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> {
|
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(
|
curated_plugin_ids_from_config_keys(configured_plugins_from_codex_home(
|
||||||
codex_home,
|
codex_home,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ fn configured_plugins_from_stack_merges_user_layers() {
|
|||||||
)
|
)
|
||||||
.expect("valid config layer stack");
|
.expect("valid config layer stack");
|
||||||
|
|
||||||
let plugins = configured_plugins_from_stack(&stack);
|
let plugins = configured_plugins_from_stack(&stack, temp_dir.path());
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plugins,
|
plugins,
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ use crate::loader::log_plugin_load_errors;
|
|||||||
use crate::loader::materialize_marketplace_plugin_source;
|
use crate::loader::materialize_marketplace_plugin_source;
|
||||||
use crate::loader::plugin_capability_summary_from_root;
|
use crate::loader::plugin_capability_summary_from_root;
|
||||||
use crate::loader::refresh_curated_plugin_cache;
|
use crate::loader::refresh_curated_plugin_cache;
|
||||||
use crate::loader::refresh_non_curated_plugin_cache;
|
use crate::loader::refresh_non_curated_plugin_cache_detailed;
|
||||||
use crate::loader::refresh_non_curated_plugin_cache_force_reinstall;
|
use crate::loader::refresh_non_curated_plugin_cache_force_reinstall_detailed;
|
||||||
use crate::loader::remote_installed_plugins_to_config;
|
use crate::loader::remote_installed_plugins_to_config;
|
||||||
use crate::manifest::PluginManifestInterface;
|
use crate::manifest::PluginManifestInterface;
|
||||||
use crate::manifest::load_plugin_manifest;
|
use crate::manifest::load_plugin_manifest;
|
||||||
@@ -32,9 +32,12 @@ use crate::marketplace::MarketplacePluginSource;
|
|||||||
use crate::marketplace::ResolvedMarketplacePlugin;
|
use crate::marketplace::ResolvedMarketplacePlugin;
|
||||||
use crate::marketplace::find_installable_marketplace_plugin;
|
use crate::marketplace::find_installable_marketplace_plugin;
|
||||||
use crate::marketplace::find_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::plugin_interface_with_marketplace_category;
|
||||||
use crate::marketplace_policy::MarketplacePolicy;
|
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::ConfiguredMarketplaceUpgradeError;
|
||||||
use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome;
|
use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome;
|
||||||
use crate::marketplace_upgrade::upgrade_configured_git_marketplaces;
|
use crate::marketplace_upgrade::upgrade_configured_git_marketplaces;
|
||||||
@@ -84,6 +87,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
|
|||||||
use codex_utils_plugins::PluginSkillRoot;
|
use codex_utils_plugins::PluginSkillRoot;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
@@ -195,6 +199,7 @@ pub struct PluginListBackgroundTaskOptions {
|
|||||||
#[derive(Clone, PartialEq, Eq)]
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
struct NonCuratedCacheRefreshRequest {
|
struct NonCuratedCacheRefreshRequest {
|
||||||
roots: Vec<AbsolutePathBuf>,
|
roots: Vec<AbsolutePathBuf>,
|
||||||
|
configured_plugin_keys: Vec<String>,
|
||||||
mode: NonCuratedCacheRefreshMode,
|
mode: NonCuratedCacheRefreshMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,9 +393,16 @@ struct PluginLoadCacheKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
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),
|
skill_config_rules: skill_config_rules_from_stack(&config.config_layer_stack),
|
||||||
remote_global_catalog_active,
|
remote_global_catalog_active,
|
||||||
}
|
}
|
||||||
@@ -494,8 +506,11 @@ impl PluginsManager {
|
|||||||
if !config.plugins_enabled {
|
if !config.plugins_enabled {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let key =
|
let key = PluginLoadCacheKey::from_config(
|
||||||
PluginLoadCacheKey::from_config(config, self.remote_global_catalog_active(config));
|
config,
|
||||||
|
self.codex_home.as_path(),
|
||||||
|
self.remote_global_catalog_active(config),
|
||||||
|
);
|
||||||
self.loaded_plugins_cache
|
self.loaded_plugins_cache
|
||||||
.read()
|
.read()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
.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 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) {
|
if !force_reload && let Some(plugins) = self.cached_loaded_plugins(&cache_key) {
|
||||||
return self.resolve_loaded_plugins_for_auth(plugins);
|
return self.resolve_loaded_plugins_for_auth(plugins);
|
||||||
}
|
}
|
||||||
@@ -1007,7 +1026,7 @@ impl PluginsManager {
|
|||||||
options: PluginListBackgroundTaskOptions,
|
options: PluginListBackgroundTaskOptions,
|
||||||
on_effective_plugins_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
|
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 {
|
if options.refresh_global_remote_catalog_cache {
|
||||||
self.maybe_start_global_remote_catalog_cache_refresh(config, auth.clone());
|
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 (installed_plugins, enabled_plugins) = self.configured_plugin_states(config);
|
||||||
let marketplace_roots =
|
let marketplace_roots =
|
||||||
self.marketplace_roots(config, additional_roots, include_openai_curated);
|
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 mut seen_plugin_keys = HashSet::new();
|
||||||
let marketplaces = marketplace_outcome
|
let marketplaces = marketplace_outcome
|
||||||
.marketplaces
|
.marketplaces
|
||||||
@@ -1636,11 +1655,12 @@ impl PluginsManager {
|
|||||||
return Ok(MarketplaceListOutcome::default());
|
return Ok(MarketplaceListOutcome::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
list_marketplaces(&self.marketplace_roots(
|
let marketplace_roots = self.marketplace_roots(
|
||||||
config,
|
config,
|
||||||
additional_roots,
|
additional_roots,
|
||||||
/*include_openai_curated*/ true,
|
/*include_openai_curated*/ true,
|
||||||
))
|
);
|
||||||
|
self.list_marketplaces_with_policy(config, &marketplace_roots)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn tool_suggest_metadata_for_marketplace_plugin(
|
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)?;
|
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()) {
|
if !self.restriction_product_matches(plugin.policy.products.as_deref()) {
|
||||||
return Err(MarketplaceError::PluginNotFound {
|
return Err(MarketplaceError::PluginNotFound {
|
||||||
plugin_name: plugin.plugin_id.plugin_name,
|
plugin_name: plugin.plugin_id.plugin_name,
|
||||||
@@ -2023,12 +2054,30 @@ impl PluginsManager {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !outcome.upgraded_roots.is_empty() {
|
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(),
|
self.codex_home.as_path(),
|
||||||
&outcome.upgraded_roots,
|
&outcome.upgraded_roots,
|
||||||
|
&configured_plugin_keys,
|
||||||
) {
|
) {
|
||||||
Ok(cache_refreshed) => {
|
Ok(refresh_outcome) => {
|
||||||
self.clear_caches_after_marketplace_source_refresh(cache_refreshed);
|
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) => {
|
Err(err) => {
|
||||||
self.clear_cache();
|
self.clear_cache();
|
||||||
@@ -2048,9 +2097,11 @@ impl PluginsManager {
|
|||||||
|
|
||||||
pub fn maybe_start_non_curated_plugin_cache_refresh(
|
pub fn maybe_start_non_curated_plugin_cache_refresh(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
|
config: &PluginsConfigInput,
|
||||||
roots: &[AbsolutePathBuf],
|
roots: &[AbsolutePathBuf],
|
||||||
) {
|
) {
|
||||||
self.schedule_non_curated_plugin_cache_refresh(
|
self.schedule_non_curated_plugin_cache_refresh(
|
||||||
|
config,
|
||||||
roots,
|
roots,
|
||||||
NonCuratedCacheRefreshMode::IfVersionChanged,
|
NonCuratedCacheRefreshMode::IfVersionChanged,
|
||||||
);
|
);
|
||||||
@@ -2127,38 +2178,81 @@ impl PluginsManager {
|
|||||||
|
|
||||||
fn schedule_non_curated_plugin_cache_refresh(
|
fn schedule_non_curated_plugin_cache_refresh(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
|
config: &PluginsConfigInput,
|
||||||
roots: &[AbsolutePathBuf],
|
roots: &[AbsolutePathBuf],
|
||||||
mode: NonCuratedCacheRefreshMode,
|
mode: NonCuratedCacheRefreshMode,
|
||||||
) {
|
) {
|
||||||
let mut roots = roots.to_vec();
|
let marketplace_roots =
|
||||||
roots.sort_unstable();
|
self.marketplace_roots(config, roots, /*include_openai_curated*/ false);
|
||||||
roots.dedup();
|
let outcome = match self.list_marketplaces_with_policy(config, &marketplace_roots) {
|
||||||
if roots.is_empty() {
|
Ok(outcome) => outcome,
|
||||||
|
Err(err) => {
|
||||||
|
warn!("failed to prepare non-curated plugin cache refresh: {err}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let request = NonCuratedCacheRefreshRequest { roots, mode };
|
};
|
||||||
|
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();
|
||||||
|
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 mut request = NonCuratedCacheRefreshRequest {
|
||||||
|
roots,
|
||||||
|
configured_plugin_keys,
|
||||||
|
mode,
|
||||||
|
};
|
||||||
|
|
||||||
let should_spawn = {
|
let should_spawn = {
|
||||||
let mut state = match self.non_curated_cache_refresh_state.write() {
|
let mut state = match self.non_curated_cache_refresh_state.write() {
|
||||||
Ok(state) => state,
|
Ok(state) => state,
|
||||||
Err(err) => err.into_inner(),
|
Err(err) => err.into_inner(),
|
||||||
};
|
};
|
||||||
// Collapse repeated plugin/list requests onto one worker and only queue another pass
|
if request.mode == NonCuratedCacheRefreshMode::IfVersionChanged
|
||||||
// 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
|
|
||||||
&& state.requested.as_ref().is_some_and(|requested| {
|
&& state.requested.as_ref().is_some_and(|requested| {
|
||||||
requested.mode == NonCuratedCacheRefreshMode::ForceReinstall
|
requested.mode == NonCuratedCacheRefreshMode::ForceReinstall
|
||||||
&& requested.roots == request.roots
|
&& 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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2349,21 +2443,33 @@ impl PluginsManager {
|
|||||||
|
|
||||||
let refresh_result = match request.mode {
|
let refresh_result = match request.mode {
|
||||||
NonCuratedCacheRefreshMode::IfVersionChanged => {
|
NonCuratedCacheRefreshMode::IfVersionChanged => {
|
||||||
refresh_non_curated_plugin_cache(self.codex_home.as_path(), &request.roots)
|
refresh_non_curated_plugin_cache_detailed(
|
||||||
}
|
|
||||||
NonCuratedCacheRefreshMode::ForceReinstall => {
|
|
||||||
refresh_non_curated_plugin_cache_force_reinstall(
|
|
||||||
self.codex_home.as_path(),
|
self.codex_home.as_path(),
|
||||||
&request.roots,
|
&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 {
|
let refreshed = match refresh_result {
|
||||||
Ok(cache_refreshed) => {
|
Ok(refresh_outcome) => {
|
||||||
if cache_refreshed {
|
if refresh_outcome.cache_refreshed {
|
||||||
self.clear_cache();
|
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) => {
|
Err(err) => {
|
||||||
self.clear_cache();
|
self.clear_cache();
|
||||||
@@ -2391,7 +2497,8 @@ impl PluginsManager {
|
|||||||
&self,
|
&self,
|
||||||
config: &PluginsConfigInput,
|
config: &PluginsConfigInput,
|
||||||
) -> (HashSet<String>, HashSet<String>) {
|
) -> (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
|
let installed_plugins = configured_plugins
|
||||||
.keys()
|
.keys()
|
||||||
.filter(|plugin_key| {
|
.filter(|plugin_key| {
|
||||||
@@ -2448,6 +2555,27 @@ impl PluginsManager {
|
|||||||
roots.dedup();
|
roots.dedup();
|
||||||
roots
|
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(
|
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)]
|
#[cfg(test)]
|
||||||
#[path = "manager_tests.rs"]
|
#[path = "manager_tests.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -100,6 +100,19 @@ fn config_layer_stack_with_requirements(
|
|||||||
.expect("build config layer stack")
|
.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]
|
#[test]
|
||||||
fn plugins_manager_tracks_auth_mode() {
|
fn plugins_manager_tracks_auth_mode() {
|
||||||
let tmp = TempDir::new().unwrap();
|
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));
|
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) {
|
fn write_auth_projection_plugin(codex_home: &Path, name: &str, include_app: bool) {
|
||||||
let plugin_root = codex_home
|
let plugin_root = codex_home
|
||||||
.join("plugins/cache")
|
.join("plugins/cache")
|
||||||
@@ -3348,7 +3539,11 @@ plugins = true
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.marketplaces
|
.marketplaces
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|marketplace| marketplace.name == "debug")
|
.find(|marketplace| {
|
||||||
|
marketplace.path
|
||||||
|
== AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json"))
|
||||||
|
.unwrap()
|
||||||
|
})
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.plugins
|
.plugins
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -3785,7 +3980,7 @@ enabled = true
|
|||||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||||
.list_marketplaces_for_config(
|
.list_marketplaces_for_config(
|
||||||
&config,
|
&config,
|
||||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
|
||||||
/*include_openai_curated*/ true,
|
/*include_openai_curated*/ true,
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -3793,7 +3988,11 @@ enabled = true
|
|||||||
|
|
||||||
let marketplace = marketplaces
|
let marketplace = marketplaces
|
||||||
.into_iter()
|
.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");
|
.expect("debug marketplace should be listed");
|
||||||
|
|
||||||
let mut plugins = marketplace.plugins;
|
let mut plugins = marketplace.plugins;
|
||||||
@@ -5256,6 +5455,7 @@ enabled = true
|
|||||||
refresh_non_curated_plugin_cache(
|
refresh_non_curated_plugin_cache(
|
||||||
tmp.path(),
|
tmp.path(),
|
||||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||||
|
&["sample-plugin@debug".to_string()],
|
||||||
)
|
)
|
||||||
.expect("cache refresh should succeed")
|
.expect("cache refresh should succeed")
|
||||||
);
|
);
|
||||||
@@ -5308,6 +5508,7 @@ enabled = true
|
|||||||
refresh_non_curated_plugin_cache(
|
refresh_non_curated_plugin_cache(
|
||||||
tmp.path(),
|
tmp.path(),
|
||||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||||
|
&["sample-plugin@debug".to_string()],
|
||||||
)
|
)
|
||||||
.expect("cache refresh should reinstall missing configured plugin")
|
.expect("cache refresh should reinstall missing configured plugin")
|
||||||
);
|
);
|
||||||
@@ -5367,6 +5568,7 @@ enabled = true
|
|||||||
refresh_non_curated_plugin_cache(
|
refresh_non_curated_plugin_cache(
|
||||||
tmp.path(),
|
tmp.path(),
|
||||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||||
|
&["sample-plugin@debug".to_string()],
|
||||||
)
|
)
|
||||||
.expect("cache refresh should materialize configured Git plugin")
|
.expect("cache refresh should materialize configured Git plugin")
|
||||||
);
|
);
|
||||||
@@ -5420,6 +5622,7 @@ enabled = true
|
|||||||
!refresh_non_curated_plugin_cache(
|
!refresh_non_curated_plugin_cache(
|
||||||
tmp.path(),
|
tmp.path(),
|
||||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||||
|
&["sample-plugin@debug".to_string()],
|
||||||
)
|
)
|
||||||
.expect("cache refresh should be a no-op when configured plugins are current")
|
.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(
|
refresh_non_curated_plugin_cache_force_reinstall(
|
||||||
tmp.path(),
|
tmp.path(),
|
||||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||||
|
&["sample-plugin@debug".to_string()],
|
||||||
)
|
)
|
||||||
.expect("cache refresh should reinstall unchanged local version")
|
.expect("cache refresh should reinstall unchanged local version")
|
||||||
);
|
);
|
||||||
@@ -5531,6 +5735,7 @@ enabled = true
|
|||||||
refresh_non_curated_plugin_cache(
|
refresh_non_curated_plugin_cache(
|
||||||
tmp.path(),
|
tmp.path(),
|
||||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||||
|
&["sample-plugin@debug".to_string()],
|
||||||
)
|
)
|
||||||
.expect("cache refresh should ignore unrelated invalid plugin manifests")
|
.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]
|
#[tokio::test]
|
||||||
async fn load_plugins_ignores_project_config_files() {
|
async fn load_plugins_ignores_project_config_files() {
|
||||||
let codex_home = TempDir::new().unwrap();
|
let codex_home = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -18,9 +18,13 @@ use codex_config::MarketplaceAllowedSourceToml;
|
|||||||
use codex_config::RequirementSource;
|
use codex_config::RequirementSource;
|
||||||
use codex_config::types::MarketplaceConfig;
|
use codex_config::types::MarketplaceConfig;
|
||||||
use codex_config::types::MarketplaceSourceType;
|
use codex_config::types::MarketplaceSourceType;
|
||||||
|
use codex_config::types::PluginConfig;
|
||||||
|
use codex_plugin::PluginId;
|
||||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
use codex_utils_path::paths_match_after_normalization;
|
use codex_utils_path::paths_match_after_normalization;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use url::Url;
|
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()
|
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(
|
pub(crate) fn validate_marketplace_source_for_add(
|
||||||
codex_home: &Path,
|
codex_home: &Path,
|
||||||
requirements: &ConfigRequirements,
|
requirements: &ConfigRequirements,
|
||||||
@@ -358,6 +460,21 @@ fn managed_marketplace_name(
|
|||||||
managed_local_marketplace_name(codex_home, root.as_path())
|
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> {
|
fn managed_local_marketplace_name(codex_home: &Path, root: &Path) -> Option<&'static str> {
|
||||||
for marketplace_name in [
|
for marketplace_name in [
|
||||||
OPENAI_BUNDLED_MARKETPLACE_NAME,
|
OPENAI_BUNDLED_MARKETPLACE_NAME,
|
||||||
|
|||||||
@@ -383,6 +383,197 @@ restrict_to_allowed_sources = true
|
|||||||
assert!(validate_marketplace_name_for_add(expected_name, "other").is_err());
|
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]
|
#[test]
|
||||||
fn blocked_upgrade_is_rejected_before_marketplace_installation() {
|
fn blocked_upgrade_is_rejected_before_marketplace_installation() {
|
||||||
let codex_home = TempDir::new().expect("create Codex home");
|
let codex_home = TempDir::new().expect("create Codex home");
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ pub struct PluginInstallResult {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PluginStore {
|
pub struct PluginStore {
|
||||||
|
codex_home: AbsolutePathBuf,
|
||||||
root: AbsolutePathBuf,
|
root: AbsolutePathBuf,
|
||||||
data_root: AbsolutePathBuf,
|
data_root: AbsolutePathBuf,
|
||||||
}
|
}
|
||||||
@@ -59,14 +60,24 @@ impl PluginStore {
|
|||||||
let data_root =
|
let data_root =
|
||||||
AbsolutePathBuf::from_absolute_path_checked(codex_home.join(PLUGINS_DATA_DIR))
|
AbsolutePathBuf::from_absolute_path_checked(codex_home.join(PLUGINS_DATA_DIR))
|
||||||
.map_err(|err| PluginStoreError::io("failed to resolve plugin data root", err))?;
|
.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 {
|
pub fn root(&self) -> &AbsolutePathBuf {
|
||||||
&self.root
|
&self.root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn codex_home(&self) -> &AbsolutePathBuf {
|
||||||
|
&self.codex_home
|
||||||
|
}
|
||||||
|
|
||||||
pub fn plugin_base_root(&self, plugin_id: &PluginId) -> AbsolutePathBuf {
|
pub fn plugin_base_root(&self, plugin_id: &PluginId) -> AbsolutePathBuf {
|
||||||
self.root
|
self.root
|
||||||
.join(&plugin_id.marketplace_name)
|
.join(&plugin_id.marketplace_name)
|
||||||
|
|||||||
Reference in new issue
Block a user