Honor per-repository plugin configuration in catalog requests (#41208)

## What changed

- Load the effective plugin configuration for every requested `cwd` in
  `plugin/list` and `plugin/installed`.
- Combine local marketplaces in request order, keeping the first source for
  duplicates while merging installed and enabled state across repositories.
- Report invalid project configurations through `marketplaceLoadErrors` without
  dropping valid local catalogs or globally enabled remote catalogs.
- Use the combined marketplace context for forced and background cache refreshes,
  while keeping remote plugin feature gating request-wide.

## Testing

- Cover distinct and duplicate marketplaces, cross-repository enabled state,
  invalid project configuration, local cache refresh, and global versus
  project-local feature flags.

GitOrigin-RevId: 53c8e0e9c760dc2b969ab10d0e63fb439673c230
This commit is contained in:
willwang-openai
2026-08-27 21:10:10 +00:00
committed by copyberry
parent 19321435b1
commit c4c51c56e4
8 changed files with 802 additions and 208 deletions
+9 -2
View File
@@ -307,8 +307,15 @@ Plugin activation and MCP settings use the existing merged configuration, includ
system settings and trusted project overrides. `skills/list` resolves plugin skills
independently for each requested working directory.
Omitted or empty catalog `cwds` exclude project configuration, including the
app-server process's project.
For local `plugin/list` and `plugin/installed` results, each requested cwd supplies
its effective plugin state and plugin feature flag. When a plugin appears in multiple
contexts, the first source wins and installed/enabled state is merged across contexts.
Invalid project configurations are reported in `marketplaceLoadErrors` without hiding
other projects or remote plugins. Omitted or empty `cwds` exclude project
configuration, including the app-server process's project. `forceRefetch` refreshes the selected local plugin
sources before returning; ordinary listing schedules the same work in the background.
Remote catalog settings and feature gating remain request-wide rather than being
selected from the requested repos. Search continues to report `enabled: false`.
Marketplace definitions can come from system configuration, but configured Git
marketplaces currently require an existing downloaded snapshot.
@@ -10,6 +10,7 @@ use codex_app_server_protocol::PluginShareTargetRole;
use codex_config::types::McpServerConfig;
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core_plugins::PluginListBackgroundTaskOptions;
use codex_core_plugins::PluginMarketplaceContext;
use codex_core_plugins::is_openai_curated_marketplace_name;
use codex_core_plugins::loader::load_configured_plugin_mcp_servers;
use codex_core_plugins::manifest::is_agent_plugin_manifest;
@@ -556,35 +557,42 @@ impl PluginRequestProcessor {
let marketplace_kinds =
marketplace_kinds.unwrap_or_else(|| vec![PluginListMarketplaceKind::Local]);
let include_local = marketplace_kinds.contains(&PluginListMarketplaceKind::Local);
let include_vertical = marketplace_kinds.contains(&PluginListMarketplaceKind::Vertical);
let config = self.load_catalog_config(&roots).await?;
let context = if include_local {
self.load_marketplace_context(roots, &config).await
} else {
PluginMarketplaceContext {
global_config: config.plugins_config_input(),
scopes: Vec::new(),
load_errors: Vec::new(),
}
};
let empty_response = || PluginListResponse {
marketplaces: Vec::new(),
marketplace_load_errors: Vec::new(),
featured_plugin_ids: Vec::new(),
};
if !config.features.enabled(Feature::Plugins) {
if !context.plugins_enabled() && context.load_errors.is_empty() {
return Ok(empty_response());
}
let auth = self.auth_manager.auth().await;
let auth_mode = auth.as_ref().map(CodexAuth::api_auth_mode);
let plugins_input = config.plugins_config_input();
if include_local
&& force_refetch
&& plugins_manager
.refresh_non_curated_plugin_cache_for_config(&plugins_input, &roots)
.refresh_non_curated_plugin_cache_for_context(&context)
.await
{
self.on_effective_plugins_changed().await;
}
let include_shared_with_me =
marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe);
let include_created_by_me_remote = marketplace_kinds
.contains(&PluginListMarketplaceKind::CreatedByMeRemote)
&& config.features.enabled(Feature::RemotePlugin);
let include_global_remote =
!explicit_marketplace_kinds && config.features.enabled(Feature::RemotePlugin);
let include_vertical = context.global_config.plugins_enabled
&& marketplace_kinds.contains(&PluginListMarketplaceKind::Vertical);
let include_shared_with_me = context.global_config.plugins_enabled
&& marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe);
let include_created_by_me_remote = context.remote_plugins_enabled()
&& marketplace_kinds.contains(&PluginListMarketplaceKind::CreatedByMeRemote);
let include_global_remote = context.remote_plugins_enabled() && !explicit_marketplace_kinds;
let use_remote_global_catalog =
include_global_remote && auth_mode.is_some_and(DomainAuthMode::uses_codex_backend);
let remote_plugin_service_config = remote_plugin_service_config(&config);
@@ -594,22 +602,22 @@ impl PluginRequestProcessor {
RemotePluginCatalogCacheMode::PreferCache
};
let mut remote_catalog_cache_refresh_scopes = Default::default();
let (mut data, marketplace_load_errors) = if include_local {
let config_for_marketplace_listing = plugins_input.clone();
let (mut data, marketplace_load_errors, local_marketplaces) = if include_local {
let context_for_marketplace_listing = context.clone();
let plugins_manager_for_marketplace_listing = plugins_manager.clone();
let roots_for_marketplace_listing = roots.clone();
let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(&config)?;
match tokio::task::spawn_blocking(move || {
let outcome = plugins_manager_for_marketplace_listing
.list_marketplaces_for_config(
&config_for_marketplace_listing,
&roots_for_marketplace_listing,
.list_marketplaces_for_context(
&context_for_marketplace_listing,
/*include_openai_curated*/ !use_remote_global_catalog,
)?;
let local_marketplaces = outcome.marketplaces.clone();
Ok::<
(
Vec<PluginMarketplaceEntry>,
Vec<codex_app_server_protocol::MarketplaceLoadErrorInfo>,
Vec<codex_core_plugins::ConfiguredMarketplace>,
),
MarketplaceError,
>((
@@ -644,6 +652,7 @@ impl PluginRequestProcessor {
message: err.message,
})
.collect(),
local_marketplaces,
))
})
.await
@@ -659,7 +668,7 @@ impl PluginRequestProcessor {
}
}
} else {
(Vec::new(), Vec::new())
(Vec::new(), Vec::new(), Vec::new())
};
// TODO(remote plugins): Remove this once remote plugins are ready and vertical plugins are
@@ -699,7 +708,9 @@ impl PluginRequestProcessor {
if include_created_by_me_remote {
remote_sources.push(RemoteMarketplaceSource::CreatedByMeRemote);
}
if marketplace_kinds.contains(&PluginListMarketplaceKind::WorkspaceDirectory) {
if context.global_config.plugins_enabled
&& marketplace_kinds.contains(&PluginListMarketplaceKind::WorkspaceDirectory)
{
remote_sources.push(RemoteMarketplaceSource::WorkspaceDirectory);
}
if include_shared_with_me && config.features.enabled(Feature::PluginSharing) {
@@ -758,11 +769,11 @@ impl PluginRequestProcessor {
|| include_global_remote
|| !remote_catalog_cache_refresh_scopes.is_empty()
{
plugins_manager.maybe_start_plugin_list_background_tasks_for_config(
&plugins_input,
plugins_manager.maybe_start_plugin_list_background_tasks(
&context,
auth.clone(),
&roots,
PluginListBackgroundTaskOptions {
local_marketplaces,
remote_catalog_cache_refresh_scopes,
},
Some(self.effective_plugins_changed_callback()),
@@ -774,7 +785,7 @@ impl PluginRequestProcessor {
|| marketplace.name == REMOTE_GLOBAL_MARKETPLACE_NAME
}) {
match plugins_manager
.featured_plugin_ids_for_config(&plugins_input, auth.as_ref())
.featured_plugin_ids_for_config(&context.global_config, auth.as_ref())
.await
{
Ok(featured_plugin_ids) => featured_plugin_ids,
@@ -817,19 +828,19 @@ impl PluginRequestProcessor {
marketplace_load_errors: Vec::new(),
};
let config = self.load_catalog_config(&roots).await?;
if !config.features.enabled(Feature::Plugins) {
let context = self.load_marketplace_context(roots, &config).await;
if !context.plugins_enabled() && context.load_errors.is_empty() {
return Ok(empty_response());
}
let auth = self.auth_manager.auth().await;
let auth_mode = auth.as_ref().map(CodexAuth::api_auth_mode);
let plugins_input = config.plugins_config_input();
let use_remote_global_catalog = config.features.enabled(Feature::RemotePlugin)
let use_remote_global_catalog = context.remote_plugins_enabled()
&& auth_mode.is_some_and(DomainAuthMode::uses_codex_backend);
let remote_installed_plugin_visible_marketplaces =
remote_installed_plugin_visible_marketplaces(&config, use_remote_global_catalog);
plugins_manager.maybe_start_remote_installed_plugin_bundle_sync(
&plugins_input,
&context.global_config,
auth.clone(),
Some(self.effective_plugins_changed_callback()),
);
@@ -838,21 +849,22 @@ impl PluginRequestProcessor {
.load_local_installed_and_suggested_plugins(
plugins_manager.clone(),
&config,
&plugins_input,
roots,
&context,
install_suggestion_plugin_names,
)
.await?;
data.extend(
self.load_remote_installed_plugins(
plugins_manager,
&plugins_input,
&remote_installed_plugin_visible_marketplaces,
auth.as_ref(),
)
.await,
);
if context.global_config.plugins_enabled {
data.extend(
self.load_remote_installed_plugins(
plugins_manager,
&context.global_config,
&remote_installed_plugin_visible_marketplaces,
auth.as_ref(),
)
.await,
);
}
filter_openai_curated_installed_conflicts(&mut data, use_remote_global_catalog);
Ok(PluginInstalledResponse {
@@ -865,8 +877,7 @@ impl PluginRequestProcessor {
&self,
plugins_manager: Arc<codex_core_plugins::PluginsManager>,
config: &Config,
plugins_input: &codex_core_plugins::PluginsConfigInput,
roots: Vec<AbsolutePathBuf>,
context: &PluginMarketplaceContext,
install_suggestion_plugin_names: HashSet<String>,
) -> Result<
(
@@ -875,14 +886,11 @@ impl PluginRequestProcessor {
),
JSONRPCErrorError,
> {
let config_for_marketplace_listing = plugins_input.clone();
let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(config)?;
let context = context.clone();
match tokio::task::spawn_blocking(move || {
let outcome = plugins_manager.list_marketplaces_for_config(
&config_for_marketplace_listing,
&roots,
/*include_openai_curated*/ true,
)?;
let outcome = plugins_manager
.list_marketplaces_for_context(&context, /*include_openai_curated*/ true)?;
Ok::<
(
Vec<PluginMarketplaceEntry>,
@@ -1,4 +1,11 @@
use super::*;
use codex_core::config::ConfigOverrides;
use codex_core_plugins::PluginMarketplaceContext;
use codex_core_plugins::PluginMarketplaceScope;
use codex_core_plugins::marketplace::MarketplaceListError;
use futures::StreamExt;
const CONFIG_LOAD_CONCURRENCY: usize = 5;
impl PluginRequestProcessor {
pub(super) async fn load_catalog_config(
@@ -14,4 +21,62 @@ impl PluginRequestProcessor {
self.load_latest_config(/*fallback_cwd*/ None).await
}
}
pub(super) async fn load_marketplace_context(
&self,
cwds: Vec<AbsolutePathBuf>,
catalog_config: &Config,
) -> PluginMarketplaceContext {
let global_config = catalog_config.plugins_config_input();
if cwds.is_empty() {
return PluginMarketplaceContext {
global_config: global_config.clone(),
scopes: vec![PluginMarketplaceScope {
cwd: None,
config: global_config,
}],
load_errors: Vec::new(),
};
}
let outcomes = futures::stream::iter(cwds)
.map(|cwd| async move {
match self
.config_manager
.load_with_overrides(
/*request_overrides*/ None,
ConfigOverrides {
cwd: Some(cwd.to_path_buf()),
..Default::default()
},
)
.await
{
Ok(config) => Ok(PluginMarketplaceScope {
cwd: Some(cwd),
config: config.plugins_config_input(),
}),
Err(err) => Err(MarketplaceListError {
path: cwd,
message: format!("failed to reload config: {err}"),
}),
}
})
.buffered(CONFIG_LOAD_CONCURRENCY)
.collect::<Vec<_>>()
.await;
let mut scopes = Vec::new();
let mut load_errors = Vec::new();
for outcome in outcomes {
match outcome {
Ok(scope) => scopes.push(scope),
Err(error) => load_errors.push(error),
}
}
PluginMarketplaceContext {
global_config,
scopes,
load_errors,
}
}
}
+402 -43
View File
@@ -42,6 +42,7 @@ use flate2::Compression;
use flate2::write::GzEncoder;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use test_case::test_case;
use tokio::time::sleep;
use tokio::time::timeout;
use wiremock::Mock;
@@ -1113,6 +1114,219 @@ enabled = true
Ok(())
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum MarketplaceRefreshScenario {
Distinct,
Duplicate,
DuplicateConfiguredInLaterCwd,
}
#[test_case(true, true, MarketplaceRefreshScenario::Distinct; "forced")]
#[test_case(false, true, MarketplaceRefreshScenario::Distinct; "background")]
#[test_case(true, false, MarketplaceRefreshScenario::Distinct; "forced with project-enabled plugins")]
#[test_case(false, false, MarketplaceRefreshScenario::Distinct; "background with project-enabled plugins")]
#[test_case(true, true, MarketplaceRefreshScenario::Duplicate; "forced preserves source precedence")]
#[test_case(false, true, MarketplaceRefreshScenario::Duplicate; "background preserves source precedence")]
#[test_case(true, true, MarketplaceRefreshScenario::DuplicateConfiguredInLaterCwd; "forced merges later repository configuration")]
#[test_case(false, true, MarketplaceRefreshScenario::DuplicateConfiguredInLaterCwd; "background merges later repository configuration")]
#[tokio::test]
async fn plugin_list_refreshes_plugins_from_each_cwd(
force_refetch: bool,
home_plugins_enabled: bool,
scenario: MarketplaceRefreshScenario,
) -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
format!("[features]\nplugins = {home_plugins_enabled}\n"),
)?;
let workspace = TempDir::new()?;
let repos = [
workspace.path().join("z_repo"),
workspace.path().join("a_repo"),
];
let names = if scenario == MarketplaceRefreshScenario::Distinct {
["first", "second"]
} else {
["first", "first"]
};
let versions = ["1.1.0", "2.0.0"];
for ((repo, name), version) in repos.iter().zip(names).zip(versions) {
for directory in [".git", ".codex", ".agents/plugins", "sample/.codex-plugin"] {
std::fs::create_dir_all(repo.join(directory))?;
}
std::fs::write(
repo.join("sample/.codex-plugin/plugin.json"),
serde_json::to_vec(&serde_json::json!({"name": "sample", "version": version}))?,
)?;
std::fs::write(
repo.join(".agents/plugins/marketplace.json"),
serde_json::to_vec(&serde_json::json!({"name": name, "plugins": [{
"name": "sample", "source": {"source": "local", "path": "./sample"}
}]}))?,
)?;
let plugin_config = if scenario == MarketplaceRefreshScenario::DuplicateConfiguredInLaterCwd
&& repo == &repos[0]
{
String::new()
} else {
format!("[plugins.\"sample@{name}\"]\nenabled = true\n")
};
std::fs::write(
repo.join(".codex/config.toml"),
format!("[features]\nplugins = true\n{plugin_config}"),
)?;
set_project_trust_level(codex_home.path(), repo, TrustLevel::Trusted)?;
write_installed_plugin_with_version(&codex_home, name, "sample", "1.0.0")?;
}
let mut server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.await?;
let cwds = repos
.iter()
.map(|repo| AbsolutePathBuf::try_from(repo.as_path()))
.collect::<std::io::Result<Vec<_>>>()?;
let id = server
.send_plugin_list_request(PluginListParams {
cwds: Some(cwds.clone()),
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Local]),
force_refetch,
})
.await?;
let response: PluginListResponse = timeout(DEFAULT_TIMEOUT, server.read_response(id)).await??;
assert_eq!(response.marketplace_load_errors, Vec::new());
let expected_sources = names
.into_iter()
.zip(versions)
.take(if scenario == MarketplaceRefreshScenario::Distinct {
2
} else {
1
})
.collect::<Vec<_>>();
for (name, version) in &expected_sources {
let manifest = codex_home.path().join(format!(
"plugins/cache/{name}/sample/{version}/.codex-plugin/plugin.json"
));
if !force_refetch {
wait_for_path_exists(&manifest).await?;
}
let value: serde_json::Value = serde_json::from_slice(&std::fs::read(manifest)?)?;
assert_eq!(value["version"], *version);
}
let id = server
.send_plugin_installed_request(PluginInstalledParams {
cwds: Some(cwds),
install_suggestion_plugin_names: None,
})
.await?;
let response: PluginInstalledResponse =
timeout(DEFAULT_TIMEOUT, server.read_response(id)).await??;
let mut plugins = response
.marketplaces
.iter()
.filter(|marketplace| ["first", "second"].contains(&marketplace.name.as_str()))
.flat_map(|marketplace| &marketplace.plugins)
.map(|plugin| (plugin.id.clone(), plugin.enabled))
.collect::<Vec<_>>();
plugins.sort_unstable();
let expected_plugins = expected_sources
.iter()
.map(|(name, _)| (format!("sample@{name}"), true))
.collect::<Vec<_>>();
assert_eq!(plugins, expected_plugins);
Ok(())
}
#[tokio::test]
async fn plugin_catalogs_skip_invalid_project_config_and_report_cwd_error() -> Result<()> {
let codex_home = TempDir::new()?;
write_plugins_enabled_config(codex_home.path())?;
let workspace = TempDir::new()?;
let invalid_repo = workspace.path().join("invalid");
let valid_repo = workspace.path().join("valid");
for repo in [&invalid_repo, &valid_repo] {
for directory in [".git", ".codex", ".agents/plugins"] {
std::fs::create_dir_all(repo.join(directory))?;
}
set_project_trust_level(codex_home.path(), repo, TrustLevel::Trusted)?;
}
std::fs::write(invalid_repo.join(".codex/config.toml"), "invalid = [\n")?;
std::fs::write(
valid_repo.join(".codex/config.toml"),
"[plugins.\"sample@valid-marketplace\"]\nenabled = true\n",
)?;
std::fs::write(
valid_repo.join(".agents/plugins/marketplace.json"),
r#"{"name":"valid-marketplace","plugins":[{"name":"sample","source":{"source":"local","path":"./sample"}}]}"#,
)?;
write_installed_plugin(&codex_home, "valid-marketplace", "sample")?;
let mut server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.await?;
let invalid_cwd = AbsolutePathBuf::try_from(invalid_repo.as_path())?;
let cwds = vec![
invalid_cwd.clone(),
AbsolutePathBuf::try_from(valid_repo.as_path())?,
];
let request_id = server
.send_plugin_list_request(PluginListParams {
cwds: Some(cwds.clone()),
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Local]),
force_refetch: false,
})
.await?;
let response: PluginListResponse =
timeout(DEFAULT_TIMEOUT, server.read_response(request_id)).await??;
assert_eq!(
response
.marketplaces
.iter()
.flat_map(|marketplace| &marketplace.plugins)
.map(|plugin| (plugin.id.as_str(), plugin.installed, plugin.enabled))
.collect::<Vec<_>>(),
vec![("sample@valid-marketplace", true, true)]
);
assert_eq!(response.marketplace_load_errors.len(), 1);
assert_eq!(
response.marketplace_load_errors[0].marketplace_path,
invalid_cwd
);
assert!(
response.marketplace_load_errors[0]
.message
.contains("failed to reload config")
);
let request_id = server
.send_plugin_installed_request(PluginInstalledParams {
cwds: Some(cwds),
install_suggestion_plugin_names: None,
})
.await?;
let response: PluginInstalledResponse =
timeout(DEFAULT_TIMEOUT, server.read_response(request_id)).await??;
assert_eq!(
response
.marketplaces
.iter()
.flat_map(|marketplace| &marketplace.plugins)
.map(|plugin| (plugin.id.as_str(), plugin.enabled))
.collect::<Vec<_>>(),
vec![("sample@valid-marketplace", true)]
);
assert_eq!(response.marketplace_load_errors.len(), 1);
assert_eq!(
response.marketplace_load_errors[0].marketplace_path,
invalid_cwd
);
Ok(())
}
#[tokio::test]
async fn plugin_list_includes_install_and_enabled_state_from_config() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -1248,25 +1462,10 @@ enabled = false
}
#[tokio::test]
async fn plugin_list_uses_home_config_for_enabled_state() -> Result<()> {
async fn plugin_list_deduplicates_sources_and_merges_enabled_state() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::create_dir_all(codex_home.path().join(".agents/plugins"))?;
write_installed_plugin(&codex_home, "codex-curated", "shared-plugin")?;
std::fs::write(
codex_home.path().join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "shared-plugin",
"source": {
"source": "local",
"path": "./shared-plugin"
}
}
]
}"#,
)?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"[features]
@@ -1311,6 +1510,16 @@ enabled = false
)?;
let workspace_default = TempDir::new()?;
std::fs::create_dir_all(workspace_default.path().join(".git"))?;
std::fs::create_dir_all(workspace_default.path().join(".agents/plugins"))?;
std::fs::copy(
workspace_enabled
.path()
.join(".agents/plugins/marketplace.json"),
workspace_default
.path()
.join(".agents/plugins/marketplace.json"),
)?;
let home = codex_home.path().to_string_lossy().into_owned();
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
@@ -1335,15 +1544,28 @@ enabled = false
let response: PluginListResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
let shared_plugin = response
let marketplaces = response
.marketplaces
.iter()
.flat_map(|marketplace| marketplace.plugins.iter())
.find(|plugin| plugin.name == "shared-plugin")
.expect("expected shared-plugin entry");
assert_eq!(shared_plugin.id, "shared-plugin@codex-curated");
assert_eq!(shared_plugin.installed, true);
assert_eq!(shared_plugin.enabled, true);
.filter(|marketplace| marketplace.name == "codex-curated")
.map(|marketplace| {
(
marketplace.name.as_str(),
marketplace
.plugins
.iter()
.map(|plugin| (plugin.id.as_str(), plugin.installed, plugin.enabled))
.collect::<Vec<_>>(),
)
})
.collect::<Vec<_>>();
assert_eq!(
marketplaces,
vec![(
"codex-curated",
vec![("shared-plugin@codex-curated", true, true)]
)]
);
Ok(())
}
@@ -1546,13 +1768,17 @@ async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> {
Ok(())
}
#[test_case(false; "configured globally")]
#[test_case(true; "configured in later repository")]
#[tokio::test]
async fn plugin_list_returns_installed_git_source_interface_from_cache() -> Result<()> {
async fn plugin_list_returns_installed_git_source_interface_from_cache(
configured_in_later_cwd: bool,
) -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let missing_remote_repo = repo_root.path().join("missing-remote-plugin-repo");
let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo)
.unwrap()
.expect("temporary repository path should produce a file URL")
.to_string();
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
@@ -1591,15 +1817,32 @@ async fn plugin_list_returns_installed_git_source_interface_from_cache() -> Resu
}
}"##,
)?;
let plugin_config = r#"[plugins."toolkit@debug"]
enabled = true
"#;
let user_plugin_config = if configured_in_later_cwd {
""
} else {
plugin_config
};
std::fs::write(
codex_home.path().join("config.toml"),
r#"[features]
plugins = true
[plugins."toolkit@debug"]
enabled = true
"#,
format!("[features]\nplugins = true\n\n{user_plugin_config}"),
)?;
let later_repo = TempDir::new()?;
let mut cwds = vec![AbsolutePathBuf::try_from(repo_root.path())?];
if configured_in_later_cwd {
for directory in [".git", ".codex", ".agents/plugins"] {
std::fs::create_dir_all(later_repo.path().join(directory))?;
}
std::fs::copy(
repo_root.path().join(".agents/plugins/marketplace.json"),
later_repo.path().join(".agents/plugins/marketplace.json"),
)?;
std::fs::write(later_repo.path().join(".codex/config.toml"), plugin_config)?;
set_project_trust_level(codex_home.path(), later_repo.path(), TrustLevel::Trusted)?;
cwds.push(AbsolutePathBuf::try_from(later_repo.path())?);
}
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
@@ -1608,7 +1851,7 @@ enabled = true
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
cwds: Some(cwds),
marketplace_kinds: None,
force_refetch: false,
})
@@ -1833,8 +2076,20 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles()
Ok(())
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ProjectPluginConfiguration {
None,
Disabled,
Invalid,
}
#[test_case(ProjectPluginConfiguration::None; "without project override")]
#[test_case(ProjectPluginConfiguration::Disabled; "project disables local plugins")]
#[test_case(ProjectPluginConfiguration::Invalid; "invalid project preserves remote plugins")]
#[tokio::test]
async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -> Result<()> {
async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled(
project_configuration: ProjectPluginConfiguration,
) -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_catalog_config(
@@ -1851,6 +2106,27 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
)?;
write_installed_plugin_with_version(&codex_home, "openai-curated-remote", "linear", "1.2.3")?;
let repo = TempDir::new()?;
let cwds = if project_configuration != ProjectPluginConfiguration::None {
for directory in [".git", ".codex", ".agents/plugins"] {
std::fs::create_dir_all(repo.path().join(directory))?;
}
let config = if project_configuration == ProjectPluginConfiguration::Invalid {
"invalid = [\n"
} else {
"[features]\nplugins = false\n[plugins.\"sample@disabled-local\"]\nenabled = true\n"
};
std::fs::write(repo.path().join(".codex/config.toml"), config)?;
std::fs::write(
repo.path().join(".agents/plugins/marketplace.json"),
r#"{"name":"disabled-local","plugins":[{"name":"sample","source":{"source":"local","path":"./sample"}}]}"#,
)?;
set_project_trust_level(codex_home.path(), repo.path(), TrustLevel::Trusted)?;
Some(vec![AbsolutePathBuf::try_from(repo.path())?])
} else {
None
};
let global_directory_body = r#"{
"plugins": [
{
@@ -1981,7 +2257,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: None,
cwds,
marketplace_kinds: None,
force_refetch: false,
})
@@ -1990,6 +2266,21 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
let response: PluginListResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
if project_configuration == ProjectPluginConfiguration::Invalid {
assert_eq!(response.marketplace_load_errors.len(), 1);
assert_eq!(
response.marketplace_load_errors[0].marketplace_path,
AbsolutePathBuf::try_from(repo.path())?
);
} else {
assert!(response.marketplace_load_errors.is_empty());
}
assert!(
!response
.marketplaces
.iter()
.any(|marketplace| marketplace.name == "disabled-local")
);
let remote_marketplace = response
.marketplaces
.into_iter()
@@ -4443,8 +4734,12 @@ async fn assert_disabled_remote_plugin_metadata(
Ok(())
}
#[test_case(false; "no project override")]
#[test_case(true; "project enables local plugins only")]
#[tokio::test]
async fn plugin_list_does_not_fetch_remote_marketplaces_when_plugins_disabled() -> Result<()> {
async fn plugin_list_does_not_fetch_remote_marketplaces_when_plugins_disabled(
project_enables_plugins: bool,
) -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
std::fs::write(
@@ -4469,23 +4764,87 @@ remote_plugin = true
AuthCredentialsStoreMode::File,
)?;
let repo = TempDir::new()?;
for directory in [".git", ".codex", ".agents/plugins"] {
std::fs::create_dir_all(repo.path().join(directory))?;
}
std::fs::write(
repo.path().join(".codex/config.toml"),
"[features]\nplugins = true\n[plugins.\"sample@local\"]\nenabled = true\n",
)?;
std::fs::write(
repo.path().join(".agents/plugins/marketplace.json"),
r#"{"name":"local","plugins":[{"name":"sample","source":{"source":"local","path":"./sample"}}]}"#,
)?;
write_installed_plugin(&codex_home, "local", "sample")?;
set_project_trust_level(codex_home.path(), repo.path(), TrustLevel::Trusted)?;
let repo_cwd = AbsolutePathBuf::try_from(repo.path())?;
let cwds = project_enables_plugins.then(|| vec![repo_cwd]);
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.await?;
for marketplace_kinds in [
None,
Some(vec![
PluginListMarketplaceKind::Local,
PluginListMarketplaceKind::WorkspaceDirectory,
PluginListMarketplaceKind::CreatedByMeRemote,
PluginListMarketplaceKind::SharedWithMe,
PluginListMarketplaceKind::Vertical,
]),
] {
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: cwds.clone(),
marketplace_kinds,
force_refetch: false,
})
.await?;
let response: PluginListResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
let ids = response
.marketplaces
.iter()
.flat_map(|marketplace| &marketplace.plugins)
.map(|plugin| plugin.id.as_str())
.collect::<Vec<_>>();
assert_eq!(
ids,
if project_enables_plugins {
vec!["sample@local"]
} else {
vec![]
}
);
assert_eq!(response.featured_plugin_ids, Vec::<String>::new());
}
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
force_refetch: false,
.send_plugin_installed_request(PluginInstalledParams {
cwds,
install_suggestion_plugin_names: None,
})
.await?;
let response: PluginListResponse =
let response: PluginInstalledResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
assert!(response.marketplaces.is_empty());
let ids = response
.marketplaces
.iter()
.flat_map(|marketplace| &marketplace.plugins)
.map(|plugin| plugin.id.as_str())
.collect::<Vec<_>>();
assert_eq!(
ids,
if project_enables_plugins {
vec!["sample@local"]
} else {
vec![]
}
);
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
Ok(())
}
+2
View File
@@ -74,6 +74,8 @@ pub use manager::PluginInstallError;
pub use manager::PluginInstallOutcome;
pub use manager::PluginInstallRequest;
pub use manager::PluginListBackgroundTaskOptions;
pub use manager::PluginMarketplaceContext;
pub use manager::PluginMarketplaceScope;
pub use manager::PluginReadOutcome;
pub use manager::PluginReadRequest;
pub use manager::PluginUninstallError;
+76 -108
View File
@@ -1,3 +1,8 @@
#[path = "marketplace_context.rs"]
mod marketplace_context;
pub use marketplace_context::PluginMarketplaceContext;
pub use marketplace_context::PluginMarketplaceScope;
use super::LoadedPlugin;
use super::PluginLoadOutcome;
use crate::PluginGitMode;
@@ -321,6 +326,7 @@ struct RemoteCatalogCacheRefreshState {
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PluginListBackgroundTaskOptions {
pub local_marketplaces: Vec<ConfiguredMarketplace>,
pub remote_catalog_cache_refresh_scopes: BTreeSet<RemotePluginScope>,
}
@@ -475,6 +481,12 @@ pub struct ConfiguredMarketplaceListOutcome {
pub errors: Vec<MarketplaceListError>,
}
#[derive(Default)]
struct ConfiguredPluginStates {
installed: HashSet<String>,
enabled: HashSet<String>,
}
impl From<PluginDetail> for PluginCapabilitySummary {
fn from(value: PluginDetail) -> Self {
let has_skills = value.skills.iter().any(|skill| {
@@ -1588,28 +1600,27 @@ impl PluginsManager {
});
}
pub fn maybe_start_plugin_list_background_tasks_for_config(
pub fn maybe_start_plugin_list_background_tasks(
self: &Arc<Self>,
config: &PluginsConfigInput,
context: &PluginMarketplaceContext,
auth: Option<CodexAuth>,
roots: &[AbsolutePathBuf],
options: PluginListBackgroundTaskOptions,
on_effective_plugins_changed: Option<EffectivePluginsChangedCallback>,
) {
self.maybe_start_non_curated_plugin_cache_refresh(config, roots);
self.maybe_start_non_curated_plugin_cache_refresh(context, &options.local_marketplaces);
self.maybe_start_remote_catalog_cache_refresh(
config,
&context.global_config,
auth.clone(),
options.remote_catalog_cache_refresh_scopes,
RemoteCatalogCacheRefreshMode::OnlyIfStale,
);
self.maybe_start_remote_plugin_caches_refresh(
config,
&context.global_config,
auth.clone(),
on_effective_plugins_changed.clone(),
);
self.maybe_start_remote_installed_plugin_bundle_sync(
config,
&context.global_config,
auth,
on_effective_plugins_changed,
);
@@ -2161,6 +2172,14 @@ impl PluginsManager {
Ok(())
}
pub fn list_marketplaces_for_context(
&self,
context: &PluginMarketplaceContext,
include_openai_curated: bool,
) -> Result<ConfiguredMarketplaceListOutcome, MarketplaceError> {
context.list_marketplaces(self, include_openai_curated)
}
pub fn list_marketplaces_for_config(
&self,
config: &PluginsConfigInput,
@@ -2171,7 +2190,22 @@ impl PluginsManager {
return Ok(ConfiguredMarketplaceListOutcome::default());
}
let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config);
let plugin_states = self.configured_plugin_states(config);
self.list_marketplaces_for_config_with_states(
config,
additional_roots,
include_openai_curated,
&plugin_states,
)
}
fn list_marketplaces_for_config_with_states(
&self,
config: &PluginsConfigInput,
additional_roots: &[AbsolutePathBuf],
include_openai_curated: bool,
plugin_states: &ConfiguredPluginStates,
) -> Result<ConfiguredMarketplaceListOutcome, MarketplaceError> {
let marketplace_roots =
self.marketplace_roots(config, additional_roots, include_openai_curated);
let marketplace_outcome = self.list_marketplaces_with_policy(config, &marketplace_roots)?;
@@ -2194,13 +2228,13 @@ impl PluginsManager {
}
let plugin_id =
PluginId::new(plugin.name.clone(), marketplace_name.clone()).ok();
let installed = installed_plugins.contains(&plugin_key);
let installed = plugin_states.installed.contains(&plugin_key);
let installed_version = installed.then_some(()).and_then(|_| {
plugin_id
.as_ref()
.and_then(|plugin_id| self.store.active_plugin_version(plugin_id))
});
let enabled = enabled_plugins.contains(&plugin_key);
let enabled = plugin_states.enabled.contains(&plugin_key);
let mut interface = plugin.interface;
let mut local_version = plugin.local_version;
let manifest_fallback = plugin.manifest_fallback.clone();
@@ -2323,8 +2357,8 @@ impl PluginsManager {
.manifest_fallback
.contents_if_has_metadata()
.map(|_| plugin.manifest_fallback.clone());
let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config);
let installed = installed_plugins.contains(&plugin_key);
let plugin_states = self.configured_plugin_states(config);
let installed = plugin_states.installed.contains(&plugin_key);
let installed_version = if installed {
self.store.active_plugin_version(&plugin.plugin_id)
} else {
@@ -2352,7 +2386,7 @@ impl PluginsManager {
.unwrap_or_default(),
manifest_fallback,
installed,
enabled: enabled_plugins.contains(&plugin_key),
enabled: plugin_states.enabled.contains(&plugin_key),
},
)
.await?;
@@ -2755,34 +2789,45 @@ impl PluginsManager {
pub fn maybe_start_non_curated_plugin_cache_refresh(
self: &Arc<Self>,
config: &PluginsConfigInput,
roots: &[AbsolutePathBuf],
context: &PluginMarketplaceContext,
marketplaces: &[ConfiguredMarketplace],
) {
self.schedule_non_curated_plugin_cache_refresh(
config,
roots,
if let Some(request) = context.non_curated_cache_refresh_request(
self,
marketplaces,
NonCuratedCacheRefreshMode::IfVersionChanged,
PluginGitMode::Automatic,
);
) {
self.schedule_non_curated_plugin_cache_refresh(request);
}
}
/// Runs an explicitly requested refresh using the caller's normal Git configuration.
pub async fn refresh_non_curated_plugin_cache_for_config(
pub async fn refresh_non_curated_plugin_cache_for_context(
self: &Arc<Self>,
config: &PluginsConfigInput,
roots: &[AbsolutePathBuf],
context: &PluginMarketplaceContext,
) -> bool {
let Ok(_refresh_permit) = self.non_curated_cache_refresh_lock.acquire().await else {
return false;
};
let mut completion = self.non_curated_cache_refresh_completion.subscribe();
let changed_sequence = completion.borrow_and_update().changed_sequence;
self.schedule_non_curated_plugin_cache_refresh(
config,
roots,
let marketplaces =
match self.list_marketplaces_for_context(context, /*include_openai_curated*/ false) {
Ok(outcome) => outcome.marketplaces,
Err(err) => {
warn!("failed to prepare non-curated plugin cache refresh: {err}");
Vec::new()
}
};
if let Some(request) = context.non_curated_cache_refresh_request(
self,
&marketplaces,
NonCuratedCacheRefreshMode::IfVersionChanged,
PluginGitMode::Manual,
);
) {
self.schedule_non_curated_plugin_cache_refresh(request);
}
loop {
let in_flight = match self.non_curated_cache_refresh_state.read() {
@@ -2925,82 +2970,8 @@ impl PluginsManager {
fn schedule_non_curated_plugin_cache_refresh(
self: &Arc<Self>,
config: &PluginsConfigInput,
roots: &[AbsolutePathBuf],
mode: NonCuratedCacheRefreshMode,
git_mode: PluginGitMode,
mut request: NonCuratedCacheRefreshRequest,
) {
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 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();
let mut configured_plugin_sources = Vec::new();
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(()) => {
for plugin in marketplace.plugins {
let plugin_key = format!("{}@{}", plugin.name, marketplace.name);
if configured_plugin_keys.binary_search(&plugin_key).is_ok() {
configured_plugin_sources.push(NonCuratedPluginSource {
marketplace_path: marketplace.path.clone(),
plugin_key,
source: plugin.source,
local_version: plugin.local_version,
});
}
}
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() || configured_plugin_keys.is_empty() {
return;
}
configured_plugin_sources.sort_by(|left, right| {
left.marketplace_path
.cmp(&right.marketplace_path)
.then_with(|| left.plugin_key.cmp(&right.plugin_key))
});
let mut request = NonCuratedCacheRefreshRequest {
roots,
configured_plugin_keys,
configured_plugin_sources,
mode,
git_mode,
};
let should_spawn = {
let mut state = match self.non_curated_cache_refresh_state.write() {
Ok(state) => state,
@@ -3361,13 +3332,10 @@ impl PluginsManager {
}
}
fn configured_plugin_states(
&self,
config: &PluginsConfigInput,
) -> (HashSet<String>, HashSet<String>) {
fn configured_plugin_states(&self, config: &PluginsConfigInput) -> ConfiguredPluginStates {
let configured_plugins =
configured_plugins_from_stack(&config.config_layer_stack, self.codex_home.as_path());
let installed_plugins = configured_plugins
let installed = configured_plugins
.keys()
.filter(|plugin_key| {
PluginId::parse(plugin_key)
@@ -3376,11 +3344,11 @@ impl PluginsManager {
})
.cloned()
.collect::<HashSet<_>>();
let enabled_plugins = configured_plugins
let enabled = configured_plugins
.into_iter()
.filter_map(|(plugin_key, plugin)| plugin.enabled.then_some(plugin_key))
.collect::<HashSet<_>>();
(installed_plugins, enabled_plugins)
ConfiguredPluginStates { installed, enabled }
}
fn marketplace_roots(
+27 -9
View File
@@ -6364,25 +6364,39 @@ enabled = true
"#,
);
let config = load_plugins_config_input(codex_home.path(), &marketplace_root).await;
let roots = [AbsolutePathBuf::try_from(marketplace_root.clone()).unwrap()];
let context = PluginMarketplaceContext {
global_config: config.clone(),
scopes: vec![PluginMarketplaceScope {
cwd: Some(AbsolutePathBuf::try_from(marketplace_root.clone()).unwrap()),
config,
}],
load_errors: Vec::new(),
};
let manager = Arc::new(test_plugins_manager(codex_home.path().to_path_buf()));
manager
.non_curated_cache_refresh_state
.write()
.expect("refresh state lock")
.in_flight = true;
let marketplaces = manager
.list_marketplaces_for_context(&context, /*include_openai_curated*/ false)
.unwrap()
.marketplaces;
for git_mode in [
PluginGitMode::Automatic,
PluginGitMode::Manual,
PluginGitMode::Automatic,
] {
manager.schedule_non_curated_plugin_cache_refresh(
&config,
&roots,
NonCuratedCacheRefreshMode::IfVersionChanged,
git_mode,
);
let request = context
.non_curated_cache_refresh_request(
&manager,
&marketplaces,
NonCuratedCacheRefreshMode::IfVersionChanged,
git_mode,
)
.expect("refresh request");
manager.schedule_non_curated_plugin_cache_refresh(request);
}
{
@@ -6398,7 +6412,7 @@ enabled = true
state.in_flight = false;
}
manager.maybe_start_non_curated_plugin_cache_refresh(&config, &roots);
manager.maybe_start_non_curated_plugin_cache_refresh(&context, &marketplaces);
{
let mut state = manager
@@ -6417,7 +6431,11 @@ enabled = true
"sample-plugin",
Some("2.0.0"),
);
manager.maybe_start_non_curated_plugin_cache_refresh(&config, &roots);
let marketplaces = manager
.list_marketplaces_for_context(&context, /*include_openai_curated*/ false)
.unwrap()
.marketplaces;
manager.maybe_start_non_curated_plugin_cache_refresh(&context, &marketplaces);
let state = manager
.non_curated_cache_refresh_state
@@ -0,0 +1,167 @@
//! Aggregates request-wide plugin settings and repository-scoped marketplaces.
//!
//! Marketplace paths and plugin sources use first-scope precedence, while plugin state is
//! merged across scopes. Cache refresh reuses listed marketplaces and configured plugin identities.
use super::*;
/// Request-wide plugin settings and the ordered repository configurations for one marketplace request.
#[derive(Clone)]
pub struct PluginMarketplaceContext {
pub global_config: PluginsConfigInput,
pub scopes: Vec<PluginMarketplaceScope>,
pub load_errors: Vec<MarketplaceListError>,
}
/// A repository's effective configuration, or non-project configuration when no cwd was supplied.
#[derive(Clone)]
pub struct PluginMarketplaceScope {
pub cwd: Option<AbsolutePathBuf>,
pub config: PluginsConfigInput,
}
impl PluginMarketplaceContext {
pub fn plugins_enabled(&self) -> bool {
self.global_config.plugins_enabled
|| self.scopes.iter().any(|scope| scope.config.plugins_enabled)
}
pub fn remote_plugins_enabled(&self) -> bool {
self.global_config.plugins_enabled && self.global_config.remote_plugin_enabled
}
pub(super) fn list_marketplaces(
&self,
manager: &PluginsManager,
include_openai_curated: bool,
) -> Result<ConfiguredMarketplaceListOutcome, MarketplaceError> {
let mut plugin_states = ConfiguredPluginStates::default();
for scope in self
.scopes
.iter()
.filter(|scope| scope.config.plugins_enabled)
{
let state = manager.configured_plugin_states(&scope.config);
plugin_states.installed.extend(state.installed);
plugin_states.enabled.extend(state.enabled);
}
let mut combined = ConfiguredMarketplaceListOutcome {
errors: self.load_errors.clone(),
..Default::default()
};
let mut seen_marketplace_paths = HashSet::new();
let mut seen_plugin_ids = HashSet::new();
let mut seen_error_paths = combined
.errors
.iter()
.map(|error| error.path.clone())
.collect::<HashSet<_>>();
for scope in self
.scopes
.iter()
.filter(|scope| scope.config.plugins_enabled)
{
let outcome = manager.list_marketplaces_for_config_with_states(
&scope.config,
scope.cwd.as_slice(),
include_openai_curated,
&plugin_states,
)?;
for mut marketplace in outcome.marketplaces {
if !seen_marketplace_paths.insert(marketplace.path.clone()) {
continue;
}
marketplace
.plugins
.retain(|plugin| seen_plugin_ids.insert(plugin.id.clone()));
if !marketplace.plugins.is_empty() {
combined.marketplaces.push(marketplace);
}
}
combined.errors.extend(
outcome
.errors
.into_iter()
.filter(|error| seen_error_paths.insert(error.path.clone())),
);
}
Ok(combined)
}
pub(super) fn non_curated_cache_refresh_request(
&self,
manager: &PluginsManager,
marketplaces: &[ConfiguredMarketplace],
mode: NonCuratedCacheRefreshMode,
git_mode: PluginGitMode,
) -> Option<NonCuratedCacheRefreshRequest> {
let configured_plugin_key_set = self
.scopes
.iter()
.filter(|scope| scope.config.plugins_enabled)
.flat_map(|scope| {
configured_plugins_from_stack(
&scope.config.config_layer_stack,
manager.codex_home.as_path(),
)
.into_keys()
})
.collect::<HashSet<_>>();
let mut roots = Vec::new();
let mut configured_plugin_keys = Vec::new();
let mut configured_plugin_sources = Vec::new();
for marketplace in marketplaces {
if is_openai_curated_marketplace_name(&marketplace.name) {
continue;
}
for plugin in &marketplace.plugins {
if !configured_plugin_key_set.contains(&plugin.id) {
continue;
}
let local_version = if plugin.source.is_install_materialized() {
plugin
.manifest_fallback
.as_ref()
.and_then(MarketplacePluginManifestFallback::parse_for_listing)
.and_then(|manifest| manifest.version)
} else {
plugin.local_version.clone()
};
configured_plugin_keys.push(plugin.id.clone());
configured_plugin_sources.push(NonCuratedPluginSource {
marketplace_path: marketplace.path.clone(),
plugin_key: plugin.id.clone(),
source: plugin.source.clone(),
local_version,
});
}
roots.push(marketplace.path.clone());
}
if roots.is_empty() || configured_plugin_keys.is_empty() {
return None;
}
// Refresh rediscovers sources in root order; retain listing's precedence.
configured_plugin_keys.sort_unstable();
configured_plugin_sources.sort_by(|left, right| {
left.marketplace_path
.cmp(&right.marketplace_path)
.then_with(|| left.plugin_key.cmp(&right.plugin_key))
});
Some(NonCuratedCacheRefreshRequest {
roots,
configured_plugin_keys,
configured_plugin_sources,
mode,
git_mode,
})
}
}