Add marketplace command (#17087)

Added a new top-level `codex marketplace add` command for installing
plugin marketplaces into Codex’s local marketplace cache.

This change adds source parsing for local directories, GitHub shorthand,
and git URLs, supports optional `--ref` and git-only `--sparse` checkout
paths, stages the source in a temp directory, validates the marketplace
manifest, and installs it under
`$CODEX_HOME/marketplaces/<marketplace-name>`

Included tests cover local install behavior in the CLI and marketplace
discovery from installed roots in core. Scoped formatting and fix passes
were run, and targeted CLI/core tests passed.
This commit is contained in:
xli-oai
2026-04-10 19:18:37 -07:00
committed by GitHub
parent 58933237cd
commit f9a8d1870f
15 changed files with 1330 additions and 2 deletions

View File

@@ -38,10 +38,12 @@ use supports_color::Stream;
mod app_cmd;
#[cfg(target_os = "macos")]
mod desktop_app;
mod marketplace_cmd;
mod mcp_cmd;
#[cfg(not(windows))]
mod wsl_paths;
use crate::marketplace_cmd::MarketplaceCli;
use crate::mcp_cmd::McpCli;
use codex_core::config::Config;
@@ -105,6 +107,9 @@ enum Subcommand {
/// Manage external MCP servers for Codex.
Mcp(McpCli),
/// Manage plugin marketplaces for Codex.
Marketplace(MarketplaceCli),
/// Start Codex as an MCP server (stdio).
McpServer,
@@ -704,6 +709,18 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone());
mcp_cli.run().await?;
}
Some(Subcommand::Marketplace(mut marketplace_cli)) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"marketplace",
)?;
prepend_config_flags(
&mut marketplace_cli.config_overrides,
root_config_overrides.clone(),
);
marketplace_cli.run().await?;
}
Some(Subcommand::AppServer(app_server_cli)) => {
let AppServerCommand {
subcommand,

View File

@@ -0,0 +1,562 @@
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use clap::Parser;
use codex_config::MarketplaceConfigUpdate;
use codex_config::record_user_marketplace;
use codex_core::config::find_codex_home;
use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core::plugins::marketplace_install_root;
use codex_core::plugins::validate_marketplace_root;
use codex_core::plugins::validate_plugin_segment;
use codex_utils_cli::CliConfigOverrides;
use std::fs;
use std::path::Path;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
mod metadata;
mod ops;
#[derive(Debug, Parser)]
pub struct MarketplaceCli {
#[clap(flatten)]
pub config_overrides: CliConfigOverrides,
#[command(subcommand)]
subcommand: MarketplaceSubcommand,
}
#[derive(Debug, clap::Subcommand)]
enum MarketplaceSubcommand {
/// Add a remote marketplace repository.
Add(AddMarketplaceArgs),
}
#[derive(Debug, Parser)]
struct AddMarketplaceArgs {
/// Marketplace source. Supports owner/repo[@ref], HTTP(S) Git URLs, or SSH URLs.
source: String,
/// Git ref to check out. Overrides any @ref or #ref suffix in SOURCE.
#[arg(long = "ref", value_name = "REF")]
ref_name: Option<String>,
/// Sparse-checkout path to use while cloning git sources. Repeat to include multiple paths.
#[arg(
long = "sparse",
value_name = "PATH",
action = clap::ArgAction::Append
)]
sparse_paths: Vec<String>,
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum MarketplaceSource {
Git {
url: String,
ref_name: Option<String>,
},
}
impl MarketplaceCli {
pub async fn run(self) -> Result<()> {
let MarketplaceCli {
config_overrides,
subcommand,
} = self;
// Validate overrides now. This command writes to CODEX_HOME only; marketplace discovery
// happens from that cache root after the next plugin/list or app-server start.
config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
match subcommand {
MarketplaceSubcommand::Add(args) => run_add(args).await?,
}
Ok(())
}
}
async fn run_add(args: AddMarketplaceArgs) -> Result<()> {
let AddMarketplaceArgs {
source,
ref_name,
sparse_paths,
} = args;
let source = parse_marketplace_source(&source, ref_name)?;
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
let install_root = marketplace_install_root(&codex_home);
fs::create_dir_all(&install_root).with_context(|| {
format!(
"failed to create marketplace install directory {}",
install_root.display()
)
})?;
let install_metadata =
metadata::MarketplaceInstallMetadata::from_source(&source, &sparse_paths);
if let Some(existing_root) = metadata::installed_marketplace_root_for_source(
&codex_home,
&install_root,
&install_metadata,
)? {
let marketplace_name = validate_marketplace_root(&existing_root).with_context(|| {
format!(
"failed to validate installed marketplace at {}",
existing_root.display()
)
})?;
record_added_marketplace(&codex_home, &marketplace_name, &install_metadata)?;
println!(
"Marketplace `{marketplace_name}` is already added from {}.",
source.display()
);
println!("Installed marketplace root: {}", existing_root.display());
return Ok(());
}
let staging_root = ops::marketplace_staging_root(&install_root);
fs::create_dir_all(&staging_root).with_context(|| {
format!(
"failed to create marketplace staging directory {}",
staging_root.display()
)
})?;
let staged_dir = tempfile::Builder::new()
.prefix("marketplace-add-")
.tempdir_in(&staging_root)
.with_context(|| {
format!(
"failed to create temporary marketplace directory in {}",
staging_root.display()
)
})?;
let staged_root = staged_dir.path().to_path_buf();
let MarketplaceSource::Git { url, ref_name } = &source;
ops::clone_git_source(url, ref_name.as_deref(), &sparse_paths, &staged_root)?;
let marketplace_name = validate_marketplace_source_root(&staged_root)
.with_context(|| format!("failed to validate marketplace from {}", source.display()))?;
if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
bail!(
"marketplace `{OPENAI_CURATED_MARKETPLACE_NAME}` is reserved and cannot be added from {}",
source.display()
);
}
let destination = install_root.join(safe_marketplace_dir_name(&marketplace_name)?);
ensure_marketplace_destination_is_inside_install_root(&install_root, &destination)?;
if destination.exists() {
bail!(
"marketplace `{marketplace_name}` is already added from a different source; remove it before adding {}",
source.display()
);
}
ops::replace_marketplace_root(&staged_root, &destination)
.with_context(|| format!("failed to install marketplace at {}", destination.display()))?;
if let Err(err) = record_added_marketplace(&codex_home, &marketplace_name, &install_metadata) {
if let Err(rollback_err) = fs::rename(&destination, &staged_root) {
bail!(
"{err}; additionally failed to roll back installed marketplace at {}: {rollback_err}",
destination.display()
);
}
return Err(err);
}
println!(
"Added marketplace `{marketplace_name}` from {}.",
source.display()
);
println!("Installed marketplace root: {}", destination.display());
Ok(())
}
fn record_added_marketplace(
codex_home: &Path,
marketplace_name: &str,
install_metadata: &metadata::MarketplaceInstallMetadata,
) -> Result<()> {
let source = install_metadata.config_source();
let last_updated = utc_timestamp_now()?;
let update = MarketplaceConfigUpdate {
last_updated: &last_updated,
source_type: install_metadata.config_source_type(),
source: &source,
ref_name: install_metadata.ref_name(),
sparse_paths: install_metadata.sparse_paths(),
};
record_user_marketplace(codex_home, marketplace_name, &update).with_context(|| {
format!("failed to add marketplace `{marketplace_name}` to user config.toml")
})?;
Ok(())
}
fn validate_marketplace_source_root(root: &Path) -> Result<String> {
let marketplace_name = validate_marketplace_root(root)?;
validate_plugin_segment(&marketplace_name, "marketplace name").map_err(anyhow::Error::msg)?;
Ok(marketplace_name)
}
fn parse_marketplace_source(
source: &str,
explicit_ref: Option<String>,
) -> Result<MarketplaceSource> {
let source = source.trim();
if source.is_empty() {
bail!("marketplace source must not be empty");
}
let (base_source, parsed_ref) = split_source_ref(source);
let ref_name = explicit_ref.or(parsed_ref);
if looks_like_local_path(&base_source) {
bail!(
"local marketplace sources are not supported yet; use an HTTP(S) Git URL, SSH Git URL, or GitHub owner/repo"
);
}
if is_ssh_git_url(&base_source) || is_git_url(&base_source) {
let url = normalize_git_url(&base_source);
return Ok(MarketplaceSource::Git { url, ref_name });
}
if looks_like_github_shorthand(&base_source) {
let url = format!("https://github.com/{base_source}.git");
return Ok(MarketplaceSource::Git { url, ref_name });
}
bail!("invalid marketplace source format: {source}");
}
fn split_source_ref(source: &str) -> (String, Option<String>) {
if let Some((base, ref_name)) = source.rsplit_once('#') {
return (base.to_string(), non_empty_ref(ref_name));
}
if !source.contains("://")
&& !is_ssh_git_url(source)
&& let Some((base, ref_name)) = source.rsplit_once('@')
{
return (base.to_string(), non_empty_ref(ref_name));
}
(source.to_string(), None)
}
fn non_empty_ref(ref_name: &str) -> Option<String> {
let ref_name = ref_name.trim();
(!ref_name.is_empty()).then(|| ref_name.to_string())
}
fn normalize_git_url(url: &str) -> String {
let url = url.trim_end_matches('/');
if url.starts_with("https://github.com/") && !url.ends_with(".git") {
format!("{url}.git")
} else {
url.to_string()
}
}
fn looks_like_local_path(source: &str) -> bool {
source.starts_with("./")
|| source.starts_with("../")
|| source.starts_with('/')
|| source.starts_with("~/")
|| source == "."
|| source == ".."
}
fn is_ssh_git_url(source: &str) -> bool {
source.starts_with("ssh://") || source.starts_with("git@") && source.contains(':')
}
fn is_git_url(source: &str) -> bool {
source.starts_with("http://") || source.starts_with("https://")
}
fn looks_like_github_shorthand(source: &str) -> bool {
let mut segments = source.split('/');
let owner = segments.next();
let repo = segments.next();
let extra = segments.next();
owner.is_some_and(is_github_shorthand_segment)
&& repo.is_some_and(is_github_shorthand_segment)
&& extra.is_none()
}
fn is_github_shorthand_segment(segment: &str) -> bool {
!segment.is_empty()
&& segment
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
}
fn safe_marketplace_dir_name(marketplace_name: &str) -> Result<String> {
let safe = marketplace_name
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
ch
} else {
'-'
}
})
.collect::<String>();
let safe = safe.trim_matches('.').to_string();
if safe.is_empty() || safe == ".." {
bail!("marketplace name `{marketplace_name}` cannot be used as an install directory");
}
Ok(safe)
}
fn ensure_marketplace_destination_is_inside_install_root(
install_root: &Path,
destination: &Path,
) -> Result<()> {
let install_root = install_root.canonicalize().with_context(|| {
format!(
"failed to resolve marketplace install root {}",
install_root.display()
)
})?;
let destination_parent = destination
.parent()
.context("marketplace destination has no parent")?
.canonicalize()
.with_context(|| {
format!(
"failed to resolve marketplace destination parent {}",
destination.display()
)
})?;
if !destination_parent.starts_with(&install_root) {
bail!(
"marketplace destination {} is outside install root {}",
destination.display(),
install_root.display()
);
}
Ok(())
}
fn utc_timestamp_now() -> Result<String> {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system clock is before Unix epoch")?;
Ok(format_utc_timestamp(duration.as_secs() as i64))
}
fn format_utc_timestamp(seconds_since_epoch: i64) -> String {
const SECONDS_PER_DAY: i64 = 86_400;
let days = seconds_since_epoch.div_euclid(SECONDS_PER_DAY);
let seconds_of_day = seconds_since_epoch.rem_euclid(SECONDS_PER_DAY);
let (year, month, day) = civil_from_days(days);
let hour = seconds_of_day / 3_600;
let minute = (seconds_of_day % 3_600) / 60;
let second = seconds_of_day % 60;
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
fn civil_from_days(days_since_epoch: i64) -> (i64, i64, i64) {
let days = days_since_epoch + 719_468;
let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
let day_of_era = days - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let mut year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
year += if month <= 2 { 1 } else { 0 };
(year, month, day)
}
impl MarketplaceSource {
fn display(&self) -> String {
match self {
Self::Git { url, ref_name } => {
if let Some(ref_name) = ref_name {
format!("{url}#{ref_name}")
} else {
url.clone()
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn github_shorthand_parses_ref_suffix() {
assert_eq!(
parse_marketplace_source("owner/repo@main", /*explicit_ref*/ None).unwrap(),
MarketplaceSource::Git {
url: "https://github.com/owner/repo.git".to_string(),
ref_name: Some("main".to_string()),
}
);
}
#[test]
fn git_url_parses_fragment_ref() {
assert_eq!(
parse_marketplace_source(
"https://example.com/team/repo.git#v1",
/*explicit_ref*/ None,
)
.unwrap(),
MarketplaceSource::Git {
url: "https://example.com/team/repo.git".to_string(),
ref_name: Some("v1".to_string()),
}
);
}
#[test]
fn explicit_ref_overrides_source_ref() {
assert_eq!(
parse_marketplace_source(
"owner/repo@main",
/*explicit_ref*/ Some("release".to_string()),
)
.unwrap(),
MarketplaceSource::Git {
url: "https://github.com/owner/repo.git".to_string(),
ref_name: Some("release".to_string()),
}
);
}
#[test]
fn github_shorthand_and_git_url_normalize_to_same_source() {
let shorthand = parse_marketplace_source("owner/repo", /*explicit_ref*/ None).unwrap();
let git_url = parse_marketplace_source(
"https://github.com/owner/repo.git",
/*explicit_ref*/ None,
)
.unwrap();
assert_eq!(shorthand, git_url);
assert_eq!(
shorthand,
MarketplaceSource::Git {
url: "https://github.com/owner/repo.git".to_string(),
ref_name: None,
}
);
}
#[test]
fn github_url_with_trailing_slash_normalizes_without_extra_path_segment() {
assert_eq!(
parse_marketplace_source("https://github.com/owner/repo/", /*explicit_ref*/ None)
.unwrap(),
MarketplaceSource::Git {
url: "https://github.com/owner/repo.git".to_string(),
ref_name: None,
}
);
}
#[test]
fn non_github_https_source_parses_as_git_url() {
assert_eq!(
parse_marketplace_source("https://gitlab.com/owner/repo", /*explicit_ref*/ None)
.unwrap(),
MarketplaceSource::Git {
url: "https://gitlab.com/owner/repo".to_string(),
ref_name: None,
}
);
}
#[test]
fn file_url_source_is_rejected() {
let err =
parse_marketplace_source("file:///tmp/marketplace.git", /*explicit_ref*/ None)
.unwrap_err();
assert!(
err.to_string()
.contains("invalid marketplace source format"),
"unexpected error: {err}"
);
}
#[test]
fn local_path_source_is_rejected() {
let err = parse_marketplace_source("./marketplace", /*explicit_ref*/ None).unwrap_err();
assert!(
err.to_string()
.contains("local marketplace sources are not supported yet"),
"unexpected error: {err}"
);
}
#[test]
fn ssh_url_parses_as_git_url() {
assert_eq!(
parse_marketplace_source(
"ssh://git@github.com/owner/repo.git#main",
/*explicit_ref*/ None,
)
.unwrap(),
MarketplaceSource::Git {
url: "ssh://git@github.com/owner/repo.git".to_string(),
ref_name: Some("main".to_string()),
}
);
}
#[test]
fn utc_timestamp_formats_unix_epoch_as_rfc3339_utc() {
assert_eq!(
format_utc_timestamp(/*seconds_since_epoch*/ 0),
"1970-01-01T00:00:00Z"
);
assert_eq!(
format_utc_timestamp(/*seconds_since_epoch*/ 1_775_779_200),
"2026-04-10T00:00:00Z"
);
}
#[test]
fn sparse_paths_parse_before_or_after_source() {
let sparse_before_source =
AddMarketplaceArgs::try_parse_from(["add", "--sparse", "plugins/foo", "owner/repo"])
.unwrap();
assert_eq!(sparse_before_source.source, "owner/repo");
assert_eq!(sparse_before_source.sparse_paths, vec!["plugins/foo"]);
let sparse_after_source =
AddMarketplaceArgs::try_parse_from(["add", "owner/repo", "--sparse", "plugins/foo"])
.unwrap();
assert_eq!(sparse_after_source.source, "owner/repo");
assert_eq!(sparse_after_source.sparse_paths, vec!["plugins/foo"]);
let repeated_sparse = AddMarketplaceArgs::try_parse_from([
"add",
"--sparse",
"plugins/foo",
"--sparse",
"skills/bar",
"owner/repo",
])
.unwrap();
assert_eq!(repeated_sparse.source, "owner/repo");
assert_eq!(
repeated_sparse.sparse_paths,
vec!["plugins/foo", "skills/bar"]
);
}
}

View File

@@ -0,0 +1,150 @@
use super::MarketplaceSource;
use anyhow::Context;
use anyhow::Result;
use codex_config::CONFIG_TOML_FILE;
use codex_core::plugins::validate_marketplace_root;
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct MarketplaceInstallMetadata {
source: InstalledMarketplaceSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum InstalledMarketplaceSource {
Git {
url: String,
ref_name: Option<String>,
sparse_paths: Vec<String>,
},
}
pub(super) fn installed_marketplace_root_for_source(
codex_home: &Path,
install_root: &Path,
install_metadata: &MarketplaceInstallMetadata,
) -> Result<Option<PathBuf>> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
let config = match std::fs::read_to_string(&config_path) {
Ok(config) => config,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
Err(err) => {
return Err(err)
.with_context(|| format!("failed to read user config {}", config_path.display()));
}
};
let config: toml::Value = toml::from_str(&config)
.with_context(|| format!("failed to parse user config {}", config_path.display()))?;
let Some(marketplaces) = config.get("marketplaces").and_then(toml::Value::as_table) else {
return Ok(None);
};
for (marketplace_name, marketplace) in marketplaces {
if !install_metadata.matches_config(marketplace) {
continue;
}
let root = install_root.join(marketplace_name);
if validate_marketplace_root(&root).is_ok() {
return Ok(Some(root));
}
}
Ok(None)
}
impl MarketplaceInstallMetadata {
pub(super) fn from_source(source: &MarketplaceSource, sparse_paths: &[String]) -> Self {
let source = match source {
MarketplaceSource::Git { url, ref_name } => InstalledMarketplaceSource::Git {
url: url.clone(),
ref_name: ref_name.clone(),
sparse_paths: sparse_paths.to_vec(),
},
};
Self { source }
}
pub(super) fn config_source_type(&self) -> &'static str {
match &self.source {
InstalledMarketplaceSource::Git { .. } => "git",
}
}
pub(super) fn config_source(&self) -> String {
match &self.source {
InstalledMarketplaceSource::Git { url, .. } => url.clone(),
}
}
pub(super) fn ref_name(&self) -> Option<&str> {
match &self.source {
InstalledMarketplaceSource::Git { ref_name, .. } => ref_name.as_deref(),
}
}
pub(super) fn sparse_paths(&self) -> &[String] {
match &self.source {
InstalledMarketplaceSource::Git { sparse_paths, .. } => sparse_paths,
}
}
fn matches_config(&self, marketplace: &toml::Value) -> bool {
marketplace.get("source_type").and_then(toml::Value::as_str)
== Some(self.config_source_type())
&& marketplace.get("source").and_then(toml::Value::as_str)
== Some(self.config_source().as_str())
&& marketplace.get("ref").and_then(toml::Value::as_str) == self.ref_name()
&& config_sparse_paths(marketplace) == self.sparse_paths()
}
}
fn config_sparse_paths(marketplace: &toml::Value) -> Vec<String> {
marketplace
.get("sparse_paths")
.and_then(toml::Value::as_array)
.map(|paths| {
paths
.iter()
.filter_map(toml::Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[test]
fn installed_marketplace_root_for_source_propagates_config_read_errors() -> Result<()> {
let codex_home = TempDir::new()?;
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
std::fs::create_dir(&config_path)?;
let install_root = codex_home.path().join("marketplaces");
let source = MarketplaceSource::Git {
url: "https://github.com/owner/repo.git".to_string(),
ref_name: None,
};
let install_metadata = MarketplaceInstallMetadata::from_source(&source, &[]);
let err = installed_marketplace_root_for_source(
codex_home.path(),
&install_root,
&install_metadata,
)
.unwrap_err();
assert_eq!(
err.to_string(),
format!("failed to read user config {}", config_path.display())
);
Ok(())
}
}

View File

@@ -0,0 +1,118 @@
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
pub(super) fn clone_git_source(
url: &str,
ref_name: Option<&str>,
sparse_paths: &[String],
destination: &Path,
) -> Result<()> {
let destination = destination.to_string_lossy().to_string();
if sparse_paths.is_empty() {
run_git(&["clone", url, destination.as_str()], /*cwd*/ None)?;
if let Some(ref_name) = ref_name {
run_git(&["checkout", ref_name], Some(Path::new(&destination)))?;
}
return Ok(());
}
run_git(
&[
"clone",
"--filter=blob:none",
"--no-checkout",
url,
destination.as_str(),
],
/*cwd*/ None,
)?;
let mut sparse_args = vec!["sparse-checkout", "set"];
sparse_args.extend(sparse_paths.iter().map(String::as_str));
let destination = Path::new(&destination);
run_git(&sparse_args, Some(destination))?;
run_git(&["checkout", ref_name.unwrap_or("HEAD")], Some(destination))?;
Ok(())
}
fn run_git(args: &[&str], cwd: Option<&Path>) -> Result<()> {
let mut command = Command::new("git");
command.args(args);
command.env("GIT_TERMINAL_PROMPT", "0");
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
let output = command
.output()
.with_context(|| format!("failed to run git {}", args.join(" ")))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
bail!(
"git {} failed with status {}\nstdout:\n{}\nstderr:\n{}",
args.join(" "),
output.status,
stdout.trim(),
stderr.trim()
);
}
pub(super) fn replace_marketplace_root(staged_root: &Path, destination: &Path) -> Result<()> {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
if destination.exists() {
bail!(
"marketplace destination already exists: {}",
destination.display()
);
}
fs::rename(staged_root, destination).map_err(Into::into)
}
pub(super) fn marketplace_staging_root(install_root: &Path) -> PathBuf {
install_root.join(".staging")
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[test]
fn replace_marketplace_root_rejects_existing_destination() {
let temp_dir = TempDir::new().unwrap();
let staged_root = temp_dir.path().join("staged");
let destination = temp_dir.path().join("destination");
fs::create_dir_all(&staged_root).unwrap();
fs::write(staged_root.join("marker.txt"), "staged").unwrap();
fs::create_dir_all(&destination).unwrap();
fs::write(destination.join("marker.txt"), "installed").unwrap();
let err = replace_marketplace_root(&staged_root, &destination).unwrap_err();
assert!(
err.to_string()
.contains("marketplace destination already exists"),
"unexpected error: {err}"
);
assert_eq!(
fs::read_to_string(staged_root.join("marker.txt")).unwrap(),
"staged"
);
assert_eq!(
fs::read_to_string(destination.join("marker.txt")).unwrap(),
"installed"
);
}
}

View File

@@ -0,0 +1,60 @@
use anyhow::Result;
use codex_core::plugins::marketplace_install_root;
use predicates::str::contains;
use std::path::Path;
use tempfile::TempDir;
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);
Ok(cmd)
}
fn write_marketplace_source(source: &Path, marker: &str) -> Result<()> {
std::fs::create_dir_all(source.join(".agents/plugins"))?;
std::fs::create_dir_all(source.join("plugins/sample/.codex-plugin"))?;
std::fs::write(
source.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
}
}
]
}"#,
)?;
std::fs::write(
source.join("plugins/sample/.codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)?;
std::fs::write(source.join("plugins/sample/marker.txt"), marker)?;
Ok(())
}
#[tokio::test]
async fn marketplace_add_rejects_local_directory_source() -> Result<()> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_marketplace_source(source.path(), "local ref")?;
codex_command(codex_home.path())?
.args(["marketplace", "add", source.path().to_str().unwrap()])
.assert()
.failure()
.stderr(contains(
"local marketplace sources are not supported yet; use an HTTP(S) Git URL, SSH Git URL, or GitHub owner/repo",
));
assert!(
!marketplace_install_root(codex_home.path())
.join("debug")
.exists()
);
Ok(())
}

View File

@@ -12,6 +12,7 @@ use crate::types::AppsConfigToml;
use crate::types::AuthCredentialsStoreMode;
use crate::types::FeedbackConfigToml;
use crate::types::History;
use crate::types::MarketplaceConfig;
use crate::types::McpServerConfig;
use crate::types::MemoriesToml;
use crate::types::Notice;
@@ -325,6 +326,10 @@ pub struct ConfigToml {
#[serde(default)]
pub plugins: HashMap<String, PluginConfig>,
/// User-level marketplace entries keyed by marketplace name.
#[serde(default)]
pub marketplaces: HashMap<String, MarketplaceConfig>,
/// Centralized feature flags (new). Prefer this over individual toggles.
#[serde(default)]
// Injects known feature keys into the schema and forbids unknown keys.

View File

@@ -4,6 +4,7 @@ pub mod config_toml;
mod constraint;
mod diagnostics;
mod fingerprint;
mod marketplace_edit;
mod mcp_edit;
mod mcp_types;
mod merge;
@@ -57,6 +58,8 @@ pub use diagnostics::format_config_error;
pub use diagnostics::format_config_error_with_source;
pub use diagnostics::io_error_from_config_error;
pub use fingerprint::version_for_toml;
pub use marketplace_edit::MarketplaceConfigUpdate;
pub use marketplace_edit::record_user_marketplace;
pub use mcp_edit::ConfigEditsBuilder;
pub use mcp_edit::load_global_mcp_servers;
pub use mcp_types::AppToolApproval;

View File

@@ -0,0 +1,83 @@
use std::fs;
use std::io::ErrorKind;
use std::path::Path;
use toml_edit::DocumentMut;
use toml_edit::Item as TomlItem;
use toml_edit::Table as TomlTable;
use toml_edit::Value as TomlValue;
use toml_edit::value;
use crate::CONFIG_TOML_FILE;
pub struct MarketplaceConfigUpdate<'a> {
pub last_updated: &'a str,
pub source_type: &'a str,
pub source: &'a str,
pub ref_name: Option<&'a str>,
pub sparse_paths: &'a [String],
}
pub fn record_user_marketplace(
codex_home: &Path,
marketplace_name: &str,
update: &MarketplaceConfigUpdate<'_>,
) -> std::io::Result<()> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
let mut doc = read_or_create_document(&config_path)?;
upsert_marketplace(&mut doc, marketplace_name, update);
fs::create_dir_all(codex_home)?;
fs::write(config_path, doc.to_string())
}
fn read_or_create_document(config_path: &Path) -> std::io::Result<DocumentMut> {
match fs::read_to_string(config_path) {
Ok(raw) => raw
.parse::<DocumentMut>()
.map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err)),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(DocumentMut::new()),
Err(err) => Err(err),
}
}
fn upsert_marketplace(
doc: &mut DocumentMut,
marketplace_name: &str,
update: &MarketplaceConfigUpdate<'_>,
) {
let root = doc.as_table_mut();
if !root.contains_key("marketplaces") {
root.insert("marketplaces", TomlItem::Table(new_implicit_table()));
}
let Some(marketplaces_item) = root.get_mut("marketplaces") else {
return;
};
if !marketplaces_item.is_table() {
*marketplaces_item = TomlItem::Table(new_implicit_table());
}
let Some(marketplaces) = marketplaces_item.as_table_mut() else {
return;
};
let mut entry = TomlTable::new();
entry.set_implicit(false);
entry["last_updated"] = value(update.last_updated.to_string());
entry["source_type"] = value(update.source_type.to_string());
entry["source"] = value(update.source.to_string());
if let Some(ref_name) = update.ref_name {
entry["ref"] = value(ref_name.to_string());
}
if !update.sparse_paths.is_empty() {
entry["sparse_paths"] = TomlItem::Value(TomlValue::Array(
update.sparse_paths.iter().map(String::as_str).collect(),
));
}
marketplaces.insert(marketplace_name, TomlItem::Table(entry));
}
fn new_implicit_table() -> TomlTable {
let mut table = TomlTable::new();
table.set_implicit(true);
table
}

