mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
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:
committed by
copyberry
parent
19321435b1
commit
c4c51c56e4
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user