From 8f29fbf19e41d9f42d415c49a839ee02c489d078 Mon Sep 17 00:00:00 2001 From: xli-oai Date: Fri, 10 Apr 2026 21:25:43 -0700 Subject: [PATCH] Auto-upgrade configured marketplaces --- .../app-server/src/codex_message_processor.rs | 1 + codex-rs/cli/src/marketplace_cmd.rs | 1 + codex-rs/config/src/marketplace_edit.rs | 4 + codex-rs/config/src/types.rs | 3 + codex-rs/core/config.schema.json | 5 + codex-rs/core/src/plugins/manager.rs | 82 ++ .../core/src/plugins/marketplace_upgrade.rs | 719 ++++++++++++++++++ codex-rs/core/src/plugins/mod.rs | 1 + 8 files changed, 816 insertions(+) create mode 100644 codex-rs/core/src/plugins/marketplace_upgrade.rs diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index d0b835280c..9a9d4dd9ce 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6349,6 +6349,7 @@ impl CodexMessageProcessor { return; } }; + plugins_manager.maybe_start_configured_marketplace_upgrade_for_config(&config); let mut remote_sync_error = None; let auth = self.auth_manager.auth().await; diff --git a/codex-rs/cli/src/marketplace_cmd.rs b/codex-rs/cli/src/marketplace_cmd.rs index 6a898c9ad6..cfe0c69b5f 100644 --- a/codex-rs/cli/src/marketplace_cmd.rs +++ b/codex-rs/cli/src/marketplace_cmd.rs @@ -186,6 +186,7 @@ fn record_added_marketplace( let last_updated = utc_timestamp_now()?; let update = MarketplaceConfigUpdate { last_updated: &last_updated, + last_revision: None, source_type: install_metadata.config_source_type(), source: &source, ref_name: install_metadata.ref_name(), diff --git a/codex-rs/config/src/marketplace_edit.rs b/codex-rs/config/src/marketplace_edit.rs index 33cdd8e163..e20a75a02e 100644 --- a/codex-rs/config/src/marketplace_edit.rs +++ b/codex-rs/config/src/marketplace_edit.rs @@ -12,6 +12,7 @@ use crate::CONFIG_TOML_FILE; pub struct MarketplaceConfigUpdate<'a> { pub last_updated: &'a str, + pub last_revision: Option<&'a str>, pub source_type: &'a str, pub source: &'a str, pub ref_name: Option<&'a str>, @@ -63,6 +64,9 @@ fn upsert_marketplace( let mut entry = TomlTable::new(); entry.set_implicit(false); entry["last_updated"] = value(update.last_updated.to_string()); + if let Some(last_revision) = update.last_revision { + entry["last_revision"] = value(last_revision.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 { diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 64cfe85d08..2c2045adf3 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -614,6 +614,9 @@ pub struct MarketplaceConfig { /// Last time Codex successfully added or refreshed this marketplace. #[serde(default)] pub last_updated: Option, + /// Git revision Codex last successfully activated for this marketplace. + #[serde(default)] + pub last_revision: Option, /// Source kind used to install this marketplace. #[serde(default)] pub source_type: Option, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index f47fee612a..9e44033e88 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -770,6 +770,11 @@ "MarketplaceConfig": { "additionalProperties": false, "properties": { + "last_revision": { + "default": null, + "description": "Git revision Codex last successfully activated for this marketplace.", + "type": "string" + }, "last_updated": { "default": null, "description": "Last time Codex successfully added or refreshed this marketplace.", diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index bfae2edf28..0288ef7c7e 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -15,6 +15,7 @@ use super::marketplace::ResolvedMarketplacePlugin; use super::marketplace::list_marketplaces; use super::marketplace::load_marketplace; use super::marketplace::resolve_marketplace_plugin; +use super::marketplace_upgrade::upgrade_configured_git_marketplaces; use super::read_curated_plugins_sha; use super::remote::RemotePluginFetchError; use super::remote::RemotePluginMutationError; @@ -108,6 +109,12 @@ struct NonCuratedCacheRefreshState { in_flight: bool, } +#[derive(Default)] +struct ConfiguredMarketplaceUpgradeState { + in_flight: bool, + completed: bool, +} + fn featured_plugin_ids_cache_key( config: &Config, auth: Option<&CodexAuth>, @@ -321,6 +328,7 @@ pub struct PluginsManager { codex_home: PathBuf, store: PluginStore, featured_plugin_ids_cache: RwLock>, + configured_marketplace_upgrade_state: RwLock, non_curated_cache_refresh_state: RwLock, cached_enabled_outcome: RwLock>, remote_sync_lock: Mutex<()>, @@ -348,6 +356,9 @@ impl PluginsManager { codex_home: codex_home.clone(), store: PluginStore::new(codex_home), featured_plugin_ids_cache: RwLock::new(None), + configured_marketplace_upgrade_state: RwLock::new( + ConfiguredMarketplaceUpgradeState::default(), + ), non_curated_cache_refresh_state: RwLock::new(NonCuratedCacheRefreshState::default()), cached_enabled_outcome: RwLock::new(None), remote_sync_lock: Mutex::new(()), @@ -1062,6 +1073,7 @@ impl PluginsManager { ) { if config.features.enabled(Feature::Plugins) { self.start_curated_repo_sync(); + self.maybe_start_configured_marketplace_upgrade_for_config(config); start_startup_remote_plugin_sync_once( Arc::clone(self), self.codex_home.clone(), @@ -1086,6 +1098,76 @@ impl PluginsManager { } } + pub fn maybe_start_configured_marketplace_upgrade_for_config( + self: &Arc, + config: &Config, + ) { + if !config.features.enabled(Feature::Plugins) { + return; + } + + let should_spawn = { + let mut state = match self.configured_marketplace_upgrade_state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + if state.completed || state.in_flight { + return; + } + state.in_flight = true; + true + }; + if !should_spawn { + return; + } + + let manager = Arc::clone(self); + let codex_home = self.codex_home.clone(); + let config = config.clone(); + if let Err(err) = std::thread::Builder::new() + .name("plugins-marketplace-auto-upgrade".to_string()) + .spawn(move || { + let outcome = upgrade_configured_git_marketplaces(codex_home.as_path(), &config); + let cache_refresh_succeeded = if outcome.upgraded_roots.is_empty() { + true + } else { + match refresh_non_curated_plugin_cache( + codex_home.as_path(), + &outcome.upgraded_roots, + ) { + Ok(cache_refreshed) => { + if cache_refreshed { + manager.clear_cache(); + } + true + } + Err(err) => { + manager.clear_cache(); + warn!( + "failed to refresh non-curated plugin cache after marketplace auto-upgrade: {err}" + ); + false + } + } + }; + + let mut state = match manager.configured_marketplace_upgrade_state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + state.in_flight = false; + state.completed = outcome.all_succeeded && cache_refresh_succeeded; + }) + { + let mut state = match self.configured_marketplace_upgrade_state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + state.in_flight = false; + warn!("failed to start configured marketplace auto-upgrade task: {err}"); + } + } + pub fn maybe_start_non_curated_plugin_cache_refresh_for_roots( self: &Arc, roots: &[AbsolutePathBuf], diff --git a/codex-rs/core/src/plugins/marketplace_upgrade.rs b/codex-rs/core/src/plugins/marketplace_upgrade.rs new file mode 100644 index 0000000000..98e4ab990f --- /dev/null +++ b/codex-rs/core/src/plugins/marketplace_upgrade.rs @@ -0,0 +1,719 @@ +use super::installed_marketplaces::marketplace_install_root; +use super::validate_marketplace_root; +use codex_config::MarketplaceConfigUpdate; +use codex_config::record_user_marketplace; +use codex_config::types::MarketplaceConfig; +use codex_config::types::MarketplaceSourceType; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::path::Path; +use std::process::Command; +use std::process::Output; +use std::process::Stdio; +use std::time::Duration; +use tempfile::TempDir; +use tracing::warn; + +use crate::config::Config; + +const MARKETPLACE_UPGRADE_GIT_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Default, PartialEq, Eq)] +pub(super) struct ConfiguredMarketplaceUpgradeOutcome { + pub upgraded_roots: Vec, + pub all_succeeded: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ConfiguredGitMarketplace { + name: String, + source: String, + ref_name: Option, + sparse_paths: Vec, + last_revision: Option, +} + +pub(super) fn upgrade_configured_git_marketplaces( + codex_home: &Path, + config: &Config, +) -> ConfiguredMarketplaceUpgradeOutcome { + let marketplaces = configured_git_marketplaces(config); + if marketplaces.is_empty() { + return ConfiguredMarketplaceUpgradeOutcome { + all_succeeded: true, + ..Default::default() + }; + } + + let install_root = marketplace_install_root(codex_home); + let mut upgraded_roots = Vec::new(); + let mut all_succeeded = true; + for marketplace in marketplaces { + match upgrade_configured_git_marketplace(codex_home, &install_root, &marketplace) { + Ok(Some(upgraded_root)) => upgraded_roots.push(upgraded_root), + Ok(None) => {} + Err(err) => { + all_succeeded = false; + warn!( + marketplace = marketplace.name, + source = marketplace.source, + error = %err, + "failed to auto-upgrade configured marketplace" + ); + } + } + } + + ConfiguredMarketplaceUpgradeOutcome { + upgraded_roots, + all_succeeded, + } +} + +fn configured_git_marketplaces(config: &Config) -> Vec { + 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 marketplaces = match marketplaces_value + .clone() + .try_into::>() + { + Ok(marketplaces) => marketplaces, + Err(err) => { + warn!("invalid marketplaces config while preparing auto-upgrade: {err}"); + return Vec::new(); + } + }; + + let mut configured = marketplaces + .into_iter() + .filter_map(|(name, marketplace)| { + let MarketplaceConfig { + last_updated: _, + last_revision, + source_type, + source, + ref_name, + sparse_paths, + } = marketplace; + if source_type != Some(MarketplaceSourceType::Git) { + return None; + } + let Some(source) = source else { + warn!( + marketplace = name, + "ignoring configured Git marketplace without source" + ); + return None; + }; + Some(ConfiguredGitMarketplace { + name, + source, + ref_name, + sparse_paths: sparse_paths.unwrap_or_default(), + last_revision, + }) + }) + .collect::>(); + configured.sort_unstable_by(|left, right| left.name.cmp(&right.name)); + configured +} + +fn upgrade_configured_git_marketplace( + codex_home: &Path, + install_root: &Path, + marketplace: &ConfiguredGitMarketplace, +) -> Result, String> { + super::validate_plugin_segment(&marketplace.name, "marketplace name")?; + let remote_revision = git_remote_revision( + &marketplace.source, + marketplace.ref_name.as_deref(), + MARKETPLACE_UPGRADE_GIT_TIMEOUT, + )?; + let destination = install_root.join(&marketplace.name); + if destination + .join(".agents/plugins/marketplace.json") + .is_file() + && marketplace.last_revision.as_deref() == Some(remote_revision.as_str()) + { + return Ok(None); + } + + let staging_parent = install_root.join(".staging"); + std::fs::create_dir_all(&staging_parent).map_err(|err| { + format!( + "failed to create marketplace upgrade staging directory {}: {err}", + staging_parent.display() + ) + })?; + let staged_dir = tempfile::Builder::new() + .prefix("marketplace-upgrade-") + .tempdir_in(&staging_parent) + .map_err(|err| { + format!( + "failed to create temporary marketplace upgrade directory in {}: {err}", + staging_parent.display() + ) + })?; + + clone_git_source( + &marketplace.source, + marketplace.ref_name.as_deref(), + &marketplace.sparse_paths, + staged_dir.path(), + MARKETPLACE_UPGRADE_GIT_TIMEOUT, + )?; + let marketplace_name = validate_marketplace_root(staged_dir.path()) + .map_err(|err| format!("failed to validate upgraded marketplace root: {err}"))?; + if marketplace_name != marketplace.name { + return Err(format!( + "upgraded marketplace name `{marketplace_name}` does not match configured marketplace `{}`", + marketplace.name + )); + } + + let last_updated = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let update = MarketplaceConfigUpdate { + last_updated: &last_updated, + last_revision: Some(&remote_revision), + source_type: "git", + source: &marketplace.source, + ref_name: marketplace.ref_name.as_deref(), + sparse_paths: &marketplace.sparse_paths, + }; + activate_marketplace_root(&destination, staged_dir, || { + record_user_marketplace(codex_home, &marketplace.name, &update).map_err(|err| { + format!( + "failed to record upgraded marketplace `{}` in user config.toml: {err}", + marketplace.name + ) + }) + })?; + + AbsolutePathBuf::try_from(destination) + .map(Some) + .map_err(|err| format!("upgraded marketplace path is not absolute: {err}")) +} + +fn git_remote_revision( + source: &str, + ref_name: Option<&str>, + timeout: Duration, +) -> Result { + if let Some(ref_name) = ref_name + && is_full_git_sha(ref_name) + { + return Ok(ref_name.to_string()); + } + + let ref_name = ref_name.unwrap_or("HEAD"); + let output = run_git_command_with_timeout( + Command::new("git") + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("ls-remote") + .arg(source) + .arg(ref_name), + "git ls-remote marketplace source", + timeout, + )?; + ensure_git_success(&output, "git ls-remote marketplace source")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let Some(first_line) = stdout.lines().next() else { + return Err("git ls-remote returned empty output for marketplace source".to_string()); + }; + let Some((revision, _)) = first_line.split_once('\t') else { + return Err(format!( + "unexpected git ls-remote output for marketplace source: {first_line}" + )); + }; + let revision = revision.trim(); + if revision.is_empty() { + return Err("git ls-remote returned empty revision for marketplace source".to_string()); + } + Ok(revision.to_string()) +} + +fn is_full_git_sha(value: &str) -> bool { + value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit()) +} + +fn clone_git_source( + source: &str, + ref_name: Option<&str>, + sparse_paths: &[String], + destination: &Path, + timeout: Duration, +) -> Result<(), String> { + if sparse_paths.is_empty() { + let output = run_git_command_with_timeout( + Command::new("git") + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("clone") + .arg(source) + .arg(destination), + "git clone marketplace source", + timeout, + )?; + ensure_git_success(&output, "git clone marketplace source")?; + if let Some(ref_name) = ref_name { + let output = run_git_command_with_timeout( + Command::new("git") + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("-C") + .arg(destination) + .arg("checkout") + .arg(ref_name), + "git checkout marketplace ref", + timeout, + )?; + ensure_git_success(&output, "git checkout marketplace ref")?; + } + return Ok(()); + } + + let output = run_git_command_with_timeout( + Command::new("git") + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("clone") + .arg("--filter=blob:none") + .arg("--no-checkout") + .arg(source) + .arg(destination), + "git clone marketplace source", + timeout, + )?; + ensure_git_success(&output, "git clone marketplace source")?; + + let mut sparse_checkout = Command::new("git"); + sparse_checkout + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("-C") + .arg(destination) + .arg("sparse-checkout") + .arg("set") + .args(sparse_paths); + let output = run_git_command_with_timeout( + &mut sparse_checkout, + "git sparse-checkout marketplace source", + timeout, + )?; + ensure_git_success(&output, "git sparse-checkout marketplace source")?; + + let output = run_git_command_with_timeout( + Command::new("git") + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("-C") + .arg(destination) + .arg("checkout") + .arg(ref_name.unwrap_or("HEAD")), + "git checkout marketplace ref", + timeout, + )?; + ensure_git_success(&output, "git checkout marketplace ref") +} + +fn activate_marketplace_root( + destination: &Path, + staged_dir: TempDir, + after_activate: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + let staged_root = staged_dir.path(); + let Some(parent) = destination.parent() else { + return Err(format!( + "failed to determine marketplace install parent for {}", + destination.display() + )); + }; + std::fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create marketplace install parent {}: {err}", + parent.display() + ) + })?; + + if destination.exists() { + let backup_dir = tempfile::Builder::new() + .prefix("marketplace-backup-") + .tempdir_in(parent) + .map_err(|err| { + format!( + "failed to create marketplace backup directory in {}: {err}", + parent.display() + ) + })?; + let backup_root = backup_dir.path().join("root"); + std::fs::rename(destination, &backup_root).map_err(|err| { + format!( + "failed to move previous marketplace root out of the way at {}: {err}", + destination.display() + ) + })?; + + if let Err(err) = std::fs::rename(staged_root, destination) { + let rollback_result = std::fs::rename(&backup_root, destination); + return match rollback_result { + Ok(()) => Err(format!( + "failed to activate upgraded marketplace at {}: {err}", + destination.display() + )), + Err(rollback_err) => { + let backup_path = backup_dir.keep().join("root"); + Err(format!( + "failed to activate upgraded marketplace at {}: {err}; failed to restore previous marketplace root (left at {}): {rollback_err}", + destination.display(), + backup_path.display() + )) + } + }; + } + + if let Err(err) = after_activate() { + let remove_result = std::fs::remove_dir_all(destination); + let rollback_result = + remove_result.and_then(|()| std::fs::rename(&backup_root, destination)); + return match rollback_result { + Ok(()) => Err(err), + Err(rollback_err) => { + let backup_path = backup_dir.keep().join("root"); + Err(format!( + "{err}; failed to restore previous marketplace root at {} (left at {}): {rollback_err}", + destination.display(), + backup_path.display() + )) + } + }; + } + } else { + std::fs::rename(staged_root, destination).map_err(|err| { + format!( + "failed to activate upgraded marketplace at {}: {err}", + destination.display() + ) + })?; + if let Err(err) = after_activate() { + let remove_result = std::fs::remove_dir_all(destination); + return match remove_result { + Ok(()) => Err(err), + Err(remove_err) => Err(format!( + "{err}; failed to remove newly activated marketplace root at {}: {remove_err}", + destination.display() + )), + }; + } + } + + Ok(()) +} + +fn run_git_command_with_timeout( + command: &mut Command, + context: &str, + timeout: Duration, +) -> Result { + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| format!("failed to run {context}: {err}"))?; + let start = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => { + return child + .wait_with_output() + .map_err(|err| format!("failed to wait for {context}: {err}")); + } + Ok(None) => {} + Err(err) => return Err(format!("failed to poll {context}: {err}")), + } + + if start.elapsed() >= timeout { + let _ = child.kill(); + let output = child + .wait_with_output() + .map_err(|err| format!("failed to wait for {context} after timeout: {err}"))?; + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return if stderr.is_empty() { + Err(format!("{context} timed out after {}s", timeout.as_secs())) + } else { + Err(format!( + "{context} timed out after {}s: {stderr}", + timeout.as_secs() + )) + }; + } + + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn ensure_git_success(output: &Output, context: &str) -> Result<(), String> { + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + Err(format!("{context} failed with status {}", output.status)) + } else { + Err(format!( + "{context} failed with status {}: {stderr}", + output.status + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::CONFIG_TOML_FILE; + use crate::plugins::test_support::load_plugins_config; + use crate::plugins::test_support::write_file; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[tokio::test] + async fn upgrade_configured_git_marketplace_installs_new_revision() { + let codex_home = TempDir::new().unwrap(); + let source_repo = TempDir::new().unwrap(); + write_marketplace_repo(source_repo.path(), "debug", "new"); + init_git_repo(source_repo.path()); + let revision = git_output(source_repo.path(), &["rev-parse", "HEAD"]); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &marketplace_config(source_repo.path(), "old-revision"), + ); + + let config = load_plugins_config(codex_home.path()).await; + let outcome = upgrade_configured_git_marketplaces(codex_home.path(), &config); + + assert_eq!( + outcome, + ConfiguredMarketplaceUpgradeOutcome { + upgraded_roots: vec![ + AbsolutePathBuf::try_from( + marketplace_install_root(codex_home.path()).join("debug") + ) + .unwrap() + ], + all_succeeded: true, + } + ); + assert_eq!( + std::fs::read_to_string( + marketplace_install_root(codex_home.path()).join("debug/plugins/sample/marker.txt") + ) + .unwrap(), + "new" + ); + let config = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).unwrap(); + assert!(config.contains(&format!(r#"last_revision = "{revision}""#))); + } + + #[tokio::test] + async fn upgrade_configured_git_marketplace_skips_matching_revision() { + let codex_home = TempDir::new().unwrap(); + let source_repo = TempDir::new().unwrap(); + write_marketplace_repo(source_repo.path(), "debug", "new"); + init_git_repo(source_repo.path()); + let revision = git_output(source_repo.path(), &["rev-parse", "HEAD"]); + let installed_root = marketplace_install_root(codex_home.path()).join("debug"); + write_marketplace_repo(&installed_root, "debug", "old"); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &marketplace_config(source_repo.path(), &revision), + ); + + let config = load_plugins_config(codex_home.path()).await; + let outcome = upgrade_configured_git_marketplaces(codex_home.path(), &config); + + assert_eq!( + outcome, + ConfiguredMarketplaceUpgradeOutcome { + upgraded_roots: Vec::new(), + all_succeeded: true, + } + ); + assert_eq!( + std::fs::read_to_string(installed_root.join("plugins/sample/marker.txt")).unwrap(), + "old" + ); + } + + #[tokio::test] + async fn upgrade_configured_git_marketplace_keeps_existing_root_on_name_mismatch() { + let codex_home = TempDir::new().unwrap(); + let source_repo = TempDir::new().unwrap(); + write_marketplace_repo(source_repo.path(), "other", "new"); + init_git_repo(source_repo.path()); + let installed_root = marketplace_install_root(codex_home.path()).join("debug"); + write_marketplace_repo(&installed_root, "debug", "old"); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &marketplace_config(source_repo.path(), "old-revision"), + ); + + let config = load_plugins_config(codex_home.path()).await; + let outcome = upgrade_configured_git_marketplaces(codex_home.path(), &config); + + assert_eq!( + outcome, + ConfiguredMarketplaceUpgradeOutcome { + upgraded_roots: Vec::new(), + all_succeeded: false, + } + ); + assert_eq!( + std::fs::read_to_string(installed_root.join("plugins/sample/marker.txt")).unwrap(), + "old" + ); + } + + #[tokio::test] + async fn upgrade_configured_git_marketplace_keeps_existing_root_on_git_failure() { + let codex_home = TempDir::new().unwrap(); + let missing_repo = codex_home.path().join("missing-repo"); + let installed_root = marketplace_install_root(codex_home.path()).join("debug"); + write_marketplace_repo(&installed_root, "debug", "old"); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &marketplace_config(&missing_repo, "old-revision"), + ); + + let config = load_plugins_config(codex_home.path()).await; + let outcome = upgrade_configured_git_marketplaces(codex_home.path(), &config); + + assert_eq!( + outcome, + ConfiguredMarketplaceUpgradeOutcome { + upgraded_roots: Vec::new(), + all_succeeded: false, + } + ); + assert_eq!( + std::fs::read_to_string(installed_root.join("plugins/sample/marker.txt")).unwrap(), + "old" + ); + } + + #[tokio::test] + async fn upgrade_configured_git_marketplaces_ignores_local_unconfigured_marketplace() { + let codex_home = TempDir::new().unwrap(); + write_marketplace_repo(codex_home.path(), "local", "local"); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); + + let config = load_plugins_config(codex_home.path()).await; + let outcome = upgrade_configured_git_marketplaces(codex_home.path(), &config); + + assert_eq!( + outcome, + ConfiguredMarketplaceUpgradeOutcome { + upgraded_roots: Vec::new(), + all_succeeded: true, + } + ); + assert!( + !marketplace_install_root(codex_home.path()) + .join("local") + .exists() + ); + } + + #[test] + fn full_git_sha_ref_is_already_a_remote_revision() { + assert!(is_full_git_sha("0123456789abcdef0123456789abcdef01234567")); + assert!(!is_full_git_sha("main")); + assert!(!is_full_git_sha("0123456")); + } + + fn marketplace_config(source_repo: &Path, last_revision: &str) -> String { + format!( + r#"[features] +plugins = true + +[marketplaces.debug] +last_updated = "2026-04-10T00:00:00Z" +last_revision = "{last_revision}" +source_type = "git" +source = "{}" +"#, + source_repo.display() + ) + } + + fn write_marketplace_repo(root: &Path, marketplace_name: &str, marker: &str) { + write_file( + &root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "{marketplace_name}", + "plugins": [ + {{ + "name": "sample", + "source": {{ + "source": "local", + "path": "./plugins/sample" + }} + }} + ] +}}"# + ), + ); + write_file( + &root.join("plugins/sample/.codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file(&root.join("plugins/sample/marker.txt"), marker); + } + + fn init_git_repo(repo: &Path) { + git(repo, &["init"]); + git(repo, &["config", "user.email", "codex-test@example.com"]); + git(repo, &["config", "user.name", "Codex Test"]); + git(repo, &["add", "."]); + git(repo, &["commit", "-m", "initial marketplace"]); + } + + fn git(repo: &Path, args: &[&str]) { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output() + .expect("git should run"); + assert!( + output.status.success(), + "git -C {} {} failed\nstdout:\n{}\nstderr:\n{}", + repo.display(), + args.join(" "), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + fn git_output(repo: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output() + .expect("git should run"); + assert!( + output.status.success(), + "git -C {} {} failed\nstdout:\n{}\nstderr:\n{}", + repo.display(), + args.join(" "), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_string() + } +} diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 5115c3f7ea..294ce68e05 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -6,6 +6,7 @@ mod installed_marketplaces; mod manager; mod manifest; mod marketplace; +mod marketplace_upgrade; mod mentions; mod remote; mod render;