View File

@@ -608,6 +608,32 @@ pub struct PluginConfig {
pub enabled: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct MarketplaceConfig {
/// Last time Codex successfully added or refreshed this marketplace.
#[serde(default)]
pub last_updated: Option<String>,
/// Source kind used to install this marketplace.
#[serde(default)]
pub source_type: Option<MarketplaceSourceType>,
/// Source location used when the marketplace was added.
#[serde(default)]
pub source: Option<String>,
/// Git ref to check out when `source_type` is `git`.
#[serde(default, rename = "ref")]
pub ref_name: Option<String>,
/// Sparse checkout paths used when `source_type` is `git`.
#[serde(default)]
pub sparse_paths: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum MarketplaceSourceType {
Git,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SandboxWorkspaceWrite {

View File

@@ -764,6 +764,50 @@
}
]
},
"MarketplaceConfig": {
"additionalProperties": false,
"properties": {
"last_updated": {
"default": null,
"description": "Last time Codex successfully added or refreshed this marketplace.",
"type": "string"
},
"ref": {
"default": null,
"description": "Git ref to check out when `source_type` is `git`.",
"type": "string"
},
"source": {
"default": null,
"description": "Source location used when the marketplace was added.",
"type": "string"
},
"source_type": {
"allOf": [
{
"$ref": "#/definitions/MarketplaceSourceType"
}
],
"default": null,
"description": "Source kind used to install this marketplace."
},
"sparse_paths": {
"default": null,
"description": "Sparse checkout paths used when `source_type` is `git`.",
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"MarketplaceSourceType": {
"enum": [
"git"
],
"type": "string"
},
"McpServerToolConfig": {
"additionalProperties": false,
"description": "Per-tool approval settings for a single MCP server tool.",
@@ -2414,6 +2458,14 @@
],
"description": "Directory where Codex writes log files, for example `codex-tui.log`. Defaults to `$CODEX_HOME/log`."
},
"marketplaces": {
"additionalProperties": {
"$ref": "#/definitions/MarketplaceConfig"
},
"default": {},
"description": "User-level marketplace entries keyed by marketplace name.",
"type": "object"
},
"mcp_oauth_callback_port": {
"description": "Optional fixed port for the local HTTP callback server used during MCP OAuth login. When unset, Codex will bind to an ephemeral port chosen by the OS.",
"format": "uint16",

View File

@@ -0,0 +1,57 @@
use crate::config::Config;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::path::Path;
use std::path::PathBuf;
use tracing::warn;
use super::validate_plugin_segment;
pub const INSTALLED_MARKETPLACES_DIR: &str = ".tmp/marketplaces";
pub fn marketplace_install_root(codex_home: &Path) -> PathBuf {
codex_home.join(INSTALLED_MARKETPLACES_DIR)
}
pub(crate) fn installed_marketplace_roots_from_config(
config: &Config,
codex_home: &Path,
) -> Vec<AbsolutePathBuf> {
let Some(user_layer) = config.config_layer_stack.get_user_layer() else {
return Vec::new();
};
let Some(marketplaces_value) = user_layer.config.get("marketplaces") else {
return Vec::new();
};
let Some(marketplaces) = marketplaces_value.as_table() else {
warn!("invalid marketplaces config: expected table");
return Vec::new();
};
let default_install_root = marketplace_install_root(codex_home);
let mut roots = marketplaces
.iter()
.filter_map(|(marketplace_name, marketplace)| {
if !marketplace.is_table() {
warn!(
marketplace_name,
"ignoring invalid configured marketplace entry"
);
return None;
}
if let Err(err) = validate_plugin_segment(marketplace_name, "marketplace name") {
warn!(
marketplace_name,
error = %err,
"ignoring invalid configured marketplace name"
);
return None;
}
let path = default_install_root.join(marketplace_name);
path.join(".agents/plugins/marketplace.json")
.is_file()
.then_some(path)
})
.filter_map(|path| AbsolutePathBuf::try_from(path).ok())
.collect::<Vec<_>>();
roots.sort_unstable_by(|left, right| left.as_path().cmp(right.as_path()));
roots
}

View File

@@ -2,6 +2,7 @@ use super::LoadedPlugin;
use super::PluginLoadOutcome;
use super::PluginManifestPaths;
use super::curated_plugins_repo_path;
use super::installed_marketplaces::installed_marketplace_roots_from_config;
use super::load_plugin_manifest;
use super::manifest::PluginManifestInterface;
use super::marketplace::MarketplaceError;
@@ -874,7 +875,8 @@ impl PluginsManager {
}
let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config);
let marketplace_outcome = list_marketplaces(&self.marketplace_roots(additional_roots))?;
let marketplace_outcome =
list_marketplaces(&self.marketplace_roots(config, additional_roots))?;
let mut seen_plugin_keys = HashSet::new();
let marketplaces = marketplace_outcome
.marketplaces
@@ -1218,10 +1220,18 @@ impl PluginsManager {
(installed_plugins, enabled_plugins)
}
fn marketplace_roots(&self, additional_roots: &[AbsolutePathBuf]) -> Vec<AbsolutePathBuf> {
fn marketplace_roots(
&self,
config: &Config,
additional_roots: &[AbsolutePathBuf],
) -> Vec<AbsolutePathBuf> {
// Treat the curated catalog as an extra marketplace root so plugin listing can surface it
// without requiring every caller to know where it is stored.
let mut roots = additional_roots.to_vec();
roots.extend(installed_marketplace_roots_from_config(
config,
self.codex_home.as_path(),
));
let curated_repo_root = curated_plugins_repo_path(self.codex_home.as_path());
if curated_repo_root.is_dir()
&& let Ok(curated_repo_root) = AbsolutePathBuf::try_from(curated_repo_root)

View File

@@ -8,6 +8,7 @@ use crate::config_loader::ConfigRequirementsToml;
use crate::plugins::LoadedPlugin;
use crate::plugins::MarketplacePluginInstallPolicy;
use crate::plugins::PluginLoadOutcome;
use crate::plugins::marketplace_install_root;
use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA;
use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha;
use crate::plugins::test_support::write_file;
@@ -1504,6 +1505,174 @@ plugins = true
);
}
#[tokio::test]
async fn list_marketplaces_includes_installed_marketplace_roots() {
let tmp = tempfile::tempdir().unwrap();
let marketplace_root = marketplace_install_root(tmp.path()).join("debug");
let plugin_root = marketplace_root.join("plugins/sample");
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[marketplaces.debug]
last_updated = "2026-04-10T12:34:56Z"
source_type = "git"
source = "/tmp/debug"
"#,
);
fs::create_dir_all(marketplace_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
fs::write(
marketplace_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
}
}
]
}"#,
)
.unwrap();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)
.unwrap();
let config = load_config(tmp.path(), tmp.path()).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[])
.unwrap()
.marketplaces;
let marketplace = marketplaces
.into_iter()
.find(|marketplace| marketplace.name == "debug")
.expect("installed marketplace should be listed");
assert_eq!(
marketplace.path,
AbsolutePathBuf::try_from(marketplace_root.join(".agents/plugins/marketplace.json"))
.unwrap()
);
assert_eq!(marketplace.plugins.len(), 1);
assert_eq!(marketplace.plugins[0].id, "sample@debug");
assert_eq!(
marketplace.plugins[0].source,
MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(plugin_root).unwrap(),
}
);
}
#[tokio::test]
async fn list_marketplaces_uses_config_when_known_registry_is_malformed() {
let tmp = tempfile::tempdir().unwrap();
let marketplace_root = marketplace_install_root(tmp.path()).join("debug");
let plugin_root = marketplace_root.join("plugins/sample");
let registry_path = tmp.path().join(".tmp/known_marketplaces.json");
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[marketplaces.debug]
last_updated = "2026-04-10T12:34:56Z"
source_type = "git"
source = "/tmp/debug"
"#,
);
fs::create_dir_all(marketplace_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
fs::write(
marketplace_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
}
}
]
}"#,
)
.unwrap();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)
.unwrap();
fs::create_dir_all(registry_path.parent().unwrap()).unwrap();
fs::write(registry_path, "{not valid json").unwrap();
let config = load_config(tmp.path(), tmp.path()).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[])
.unwrap()
.marketplaces;
let marketplace = marketplaces
.into_iter()
.find(|marketplace| marketplace.name == "debug")
.expect("configured marketplace should be discovered");
assert_eq!(marketplace.plugins[0].id, "sample@debug");
}
#[tokio::test]
async fn list_marketplaces_ignores_installed_roots_missing_from_config() {
let tmp = tempfile::tempdir().unwrap();
let marketplace_root = marketplace_install_root(tmp.path()).join("debug");
let plugin_root = marketplace_root.join("plugins/sample");
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
fs::create_dir_all(marketplace_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
fs::write(
marketplace_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
}
}
]
}"#,
)
.unwrap();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)
.unwrap();
let config = load_config(tmp.path(), tmp.path()).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[])
.unwrap()
.marketplaces;
assert!(marketplaces.is_empty());
}
#[tokio::test]
async fn list_marketplaces_uses_first_duplicate_plugin_entry() {
let tmp = tempfile::tempdir().unwrap();

View File

@@ -211,6 +211,17 @@ pub fn list_marketplaces(
list_marketplaces_with_home(additional_roots, home_dir().as_deref())
}
pub fn validate_marketplace_root(root: &Path) -> Result<String, MarketplaceError> {
let path = AbsolutePathBuf::try_from(root.join(MARKETPLACE_RELATIVE_PATH)).map_err(|err| {
MarketplaceError::InvalidMarketplaceFile {
path: root.join(MARKETPLACE_RELATIVE_PATH),
message: format!("marketplace path must resolve to an absolute path: {err}"),
}
})?;
let marketplace = load_marketplace(&path)?;
Ok(marketplace.name)
}
pub(crate) fn load_marketplace(path: &AbsolutePathBuf) -> Result<Marketplace, MarketplaceError> {
let marketplace = load_raw_marketplace_manifest(path)?;
let mut plugins = Vec::new();

View File

@@ -2,6 +2,7 @@ use codex_config::types::McpServerConfig;
mod discoverable;
mod injection;
mod installed_marketplaces;
mod manager;
mod manifest;
mod marketplace;
@@ -20,12 +21,15 @@ pub use codex_plugin::PluginCapabilitySummary;
pub use codex_plugin::PluginId;
pub use codex_plugin::PluginIdError;
pub use codex_plugin::PluginTelemetryMetadata;
pub use codex_plugin::validate_plugin_segment;
pub type LoadedPlugin = codex_plugin::LoadedPlugin<McpServerConfig>;
pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome<McpServerConfig>;
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
pub(crate) use injection::build_plugin_injections;
pub use installed_marketplaces::INSTALLED_MARKETPLACES_DIR;
pub use installed_marketplaces::marketplace_install_root;
pub use manager::ConfiguredMarketplace;
pub use manager::ConfiguredMarketplaceListOutcome;
pub use manager::ConfiguredMarketplacePlugin;
@@ -53,6 +57,7 @@ pub use marketplace::MarketplacePluginAuthPolicy;
pub use marketplace::MarketplacePluginInstallPolicy;
pub use marketplace::MarketplacePluginPolicy;
pub use marketplace::MarketplacePluginSource;
pub use marketplace::validate_marketplace_root;
pub use remote::RemotePluginFetchError;
pub use remote::fetch_remote_featured_plugin_ids;
pub(crate) use render::render_explicit_plugin_instructions;