[codex] List marketplaces considered by plugin discovery

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Casey Chow
2026-05-20 19:17:46 -04:00
committed by GitHub
Unverified
parent 8253ae4e5c
commit 60b45d92d9
5 changed files with 388 additions and 79 deletions
+70 -31
View File
@@ -6,14 +6,15 @@ use codex_core::config::Config;
use codex_core::config::find_codex_home;
use codex_core_plugins::PluginMarketplaceUpgradeOutcome;
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::marketplace_root_dir;
use codex_core_plugins::marketplace_add::MarketplaceAddRequest;
use codex_core_plugins::marketplace_add::add_marketplace;
use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest;
use codex_core_plugins::marketplace_remove::remove_marketplace;
use codex_plugin::validate_plugin_segment;
use codex_utils_cli::CliConfigOverrides;
use std::collections::HashSet;
use crate::plugin_cmd::configured_marketplace_snapshot_issues;
#[derive(Debug, Parser)]
#[command(bin_name = "codex plugin marketplace")]
@@ -30,7 +31,7 @@ enum MarketplaceSubcommand {
/// Add a local or Git marketplace to the configured marketplace sources.
Add(AddMarketplaceArgs),
/// List configured marketplace names and their local snapshot roots.
/// List plugin marketplaces Codex is currently considering and their roots.
List,
/// Refresh configured Git marketplace snapshots.
@@ -150,39 +151,77 @@ async fn run_list(overrides: Vec<(String, toml::Value)>) -> Result<()> {
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let configured_marketplaces = config
.config_layer_stack
.get_active_user_layer()
.and_then(|layer| layer.config.get("marketplaces"))
.and_then(toml::Value::as_table);
let Some(configured_marketplaces) = configured_marketplaces else {
println!("No configured plugin marketplaces.");
return Ok(());
};
if configured_marketplaces.is_empty() {
println!("No configured plugin marketplaces.");
let manager = PluginsManager::new(config.codex_home.to_path_buf());
let plugins_input = config.plugins_config_input();
let marketplace_listing = manager
.discover_marketplaces_for_config(&plugins_input, &[])
.context("failed to list plugin marketplaces")?;
let mut load_issues = configured_marketplace_snapshot_issues(
config.codex_home.as_path(),
&plugins_input,
&marketplace_listing.errors,
/*marketplace_name*/ None,
);
let mut issue_paths = load_issues
.iter()
.map(|issue| issue.path.clone())
.collect::<HashSet<_>>();
for error in &marketplace_listing.errors {
if issue_paths.insert(error.path.to_path_buf()) {
load_issues.push(crate::plugin_cmd::ConfiguredMarketplaceSnapshotIssue {
marketplace_name: error.path.display().to_string(),
path: error.path.to_path_buf(),
message: error.message.clone(),
});
}
}
if !load_issues.is_empty() {
let issue_lines = load_issues
.iter()
.map(|issue| {
format!(
"- `{}` at {}: {}",
issue.marketplace_name,
issue.path.display(),
issue.message
)
})
.collect::<Vec<_>>()
.join("\n");
bail!("failed to load marketplace(s):\n{issue_lines}");
}
let marketplaces = marketplace_listing.marketplaces;
if marketplaces.is_empty() {
println!("No plugin marketplaces in scope.");
return Ok(());
}
let default_install_root = marketplace_install_root(config.codex_home.as_path());
for (marketplace_name, marketplace) in configured_marketplaces {
if !marketplace.is_table() {
eprintln!("Ignoring invalid marketplace `{marketplace_name}`: expected table.");
let mut seen_roots = HashSet::new();
let mut rows = Vec::new();
for marketplace in marketplaces {
let Ok(root) = marketplace_root_dir(&marketplace.path) else {
continue;
};
if !seen_roots.insert(root.clone()) {
continue;
}
if let Err(err) = validate_plugin_segment(marketplace_name, "marketplace name") {
eprintln!("Ignoring invalid marketplace `{marketplace_name}`: {err}.");
continue;
}
let root = resolve_configured_marketplace_root(
rows.push((marketplace.name, root));
}
let marketplace_width = rows
.iter()
.map(|(name, _)| name.len())
.max()
.unwrap_or("MARKETPLACE".len())
.max("MARKETPLACE".len());
println!("{:<marketplace_width$} ROOT", "MARKETPLACE");
for (marketplace_name, root) in rows {
println!(
"{:<marketplace_width$} {}",
marketplace_name,
marketplace,
default_install_root.as_path(),
)
.map(|root| root.display().to_string())
.unwrap_or_else(|| "<invalid source>".to_string());
println!("{marketplace_name}\t{root}");
root.display()
);
}
Ok(())
+33 -10
View File
@@ -359,10 +359,10 @@ fn find_marketplace_for_plugin(
}
}
struct ConfiguredMarketplaceSnapshotIssue {
marketplace_name: String,
path: PathBuf,
message: String,
pub(crate) struct ConfiguredMarketplaceSnapshotIssue {
pub(crate) marketplace_name: String,
pub(crate) path: PathBuf,
pub(crate) message: String,
}
fn ensure_configured_marketplace_snapshots_loaded(
@@ -396,17 +396,16 @@ fn ensure_configured_marketplace_snapshots_loaded(
bail!("failed to load configured marketplace snapshot(s):\n{issue_lines}");
}
fn configured_marketplace_snapshot_issues(
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_layer) = plugins_input.config_layer_stack.get_active_user_layer() else {
let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else {
return Vec::new();
};
let Some(configured_marketplaces) = user_layer
.config
let Some(configured_marketplaces) = user_config
.get("marketplaces")
.and_then(toml::Value::as_table)
else {
@@ -420,9 +419,33 @@ fn configured_marketplace_snapshot_issues(
if marketplace_name.is_some_and(|name| configured_name != name) {
continue;
}
if !marketplace.is_table()
|| validate_plugin_segment(configured_name, "marketplace name").is_err()
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(
+269 -36
View File
@@ -7,9 +7,21 @@ use predicates::str::contains;
use std::path::Path;
use tempfile::TempDir;
const MARKETPLACE_HEADER: &str = "MARKETPLACE";
const MARKETPLACE_LIST_HEADER: &str = "MARKETPLACE ROOT";
fn marketplace_list_row(marketplace_name: &str, root: &Path) -> String {
format!(
"{marketplace_name:<width$} {}",
root.display(),
width = MARKETPLACE_HEADER.len()
)
}
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
cmd.env("CODEX_HOME", codex_home);
cmd.env("HOME", codex_home);
Ok(cmd)
}
@@ -40,7 +52,7 @@ plugins = true
Ok(())
}
fn write_marketplace_source(source: &Path) -> Result<()> {
fn write_marketplace_source_with_manifest(source: &Path, marketplace_manifest: &str) -> Result<()> {
std::fs::create_dir_all(source.join(".agents").join("plugins"))?;
std::fs::create_dir_all(source.join("plugins").join("sample").join(".codex-plugin"))?;
std::fs::write(
@@ -48,6 +60,22 @@ fn write_marketplace_source(source: &Path) -> Result<()> {
.join(".agents")
.join("plugins")
.join("marketplace.json"),
marketplace_manifest,
)?;
std::fs::write(
source
.join("plugins")
.join("sample")
.join(".codex-plugin")
.join("plugin.json"),
r#"{"name":"sample","version":"1.2.3","description":"Sample plugin"}"#,
)?;
Ok(())
}
fn write_marketplace_source(source: &Path) -> Result<()> {
write_marketplace_source_with_manifest(
source,
r#"{
"name": "debug",
"plugins": [
@@ -60,16 +88,28 @@ fn write_marketplace_source(source: &Path) -> Result<()> {
}
]
}"#,
)?;
std::fs::write(
source
.join("plugins")
.join("sample")
.join(".codex-plugin")
.join("plugin.json"),
r#"{"name":"sample","version":"1.2.3","description":"Sample plugin"}"#,
)?;
Ok(())
)
}
fn write_marketplace_source_with_explicit_empty_products(source: &Path) -> Result<()> {
write_marketplace_source_with_manifest(
source,
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
},
"policy": {
"products": []
}
}
]
}"#,
)
}
fn setup_local_marketplace() -> Result<(TempDir, TempDir)> {
@@ -94,6 +134,20 @@ fn setup_unconfigured_local_marketplace() -> Result<(TempDir, TempDir)> {
Ok((codex_home, source))
}
fn setup_local_marketplace_with_explicit_empty_products() -> Result<(TempDir, TempDir)> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_plugins_enabled_config(codex_home.path())?;
write_marketplace_source_with_explicit_empty_products(source.path())?;
let source_path = source.path().to_string_lossy().into_owned();
record_user_marketplace(
codex_home.path(),
"debug",
&configured_local_marketplace(&source_path),
)?;
Ok((codex_home, source))
}
fn setup_configured_marketplace_without_manifest() -> Result<(TempDir, TempDir)> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
@@ -207,16 +261,203 @@ fn remove_installed_plugin_config(codex_home: &Path, plugin_key: &str) -> Result
Ok(())
}
fn setup_configured_local_marketplace_with_missing_source() -> Result<TempDir> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[marketplaces.debug]
source_type = "local"
"#,
)?;
Ok(codex_home)
}
fn setup_configured_local_marketplace_with_invalid_name() -> Result<TempDir> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[marketplaces."bad/name"]
source_type = "local"
source = "/tmp/debug"
"#,
)?;
Ok(codex_home)
}
fn assert_configured_marketplace_snapshot_failure(
assert: assert_cmd::assert::Assert,
source: &Path,
detail: &str,
) {
assert
.failure()
.stderr(contains(
"failed to load configured marketplace snapshot(s):",
))
.stderr(contains("`debug`"))
.stderr(contains(source.display().to_string()))
.stderr(contains(detail));
}
fn assert_marketplace_failure(
assert: assert_cmd::assert::Assert,
marketplace_name: &str,
source: &Path,
detail: &str,
) {
assert
.failure()
.stderr(contains("failed to load marketplace(s):"))
.stderr(contains(format!("`{marketplace_name}`")))
.stderr(contains(source.display().to_string()))
.stderr(contains(detail));
}
#[tokio::test]
async fn marketplace_list_shows_configured_marketplace_names() -> Result<()> {
let (codex_home, source) = setup_local_marketplace()?;
let expected_row = marketplace_list_row("debug", source.path());
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert()
.success()
.stdout(contains("debug"))
.stdout(contains(source.path().display().to_string()));
.stdout(contains(MARKETPLACE_LIST_HEADER))
.stdout(contains(&expected_row))
.stdout(contains("\t").not());
Ok(())
}
#[tokio::test]
async fn marketplace_list_includes_home_marketplace_when_present() -> Result<()> {
let codex_home = TempDir::new()?;
let home = TempDir::new()?;
write_marketplace_source(home.path())?;
write_plugins_enabled_config(codex_home.path())?;
let expected_row = marketplace_list_row("debug", home.path());
codex_command(codex_home.path())?
.env("HOME", home.path())
.args(["plugin", "marketplace", "list"])
.assert()
.success()
.stdout(contains(MARKETPLACE_LIST_HEADER))
.stdout(contains(&expected_row))
.stdout(contains("\t").not());
Ok(())
}
#[tokio::test]
async fn marketplace_list_includes_root_when_plugins_are_filtered_out() -> Result<()> {
let (codex_home, source) = setup_local_marketplace_with_explicit_empty_products()?;
let expected_row = marketplace_list_row("debug", source.path());
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert()
.success()
.stdout(contains(MARKETPLACE_LIST_HEADER))
.stdout(contains(&expected_row));
Ok(())
}
#[tokio::test]
async fn marketplace_list_fails_when_configured_marketplace_snapshot_is_missing() -> Result<()> {
let (codex_home, source) = setup_configured_marketplace_without_manifest()?;
assert_marketplace_failure(
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert(),
"debug",
source.path(),
"marketplace root does not contain a supported manifest",
);
Ok(())
}
#[tokio::test]
async fn marketplace_list_fails_when_configured_marketplace_name_is_invalid() -> Result<()> {
let codex_home = setup_configured_local_marketplace_with_invalid_name()?;
assert_marketplace_failure(
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert(),
"bad/name",
Path::new("<invalid config>"),
"marketplace name",
);
Ok(())
}
#[tokio::test]
async fn marketplace_list_fails_when_configured_local_marketplace_source_is_missing() -> Result<()>
{
let codex_home = setup_configured_local_marketplace_with_missing_source()?;
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert()
.failure()
.stderr(contains("failed to load marketplace(s):"))
.stderr(contains("`debug`"))
.stderr(contains("<invalid source>"))
.stderr(contains(
"configured local marketplace source is missing or empty",
));
Ok(())
}
#[tokio::test]
async fn marketplace_list_fails_when_home_marketplace_is_malformed() -> Result<()> {
let codex_home = TempDir::new()?;
let home = TempDir::new()?;
write_plugins_enabled_config(codex_home.path())?;
std::fs::create_dir_all(home.path().join(".agents/plugins"))?;
let home_marketplace_path = home
.path()
.join(".agents")
.join("plugins")
.join("marketplace.json");
std::fs::write(&home_marketplace_path, "{not valid json")?;
codex_command(codex_home.path())?
.env("HOME", home.path())
.args(["plugin", "marketplace", "list"])
.assert()
.failure()
.stderr(contains("failed to load marketplace(s):"))
.stderr(contains(home_marketplace_path.display().to_string()))
.stderr(contains("key must be a string"));
Ok(())
}
#[tokio::test]
async fn marketplace_list_fails_when_configured_marketplace_snapshot_is_malformed() -> Result<()> {
let (codex_home, source) = setup_configured_marketplace_with_malformed_manifest()?;
assert_marketplace_failure(
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert(),
"debug",
source.path(),
"key must be a string",
);
Ok(())
}
@@ -286,18 +527,13 @@ async fn plugin_list_excludes_unconfigured_repo_local_marketplaces() -> Result<(
async fn plugin_list_fails_when_configured_marketplace_snapshot_is_missing() -> Result<()> {
let (codex_home, source) = setup_configured_marketplace_without_manifest()?;
codex_command(codex_home.path())?
.args(["plugin", "list"])
.assert()
.failure()
.stderr(contains(
"failed to load configured marketplace snapshot(s):",
))
.stderr(contains("`debug`"))
.stderr(contains(source.path().display().to_string()))
.stderr(contains(
"marketplace root does not contain a supported manifest",
));
assert_configured_marketplace_snapshot_failure(
codex_command(codex_home.path())?
.args(["plugin", "list"])
.assert(),
source.path(),
"marketplace root does not contain a supported manifest",
);
Ok(())
}
@@ -414,18 +650,15 @@ async fn plugin_add_rejects_unconfigured_repo_local_marketplaces() -> Result<()>
#[tokio::test]
async fn plugin_add_fails_when_configured_marketplace_snapshot_is_malformed() -> Result<()> {
let (codex_home, _source) = setup_configured_marketplace_with_malformed_manifest()?;
let (codex_home, source) = setup_configured_marketplace_with_malformed_manifest()?;
codex_command(codex_home.path())?
.args(["plugin", "add", "sample@debug"])
.assert()
.failure()
.stderr(contains(
"failed to load configured marketplace snapshot(s):",
))
.stderr(contains("`debug`"))
.stderr(contains("invalid marketplace file"))
.stderr(contains("key must be a string"));
assert_configured_marketplace_snapshot_failure(
codex_command(codex_home.path())?
.args(["plugin", "add", "sample@debug"])
.assert(),
source.path(),
"key must be a string",
);
Ok(())
}
+14 -1
View File
@@ -22,6 +22,7 @@ use crate::manifest::load_plugin_manifest;
use crate::marketplace::MarketplaceError;
use crate::marketplace::MarketplaceInterface;
use crate::marketplace::MarketplaceListError;
use crate::marketplace::MarketplaceListOutcome;
use crate::marketplace::MarketplacePluginAuthPolicy;
use crate::marketplace::MarketplacePluginPolicy;
use crate::marketplace::MarketplacePluginSource;
@@ -1208,7 +1209,7 @@ impl PluginsManager {
let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config);
let marketplace_outcome =
list_marketplaces(&self.marketplace_roots(config, additional_roots))?;
self.discover_marketplaces_for_config(config, additional_roots)?;
let mut seen_plugin_keys = HashSet::new();
let marketplaces = marketplace_outcome
.marketplaces
@@ -1286,6 +1287,18 @@ impl PluginsManager {
})
}
pub fn discover_marketplaces_for_config(
&self,
config: &PluginsConfigInput,
additional_roots: &[AbsolutePathBuf],
) -> Result<MarketplaceListOutcome, MarketplaceError> {
if !config.plugins_enabled {
return Ok(MarketplaceListOutcome::default());
}
list_marketplaces(&self.marketplace_roots(config, additional_roots))
}
pub async fn read_plugin_for_config(
&self,
config: &PluginsConfigInput,
+2 -1
View File
@@ -698,7 +698,8 @@ pub fn plugin_interface_with_marketplace_category(
interface
}
fn marketplace_root_dir(
#[doc(hidden)]
pub fn marketplace_root_dir(
marketplace_path: &AbsolutePathBuf,
) -> Result<AbsolutePathBuf, MarketplaceError> {
for relative_path in MARKETPLACE_MANIFEST_RELATIVE_PATHS {