Files
codex/codex-rs/cli/src/plugin_cmd.rs
T
xl-openai 7011044c1c [codex] Skip local curated discovery for remote plugins (#27311)
## Summary

- skip the local `openai-curated` marketplace before marketplace loading
when tool-suggest discovery uses remote plugins
- preserve existing marketplace listing behavior for all other callers
and when remote plugins are disabled
- add regression coverage proving the curated marketplace is excluded
before its malformed manifest can be read

## Why

Tool-suggest discovery previously loaded every local `openai-curated`
plugin manifest and only discarded that marketplace afterward when
remote plugins were enabled. The remote catalog is used in that mode, so
the local scan consumed CPU without contributing discoverable plugins.

## Impact

Remote-plugin tool suggestion discovery no longer reads the local
curated marketplace and its plugin manifests. `openai-bundled`,
configured marketplaces, normal `plugin/list` behavior, and local
curated discovery when remote plugins are disabled are unchanged.

## Validation

- `just test -p codex-core-plugins
list_marketplaces_can_skip_openai_curated_before_loading`
- `just test -p codex-core
list_tool_suggest_discoverable_plugins_omits_openai_curated_when_remote_enabled`
- `just fmt`
- `git diff --check`
2026-06-10 13:11:09 -07:00

804 lines
26 KiB
Rust

use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use clap::Parser;
use codex_core::config::Config;
use codex_core::config::find_codex_home;
use codex_core_plugins::ConfiguredMarketplace;
use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME;
use codex_core_plugins::PluginInstallOutcome;
use codex_core_plugins::PluginInstallRequest;
use codex_core_plugins::PluginsConfigInput;
use codex_core_plugins::PluginsManager;
use codex_core_plugins::installed_marketplaces::marketplace_install_root;
use codex_core_plugins::installed_marketplaces::resolve_configured_marketplace_root;
use codex_core_plugins::marketplace::MarketplaceListError;
use codex_core_plugins::marketplace::MarketplacePluginAuthPolicy;
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
use codex_core_plugins::marketplace::MarketplacePluginSource;
use codex_core_plugins::marketplace::find_marketplace_manifest_path;
use codex_plugin::PluginId;
use codex_plugin::validate_plugin_segment;
use codex_utils_cli::CliConfigOverrides;
use serde::Serialize;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use crate::marketplace_cmd::MarketplaceCli;
const OPENAI_BUNDLED_ALPHA_MARKETPLACE_NAME: &str = "openai-bundled-alpha";
const OPENAI_PRIMARY_RUNTIME_MARKETPLACE_NAME: &str = "openai-primary-runtime";
#[derive(Debug, Parser)]
#[command(bin_name = "codex plugin")]
pub struct PluginCli {
#[clap(flatten)]
pub config_overrides: CliConfigOverrides,
#[command(subcommand)]
pub subcommand: PluginSubcommand,
}
#[derive(Debug, clap::Subcommand)]
pub enum PluginSubcommand {
/// Install a plugin from a configured marketplace snapshot.
///
/// Pass either `PLUGIN@MARKETPLACE` or pass `PLUGIN` with
/// `--marketplace MARKETPLACE`.
Add(AddPluginArgs),
/// List plugins available from configured marketplace snapshots.
List(ListPluginsArgs),
/// Add, list, upgrade, or remove configured plugin marketplaces.
Marketplace(MarketplaceCli),
/// Remove an installed plugin from local config and cache.
///
/// Pass either `PLUGIN@MARKETPLACE` or pass `PLUGIN` with
/// `--marketplace MARKETPLACE`.
Remove(RemovePluginArgs),
}
#[derive(Debug, Parser)]
#[command(
bin_name = "codex plugin add",
after_help = "Examples:\n codex plugin add sample@debug\n codex plugin add sample --marketplace debug"
)]
pub struct AddPluginArgs {
/// Plugin selector to install: either PLUGIN@MARKETPLACE or PLUGIN with --marketplace.
#[arg(value_name = "PLUGIN[@MARKETPLACE]")]
plugin: String,
/// Configured marketplace name to use when PLUGIN does not include @MARKETPLACE.
#[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")]
marketplace_name: Option<String>,
/// Output install result as JSON.
#[arg(long = "json")]
json: bool,
}
#[derive(Debug, Parser)]
#[command(
bin_name = "codex plugin list",
after_help = "Examples:\n codex plugin list\n codex plugin list --marketplace debug\n codex plugin list --json\n codex plugin list --available --json"
)]
pub struct ListPluginsArgs {
/// Only list plugins from this configured marketplace name.
#[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")]
marketplace_name: Option<String>,
/// Output plugin list as JSON.
#[arg(long = "json")]
json: bool,
/// Include uninstalled marketplace plugins in the JSON output.
#[arg(long = "available", requires = "json")]
available: bool,
}
#[derive(Debug, Parser)]
#[command(
bin_name = "codex plugin remove",
after_help = "Examples:\n codex plugin remove sample@debug\n codex plugin remove sample --marketplace debug"
)]
pub struct RemovePluginArgs {
/// Plugin selector to remove: either PLUGIN@MARKETPLACE or PLUGIN with --marketplace.
#[arg(value_name = "PLUGIN[@MARKETPLACE]")]
plugin: String,
/// Marketplace name to use when PLUGIN does not include @MARKETPLACE.
#[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")]
marketplace_name: Option<String>,
/// Output remove result as JSON.
#[arg(long = "json")]
json: bool,
}
pub async fn run_plugin_add(
overrides: Vec<(String, toml::Value)>,
args: AddPluginArgs,
) -> Result<()> {
let PluginCommandContext {
codex_home,
plugins_input,
manager,
} = load_plugin_command_context(overrides).await?;
let AddPluginArgs {
plugin,
marketplace_name,
json,
} = args;
let PluginSelection {
plugin_name,
marketplace_name,
..
} = parse_plugin_selection(plugin, marketplace_name)?;
let marketplace = find_marketplace_for_plugin(
&manager,
codex_home.as_path(),
&plugins_input,
&marketplace_name,
&plugin_name,
)?;
let outcome = manager
.install_plugin(PluginInstallRequest {
plugin_name,
marketplace_path: marketplace.path,
})
.await?;
if json {
let output = JsonPluginAddOutput::from_outcome(outcome);
println!("{}", serde_json::to_string_pretty(&output)?);
return Ok(());
}
println!(
"Added plugin `{}` from marketplace `{}`.",
outcome.plugin_id.plugin_name, outcome.plugin_id.marketplace_name
);
println!(
"Installed plugin root: {}",
outcome.installed_path.as_path().display()
);
Ok(())
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonPluginAddOutput {
plugin_id: String,
name: String,
marketplace_name: String,
version: String,
installed_path: String,
auth_policy: &'static str,
}
impl JsonPluginAddOutput {
fn from_outcome(outcome: PluginInstallOutcome) -> Self {
Self {
plugin_id: outcome.plugin_id.as_key(),
name: outcome.plugin_id.plugin_name,
marketplace_name: outcome.plugin_id.marketplace_name,
version: outcome.plugin_version,
installed_path: outcome.installed_path.as_path().display().to_string(),
auth_policy: auth_policy_label(outcome.auth_policy),
}
}
}
pub async fn run_plugin_list(
overrides: Vec<(String, toml::Value)>,
args: ListPluginsArgs,
) -> Result<()> {
let PluginCommandContext {
codex_home,
plugins_input,
manager,
..
} = load_plugin_command_context(overrides).await?;
let outcome = manager
.list_marketplaces_for_config(&plugins_input, &[], /*include_openai_curated*/ true)
.context("failed to list marketplace plugins")?;
ensure_configured_marketplace_snapshots_loaded(
codex_home.as_path(),
&plugins_input,
&outcome.errors,
/*marketplace_name*/ None,
)?;
let marketplaces = outcome
.marketplaces
.into_iter()
.filter(|marketplace| {
args.marketplace_name
.as_ref()
.is_none_or(|name| marketplace.name == *name)
})
.collect::<Vec<_>>();
let marketplace_sources = configured_marketplace_sources(&plugins_input);
if args.json {
let output = JsonPluginListOutput::from_marketplaces(
marketplaces,
args.available,
&marketplace_sources,
);
println!("{}", serde_json::to_string_pretty(&output)?);
return Ok(());
}
if marketplaces.is_empty() {
if let Some(marketplace_name) = args.marketplace_name {
println!("No plugins found in marketplace `{marketplace_name}`.");
} else {
println!("No marketplace plugins found.");
}
} else {
for (index, marketplace) in marketplaces.into_iter().enumerate() {
let mut rows = Vec::new();
let mut plugin_width = "PLUGIN".len();
let mut status_width = "STATUS".len();
let mut installed_version_width = "VERSION".len();
let mut path_width = "PATH".len();
for plugin in &marketplace.plugins {
let state = if plugin.installed && plugin.enabled {
"installed, enabled"
} else if plugin.installed {
"installed, disabled"
} else {
"not installed"
};
let installed_version = plugin.installed_version.clone().unwrap_or_default();
let path = match &plugin.source {
codex_core_plugins::marketplace::MarketplacePluginSource::Local { path } => {
path.as_path().display().to_string()
}
codex_core_plugins::marketplace::MarketplacePluginSource::Git {
url,
path,
ref_name,
sha,
} => {
let mut parts = vec![url.clone()];
if let Some(path) = path {
parts.push(format!("path `{path}`"));
}
if let Some(ref_name) = ref_name {
parts.push(format!("ref `{ref_name}`"));
}
if let Some(sha) = sha {
parts.push(format!("sha `{sha}`"));
}
parts.join(", ")
}
};
plugin_width = plugin_width.max(plugin.id.len());
status_width = status_width.max(state.len());
installed_version_width = installed_version_width.max(installed_version.len());
path_width = path_width.max(path.len());
rows.push((plugin.id.clone(), state, installed_version, path));
}
if index > 0 {
println!();
}
println!("Marketplace `{}`", marketplace.name);
println!("{}", marketplace.path.as_path().display());
println!();
println!(
"{:<plugin_width$} {:<status_width$} {:<installed_version_width$} {:<path_width$}",
"PLUGIN", "STATUS", "VERSION", "PATH"
);
for (plugin, status, installed_version, path) in rows {
println!(
"{plugin:<plugin_width$} {status:<status_width$} {installed_version:<installed_version_width$} {path:<path_width$}"
);
}
}
}
Ok(())
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonPluginListOutput {
installed: Vec<JsonPluginListEntry>,
available: Vec<JsonPluginListEntry>,
}
impl JsonPluginListOutput {
fn from_marketplaces(
marketplaces: Vec<codex_core_plugins::ConfiguredMarketplace>,
include_available: bool,
marketplace_sources: &HashMap<String, JsonMarketplaceSource>,
) -> Self {
let mut installed = Vec::new();
let mut available = Vec::new();
for marketplace in marketplaces {
let marketplace_source = marketplace_sources.get(&marketplace.name).cloned();
for plugin in marketplace.plugins {
let entry = JsonPluginListEntry::from_configured_plugin(
&marketplace.name,
marketplace_source.clone(),
plugin,
);
if entry.installed {
installed.push(entry);
} else if include_available {
available.push(entry);
}
}
}
Self {
installed,
available,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonPluginListEntry {
plugin_id: String,
name: String,
marketplace_name: String,
version: Option<String>,
installed: bool,
enabled: bool,
source: JsonPluginSource,
#[serde(skip_serializing_if = "Option::is_none")]
marketplace_source: Option<JsonMarketplaceSource>,
install_policy: &'static str,
auth_policy: &'static str,
}
impl JsonPluginListEntry {
fn from_configured_plugin(
marketplace_name: &str,
marketplace_source: Option<JsonMarketplaceSource>,
plugin: codex_core_plugins::ConfiguredMarketplacePlugin,
) -> Self {
let version = plugin.installed_version.or(plugin.local_version);
Self {
plugin_id: plugin.id,
name: plugin.name,
marketplace_name: marketplace_name.to_string(),
version,
installed: plugin.installed,
enabled: plugin.enabled,
source: JsonPluginSource::from_marketplace_source(plugin.source),
marketplace_source,
install_policy: install_policy_label(plugin.policy.installation),
auth_policy: auth_policy_label(plugin.policy.authentication),
}
}
}
#[derive(Debug, Serialize)]
#[serde(tag = "source", rename_all = "kebab-case")]
enum JsonPluginSource {
Local {
path: String,
},
Git {
url: String,
#[serde(rename = "ref", skip_serializing_if = "Option::is_none")]
ref_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
sha: Option<String>,
},
GitSubdir {
url: String,
path: String,
#[serde(rename = "ref", skip_serializing_if = "Option::is_none")]
ref_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
sha: Option<String>,
},
}
impl JsonPluginSource {
fn from_marketplace_source(source: MarketplacePluginSource) -> Self {
match source {
MarketplacePluginSource::Local { path } => Self::Local {
path: path.as_path().display().to_string(),
},
MarketplacePluginSource::Git {
url,
path: Some(path),
ref_name,
sha,
} => Self::GitSubdir {
url,
path,
ref_name,
sha,
},
MarketplacePluginSource::Git {
url,
path: None,
ref_name,
sha,
} => Self::Git { url, ref_name, sha },
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct JsonMarketplaceSource {
source_type: String,
source: String,
}
pub(crate) fn configured_marketplace_sources(
plugins_input: &PluginsConfigInput,
) -> HashMap<String, JsonMarketplaceSource> {
let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else {
return HashMap::new();
};
let Some(marketplaces) = user_config
.get("marketplaces")
.and_then(toml::Value::as_table)
else {
return HashMap::new();
};
marketplaces
.iter()
.filter_map(|(marketplace_name, marketplace)| {
let source_type = marketplace
.get("source_type")
.and_then(toml::Value::as_str)?;
let source = marketplace.get("source").and_then(toml::Value::as_str)?;
Some((
marketplace_name.clone(),
JsonMarketplaceSource {
source_type: source_type.to_string(),
source: source.to_string(),
},
))
})
.collect()
}
fn install_policy_label(policy: MarketplacePluginInstallPolicy) -> &'static str {
match policy {
MarketplacePluginInstallPolicy::NotAvailable => "NOT_AVAILABLE",
MarketplacePluginInstallPolicy::Available => "AVAILABLE",
MarketplacePluginInstallPolicy::InstalledByDefault => "INSTALLED_BY_DEFAULT",
}
}
fn auth_policy_label(policy: MarketplacePluginAuthPolicy) -> &'static str {
match policy {
MarketplacePluginAuthPolicy::OnInstall => "ON_INSTALL",
MarketplacePluginAuthPolicy::OnUse => "ON_USE",
}
}
pub async fn run_plugin_remove(
overrides: Vec<(String, toml::Value)>,
args: RemovePluginArgs,
) -> Result<()> {
let PluginCommandContext { manager, .. } = load_plugin_command_context(overrides).await?;
let RemovePluginArgs {
plugin,
marketplace_name,
json,
} = args;
let selection = parse_plugin_selection(plugin, marketplace_name)?;
manager
.uninstall_plugin(selection.plugin_key.clone())
.await?;
if json {
let output = JsonPluginRemoveOutput::from_selection(selection);
println!("{}", serde_json::to_string_pretty(&output)?);
return Ok(());
}
println!(
"Removed plugin `{}` from marketplace `{}`.",
selection.plugin_name, selection.marketplace_name
);
Ok(())
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonPluginRemoveOutput {
plugin_id: String,
name: String,
marketplace_name: String,
}
impl JsonPluginRemoveOutput {
fn from_selection(selection: PluginSelection) -> Self {
Self {
plugin_id: selection.plugin_key,
name: selection.plugin_name,
marketplace_name: selection.marketplace_name,
}
}
}
struct PluginCommandContext {
codex_home: PathBuf,
plugins_input: PluginsConfigInput,
manager: PluginsManager,
}
async fn load_plugin_command_context(
overrides: Vec<(String, toml::Value)>,
) -> Result<PluginCommandContext> {
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let plugins_input = config.plugins_config_input();
let manager = PluginsManager::new(codex_home.to_path_buf());
Ok(PluginCommandContext {
codex_home: codex_home.to_path_buf(),
plugins_input,
manager,
})
}
struct PluginSelection {
plugin_name: String,
marketplace_name: String,
plugin_key: String,
}
impl PluginSelection {
fn from_plugin_id(plugin_id: PluginId) -> Self {
let plugin_key = plugin_id.as_key();
Self {
plugin_name: plugin_id.plugin_name,
marketplace_name: plugin_id.marketplace_name,
plugin_key,
}
}
}
fn parse_plugin_selection(
plugin: String,
marketplace_name: Option<String>,
) -> Result<PluginSelection> {
match (PluginId::parse(&plugin), marketplace_name) {
(Ok(plugin_id), None) => Ok(PluginSelection::from_plugin_id(plugin_id)),
(Ok(plugin_id), Some(marketplace_name)) => {
if plugin_id.marketplace_name != marketplace_name {
bail!(
"plugin id `{}` belongs to marketplace `{}`, but --marketplace specified `{}`",
plugin,
plugin_id.marketplace_name,
marketplace_name
);
}
Ok(PluginSelection::from_plugin_id(plugin_id))
}
(Err(_), Some(marketplace_name)) => Ok(PluginSelection::from_plugin_id(PluginId::new(
plugin,
marketplace_name,
)?)),
(Err(_), None) => {
bail!("plugin requires --marketplace unless passed as <plugin>@<marketplace>")
}
}
}
fn find_marketplace_for_plugin(
manager: &PluginsManager,
codex_home: &std::path::Path,
plugins_input: &PluginsConfigInput,
marketplace_name: &str,
plugin_name: &str,
) -> Result<ConfiguredMarketplace> {
let outcome = manager
.list_marketplaces_for_config(plugins_input, &[], /*include_openai_curated*/ true)
.context("failed to list marketplace plugins")?;
ensure_configured_marketplace_snapshots_loaded(
codex_home,
plugins_input,
&outcome.errors,
Some(marketplace_name),
)?;
let matches = outcome
.marketplaces
.into_iter()
.filter(|marketplace| marketplace.name == marketplace_name)
.filter(|marketplace| {
marketplace
.plugins
.iter()
.any(|plugin| plugin.name == plugin_name)
})
.collect::<Vec<_>>();
match matches.as_slice() {
[] => bail!("plugin `{plugin_name}` was not found in marketplace `{marketplace_name}`"),
[marketplace] => Ok(marketplace.clone()),
_ => bail!(
"plugin `{plugin_name}` in marketplace `{marketplace_name}` matched multiple marketplace roots"
),
}
}
pub(crate) struct ConfiguredMarketplaceSnapshotIssue {
pub(crate) marketplace_name: String,
pub(crate) path: PathBuf,
pub(crate) message: String,
}
fn ensure_configured_marketplace_snapshots_loaded(
codex_home: &std::path::Path,
plugins_input: &PluginsConfigInput,
load_errors: &[MarketplaceListError],
marketplace_name: Option<&str>,
) -> Result<()> {
let issues = configured_marketplace_snapshot_issues(
codex_home,
plugins_input,
load_errors,
marketplace_name,
);
if issues.is_empty() {
return Ok(());
}
let issue_lines = issues
.iter()
.map(|issue| {
format!(
"- `{}` at {}: {}",
issue.marketplace_name,
issue.path.display(),
issue.message
)
})
.collect::<Vec<_>>()
.join("\n");
bail!("failed to load configured marketplace snapshot(s):\n{issue_lines}");
}
pub(crate) fn configured_marketplace_snapshot_issues(
codex_home: &std::path::Path,
plugins_input: &PluginsConfigInput,
load_errors: &[MarketplaceListError],
marketplace_name: Option<&str>,
) -> Vec<ConfiguredMarketplaceSnapshotIssue> {
let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else {
return Vec::new();
};
let Some(configured_marketplaces) = user_config
.get("marketplaces")
.and_then(toml::Value::as_table)
else {
return Vec::new();
};
let default_install_root = marketplace_install_root(codex_home);
let mut manifest_paths = Vec::new();
let mut issues = Vec::new();
for (configured_name, marketplace) in configured_marketplaces {
if marketplace_name.is_some_and(|name| configured_name != name) {
continue;
}
if !marketplace.is_table() {
issues.push(ConfiguredMarketplaceSnapshotIssue {
marketplace_name: configured_name.clone(),
path: PathBuf::from("<invalid config>"),
message: "configured marketplace entry must be a table".to_string(),
});
continue;
}
if let Err(err) = validate_plugin_segment(configured_name, "marketplace name") {
issues.push(ConfiguredMarketplaceSnapshotIssue {
marketplace_name: configured_name.clone(),
path: PathBuf::from("<invalid config>"),
message: err.to_string(),
});
continue;
}
if marketplace.get("source_type").and_then(toml::Value::as_str) == Some("local")
&& marketplace
.get("source")
.and_then(toml::Value::as_str)
.is_none_or(str::is_empty)
{
issues.push(ConfiguredMarketplaceSnapshotIssue {
marketplace_name: configured_name.clone(),
path: PathBuf::from("<invalid source>"),
message: "configured local marketplace source is missing or empty".to_string(),
});
continue;
}
let Some(root) = resolve_configured_marketplace_root(
configured_name,
marketplace,
&default_install_root,
) else {
continue;
};
match find_marketplace_manifest_path(&root) {
Some(path) => manifest_paths.push((configured_name.clone(), path)),
None => {
if is_implicit_system_marketplace_root(configured_name, codex_home, &root) {
continue;
}
issues.push(ConfiguredMarketplaceSnapshotIssue {
marketplace_name: configured_name.clone(),
path: root,
message: "marketplace root does not contain a supported manifest".to_string(),
});
}
}
}
for error in load_errors {
if let Some((configured_name, _)) = manifest_paths
.iter()
.find(|(_, path)| path.as_path() == error.path.as_path())
{
issues.push(ConfiguredMarketplaceSnapshotIssue {
marketplace_name: configured_name.clone(),
path: error.path.to_path_buf(),
message: error.message.clone(),
});
}
}
issues
}
fn is_implicit_system_marketplace_root(
marketplace_name: &str,
_codex_home: &Path,
root: &Path,
) -> bool {
if matches!(
marketplace_name,
OPENAI_BUNDLED_MARKETPLACE_NAME | OPENAI_BUNDLED_ALPHA_MARKETPLACE_NAME
) && path_ends_with(root, &[".tmp", "bundled-marketplaces", marketplace_name])
{
return true;
}
marketplace_name == OPENAI_PRIMARY_RUNTIME_MARKETPLACE_NAME
&& path_ends_with(
root,
&[
"codex-runtimes",
"codex-primary-runtime",
"plugins",
marketplace_name,
],
)
}
fn path_ends_with(path: &Path, suffix: &[&str]) -> bool {
let path_components = path
.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>();
path_components.as_slice().ends_with(
&suffix
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>(),
)
}