Move marketplace add/remove and startup sync out of core. (#19099)

Move more things to core-plugins.

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
xl-openai
2026-04-23 11:27:17 -07:00
committed by GitHub
Unverified
parent e9165b9f40
commit 198eddd25d
33 changed files with 1800 additions and 1719 deletions
+2 -2
View File
@@ -2,12 +2,12 @@ use anyhow::Context;
use std::collections::HashSet;
use tracing::warn;
use super::OPENAI_BUNDLED_MARKETPLACE_NAME;
use super::OPENAI_CURATED_MARKETPLACE_NAME;
use super::PluginCapabilitySummary;
use super::PluginsManager;
use crate::config::Config;
use codex_config::types::ToolSuggestDiscoverableType;
use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME;
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_features::Feature;
use codex_tools::DiscoverablePluginInfo;
@@ -6,6 +6,7 @@ use crate::plugins::test_support::write_curated_plugin_sha;
use crate::plugins::test_support::write_file;
use crate::plugins::test_support::write_openai_curated_marketplace;
use crate::plugins::test_support::write_plugins_feature_config;
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
use codex_tools::DiscoverablePluginInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
@@ -17,7 +18,7 @@ use tracing_test::internal::MockWriter;
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plugins() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["sample", "slack"]);
write_plugins_feature_config(codex_home.path());
@@ -102,7 +103,7 @@ discoverables = [{{ type = "plugin", id = "{plugin_id}" }}]
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_ignores_missing_allowlisted_plugin() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
let marketplace_name = TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST
.iter()
@@ -153,7 +154,7 @@ source = "/tmp/{marketplace_name}"
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_feature_disabled() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
write_file(
&codex_home.path().join(crate::config::CONFIG_TOML_FILE),
@@ -173,7 +174,7 @@ plugins = false
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_normalizes_description() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
write_plugins_feature_config(codex_home.path());
write_file(
@@ -205,7 +206,7 @@ async fn list_tool_suggest_discoverable_plugins_normalizes_description() {
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
write_curated_plugin_sha(codex_home.path());
write_plugins_feature_config(codex_home.path());
@@ -232,7 +233,7 @@ async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins(
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_includes_configured_plugin_ids() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["sample"]);
write_file(
&codex_home.path().join(crate::config::CONFIG_TOML_FILE),
@@ -267,7 +268,7 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }]
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_plugin() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(
&curated_root,
&["slack", "build-ios-apps", "life-science-research"],
@@ -1,75 +0,0 @@
use crate::config::Config;
use codex_core_plugins::marketplace::find_marketplace_manifest_path;
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 = resolve_configured_marketplace_root(
marketplace_name,
marketplace,
&default_install_root,
)?;
find_marketplace_manifest_path(&path).map(|_| 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
}
pub(crate) fn resolve_configured_marketplace_root(
marketplace_name: &str,
marketplace: &toml::Value,
default_install_root: &Path,
) -> Option<PathBuf> {
match marketplace.get("source_type").and_then(toml::Value::as_str) {
Some("local") => marketplace
.get("source")
.and_then(toml::Value::as_str)
.filter(|source| !source.is_empty())
.map(PathBuf::from),
_ => Some(default_install_root.join(marketplace_name)),
}
}
+7 -8
View File
@@ -1,9 +1,5 @@
use super::PluginLoadOutcome;
use super::curated_plugins_repo_path;
use super::installed_marketplaces::installed_marketplace_roots_from_config;
use super::read_curated_plugins_sha;
use super::startup_sync::start_startup_remote_plugin_sync_once;
use super::sync_openai_plugins_repo;
use crate::SkillMetadata;
use crate::config::Config;
use crate::config::edit::ConfigEdit;
@@ -11,6 +7,8 @@ use crate::config::edit::ConfigEditsBuilder;
use crate::config_loader::ConfigLayerStack;
use codex_analytics::AnalyticsEventsClient;
use codex_config::types::PluginConfig;
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core_plugins::installed_marketplaces::installed_marketplace_roots_from_layer_stack;
use codex_core_plugins::loader::configured_curated_plugin_ids_from_codex_home;
use codex_core_plugins::loader::installed_plugin_telemetry_metadata;
use codex_core_plugins::loader::load_plugin_apps;
@@ -44,6 +42,9 @@ use codex_core_plugins::marketplace_upgrade::upgrade_configured_git_marketplaces
use codex_core_plugins::remote::RemotePluginServiceConfig;
use codex_core_plugins::remote_legacy::RemotePluginFetchError;
use codex_core_plugins::remote_legacy::RemotePluginMutationError;
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
use codex_core_plugins::startup_sync::read_curated_plugins_sha;
use codex_core_plugins::startup_sync::sync_openai_plugins_repo;
use codex_core_plugins::store::PluginInstallResult as StorePluginInstallResult;
use codex_core_plugins::store::PluginStore;
use codex_core_plugins::store::PluginStoreError;
@@ -70,8 +71,6 @@ use toml_edit::value;
use tracing::info;
use tracing::warn;
pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
pub const OPENAI_BUNDLED_MARKETPLACE_NAME: &str = "openai-bundled";
static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false);
const FEATURED_PLUGIN_IDS_CACHE_TTL: std::time::Duration =
std::time::Duration::from_secs(60 * 60 * 3);
@@ -1466,8 +1465,8 @@ impl PluginsManager {
// 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,
roots.extend(installed_marketplace_roots_from_layer_stack(
&config.config_layer_stack,
self.codex_home.as_path(),
));
let curated_repo_root = curated_plugins_repo_path(self.codex_home.as_path());
+2 -1
View File
@@ -7,7 +7,6 @@ use crate::config_loader::ConfigRequirements;
use crate::config_loader::ConfigRequirementsToml;
use crate::plugins::LoadedPlugin;
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;
@@ -15,9 +14,11 @@ use crate::plugins::test_support::write_openai_curated_marketplace;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::McpServerConfig;
use codex_config::types::McpServerTransportConfig;
use codex_core_plugins::installed_marketplaces::marketplace_install_root;
use codex_core_plugins::loader::refresh_non_curated_plugin_cache;
use codex_core_plugins::loader::refresh_non_curated_plugin_cache_force_reinstall;
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
use codex_login::CodexAuth;
use codex_protocol::protocol::Product;
use codex_utils_absolute_path::test_support::PathBufExt;
@@ -1,407 +0,0 @@
use super::OPENAI_CURATED_MARKETPLACE_NAME;
use super::marketplace_install_root;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use tempfile::Builder;
mod install;
mod metadata;
mod source;
use install::clone_git_source;
use install::ensure_marketplace_destination_is_inside_install_root;
use install::marketplace_staging_root;
use install::replace_marketplace_root;
use install::safe_marketplace_dir_name;
use metadata::MarketplaceInstallMetadata;
use metadata::find_marketplace_root_by_name;
use metadata::installed_marketplace_root_for_source;
use metadata::record_added_marketplace_entry;
use source::MarketplaceSource;
pub(crate) use source::parse_marketplace_source;
use source::stage_marketplace_source;
use source::validate_marketplace_source_root;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplaceAddRequest {
pub source: String,
pub ref_name: Option<String>,
pub sparse_paths: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplaceAddOutcome {
pub marketplace_name: String,
pub source_display: String,
pub installed_root: AbsolutePathBuf,
pub already_added: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum MarketplaceAddError {
#[error("{0}")]
InvalidRequest(String),
#[error("{0}")]
Internal(String),
}
pub async fn add_marketplace(
codex_home: PathBuf,
request: MarketplaceAddRequest,
) -> Result<MarketplaceAddOutcome, MarketplaceAddError> {
tokio::task::spawn_blocking(move || add_marketplace_sync(codex_home.as_path(), request))
.await
.map_err(|err| MarketplaceAddError::Internal(format!("failed to add marketplace: {err}")))?
}
pub fn is_local_marketplace_source(
source: &str,
explicit_ref: Option<String>,
) -> Result<bool, MarketplaceAddError> {
Ok(matches!(
parse_marketplace_source(source, explicit_ref)?,
source::MarketplaceSource::Local { .. }
))
}
fn add_marketplace_sync(
codex_home: &Path,
request: MarketplaceAddRequest,
) -> Result<MarketplaceAddOutcome, MarketplaceAddError> {
add_marketplace_sync_with_cloner(codex_home, request, clone_git_source)
}
fn add_marketplace_sync_with_cloner<F>(
codex_home: &Path,
request: MarketplaceAddRequest,
clone_source: F,
) -> Result<MarketplaceAddOutcome, MarketplaceAddError>
where
F: Fn(&str, Option<&str>, &[String], &Path) -> Result<(), MarketplaceAddError>,
{
let MarketplaceAddRequest {
source,
ref_name,
sparse_paths,
} = request;
let source = parse_marketplace_source(&source, ref_name)?;
if !sparse_paths.is_empty() && !matches!(source, MarketplaceSource::Git { .. }) {
return Err(MarketplaceAddError::InvalidRequest(
"--sparse is only supported for git marketplace sources".to_string(),
));
}
let install_root = marketplace_install_root(codex_home);
fs::create_dir_all(&install_root).map_err(|err| {
MarketplaceAddError::Internal(format!(
"failed to create marketplace install directory {}: {err}",
install_root.display()
))
})?;
let install_metadata = MarketplaceInstallMetadata::from_source(&source, &sparse_paths);
if let Some(existing_root) =
installed_marketplace_root_for_source(codex_home, &install_root, &install_metadata)?
{
let marketplace_name = validate_marketplace_source_root(&existing_root)?;
record_added_marketplace_entry(codex_home, &marketplace_name, &install_metadata)?;
return Ok(MarketplaceAddOutcome {
marketplace_name,
source_display: source.display(),
installed_root: AbsolutePathBuf::try_from(existing_root).map_err(|err| {
MarketplaceAddError::Internal(format!(
"failed to resolve installed marketplace root: {err}"
))
})?,
already_added: true,
});
}
if let MarketplaceSource::Local { path } = &source {
let marketplace_name = validate_marketplace_source_root(path)?;
if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
return Err(MarketplaceAddError::InvalidRequest(format!(
"marketplace '{OPENAI_CURATED_MARKETPLACE_NAME}' is reserved and cannot be added from {}",
source.display()
)));
}
if find_marketplace_root_by_name(codex_home, &install_root, &marketplace_name)?.is_some() {
return Err(MarketplaceAddError::InvalidRequest(format!(
"marketplace '{marketplace_name}' is already added from a different source; remove it before adding {}",
source.display()
)));
}
record_added_marketplace_entry(codex_home, &marketplace_name, &install_metadata)?;
return Ok(MarketplaceAddOutcome {
marketplace_name,
source_display: source.display(),
installed_root: AbsolutePathBuf::try_from(path.clone()).map_err(|err| {
MarketplaceAddError::Internal(format!(
"failed to resolve installed marketplace root: {err}"
))
})?,
already_added: false,
});
}
let staging_root = marketplace_staging_root(&install_root);
fs::create_dir_all(&staging_root).map_err(|err| {
MarketplaceAddError::Internal(format!(
"failed to create marketplace staging directory {}: {err}",
staging_root.display()
))
})?;
let staged_root = Builder::new()
.prefix("marketplace-add-")
.tempdir_in(&staging_root)
.map_err(|err| {
MarketplaceAddError::Internal(format!(
"failed to create temporary marketplace directory in {}: {err}",
staging_root.display()
))
})?;
let staged_root = staged_root.keep();
stage_marketplace_source(&source, &sparse_paths, &staged_root, clone_source)?;
let marketplace_name = validate_marketplace_source_root(&staged_root)?;
if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
return Err(MarketplaceAddError::InvalidRequest(format!(
"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() {
return Err(MarketplaceAddError::InvalidRequest(format!(
"marketplace '{marketplace_name}' is already added from a different source; remove it before adding {}",
source.display()
)));
}
replace_marketplace_root(&staged_root, &destination).map_err(|err| {
MarketplaceAddError::Internal(format!(
"failed to install marketplace at {}: {err}",
destination.display()
))
})?;
if let Err(err) =
record_added_marketplace_entry(codex_home, &marketplace_name, &install_metadata)
{
if let Err(rollback_err) = fs::rename(&destination, &staged_root) {
return Err(MarketplaceAddError::Internal(format!(
"{err}; additionally failed to roll back installed marketplace at {}: {rollback_err}",
destination.display()
)));
}
return Err(err);
}
Ok(MarketplaceAddOutcome {
marketplace_name,
source_display: source.display(),
installed_root: AbsolutePathBuf::try_from(destination).map_err(|err| {
MarketplaceAddError::Internal(format!(
"failed to resolve installed marketplace root: {err}"
))
})?,
already_added: false,
})
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[test]
fn add_marketplace_sync_installs_marketplace_and_updates_config() -> Result<()> {
let codex_home = TempDir::new()?;
let source_root = TempDir::new()?;
write_marketplace_source(source_root.path(), "remote copy")?;
let result = add_marketplace_sync_with_cloner(
codex_home.path(),
MarketplaceAddRequest {
source: "https://github.com/owner/repo.git".to_string(),
ref_name: None,
sparse_paths: Vec::new(),
},
|_url, _ref_name, _sparse_paths, destination| {
copy_dir_all(source_root.path(), destination)
.map_err(|err| MarketplaceAddError::Internal(err.to_string()))
},
)?;
assert_eq!(result.marketplace_name, "debug");
assert_eq!(result.source_display, "https://github.com/owner/repo.git");
assert!(!result.already_added);
assert!(
result
.installed_root
.as_path()
.join(".agents/plugins/marketplace.json")
.is_file()
);
let config = fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE))?;
assert!(config.contains("[marketplaces.debug]"));
assert!(config.contains("source_type = \"git\""));
assert!(config.contains("source = \"https://github.com/owner/repo.git\""));
Ok(())
}
#[test]
fn add_marketplace_sync_installs_local_directory_source_and_updates_config() -> Result<()> {
let codex_home = TempDir::new()?;
let source_root = TempDir::new()?;
write_marketplace_source(source_root.path(), "local copy")?;
let result = add_marketplace_sync_with_cloner(
codex_home.path(),
MarketplaceAddRequest {
source: source_root.path().display().to_string(),
ref_name: None,
sparse_paths: Vec::new(),
},
|_url, _ref_name, _sparse_paths, _destination| {
panic!("git cloner should not be called for local marketplace sources")
},
)?;
let expected_source = source_root.path().canonicalize()?.display().to_string();
assert_eq!(result.marketplace_name, "debug");
assert_eq!(result.source_display, expected_source);
assert_eq!(
result.installed_root.as_path(),
source_root.path().canonicalize()?
);
assert!(!result.already_added);
assert!(
!marketplace_install_root(codex_home.path())
.join("debug")
.exists()
);
let config = fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE))?;
let config: toml::Value = toml::from_str(&config)?;
assert_eq!(
config["marketplaces"]["debug"]["source_type"].as_str(),
Some("local")
);
assert_eq!(
config["marketplaces"]["debug"]["source"].as_str(),
Some(expected_source.as_str())
);
Ok(())
}
#[test]
fn add_marketplace_sync_rejects_sparse_checkout_for_local_directory_source() -> Result<()> {
let codex_home = TempDir::new()?;
let source_root = TempDir::new()?;
write_marketplace_source(source_root.path(), "local copy")?;
let err = add_marketplace_sync_with_cloner(
codex_home.path(),
MarketplaceAddRequest {
source: source_root.path().display().to_string(),
ref_name: None,
sparse_paths: vec![".agents".to_string()],
},
|_url, _ref_name, _sparse_paths, _destination| {
panic!("git cloner should not be called for local marketplace sources")
},
)
.unwrap_err();
assert_eq!(
err.to_string(),
"--sparse is only supported for git marketplace sources"
);
assert!(
!codex_home
.path()
.join(codex_config::CONFIG_TOML_FILE)
.exists()
);
Ok(())
}
#[test]
fn add_marketplace_sync_treats_existing_local_directory_source_as_already_added() -> Result<()>
{
let codex_home = TempDir::new()?;
let source_root = TempDir::new()?;
write_marketplace_source(source_root.path(), "local copy")?;
let request = MarketplaceAddRequest {
source: source_root.path().display().to_string(),
ref_name: None,
sparse_paths: Vec::new(),
};
let first_result = add_marketplace_sync_with_cloner(codex_home.path(), request.clone(), {
|_url, _ref_name, _sparse_paths, _destination| {
panic!("git cloner should not be called for local marketplace sources")
}
})?;
let second_result = add_marketplace_sync_with_cloner(codex_home.path(), request, {
|_url, _ref_name, _sparse_paths, _destination| {
panic!("git cloner should not be called for local marketplace sources")
}
})?;
assert!(!first_result.already_added);
assert!(second_result.already_added);
assert_eq!(second_result.installed_root, first_result.installed_root);
Ok(())
}
fn write_marketplace_source(source: &Path, marker: &str) -> std::io::Result<()> {
fs::create_dir_all(source.join(".agents/plugins"))?;
fs::create_dir_all(source.join("plugins/sample/.codex-plugin"))?;
fs::write(
source.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
}
}
]
}"#,
)?;
fs::write(
source.join("plugins/sample/.codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)?;
fs::write(source.join("plugins/sample/marker.txt"), marker)?;
Ok(())
}
fn copy_dir_all(source: &Path, destination: &Path) -> std::io::Result<()> {
fs::create_dir_all(destination)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
let source_path = entry.path();
let destination_path = destination.join(entry.file_name());
if source_path.is_dir() {
copy_dir_all(&source_path, &destination_path)?;
} else {
fs::copy(&source_path, &destination_path)?;
}
}
Ok(())
}
}
@@ -1,137 +0,0 @@
use super::MarketplaceAddError;
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<(), MarketplaceAddError> {
let destination_string = destination.to_string_lossy().to_string();
if sparse_paths.is_empty() {
run_git(
&["clone", url, destination_string.as_str()],
/*cwd*/ None,
)?;
if let Some(ref_name) = ref_name {
run_git(
&["checkout", ref_name],
Some(Path::new(&destination_string)),
)?;
}
return Ok(());
}
run_git(
&[
"clone",
"--filter=blob:none",
"--no-checkout",
url,
destination_string.as_str(),
],
/*cwd*/ None,
)?;
let mut sparse_args = vec!["sparse-checkout", "set"];
sparse_args.extend(sparse_paths.iter().map(String::as_str));
run_git(&sparse_args, Some(destination))?;
run_git(&["checkout", ref_name.unwrap_or("HEAD")], Some(destination))?;
Ok(())
}
pub(super) fn safe_marketplace_dir_name(
marketplace_name: &str,
) -> Result<String, MarketplaceAddError> {
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 == ".." {
return Err(MarketplaceAddError::InvalidRequest(format!(
"marketplace name '{marketplace_name}' cannot be used as an install directory"
)));
}
Ok(safe)
}
pub(super) fn ensure_marketplace_destination_is_inside_install_root(
install_root: &Path,
destination: &Path,
) -> Result<(), MarketplaceAddError> {
let install_root = install_root.canonicalize().map_err(|err| {
MarketplaceAddError::Internal(format!(
"failed to resolve marketplace install root {}: {err}",
install_root.display()
))
})?;
let destination_parent = destination
.parent()
.ok_or_else(|| {
MarketplaceAddError::Internal("marketplace destination has no parent".to_string())
})?
.canonicalize()
.map_err(|err| {
MarketplaceAddError::Internal(format!(
"failed to resolve marketplace destination parent {}: {err}",
destination.display()
))
})?;
if !destination_parent.starts_with(&install_root) {
return Err(MarketplaceAddError::InvalidRequest(format!(
"marketplace destination {} is outside install root {}",
destination.display(),
install_root.display()
)));
}
Ok(())
}
pub(super) fn replace_marketplace_root(
staged_root: &Path,
destination: &Path,
) -> std::io::Result<()> {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
fs::rename(staged_root, destination)
}
pub(super) fn marketplace_staging_root(install_root: &Path) -> PathBuf {
install_root.join(".staging")
}
fn run_git(args: &[&str], cwd: Option<&Path>) -> Result<(), MarketplaceAddError> {
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().map_err(|err| {
MarketplaceAddError::Internal(format!("failed to run git {}: {err}", args.join(" ")))
})?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
Err(MarketplaceAddError::Internal(format!(
"git {} failed with status {}\nstdout:\n{}\nstderr:\n{}",
args.join(" "),
output.status,
stdout.trim(),
stderr.trim()
)))
}
@@ -1,315 +0,0 @@
use super::MarketplaceAddError;
use super::source::MarketplaceSource;
use crate::plugins::installed_marketplaces::resolve_configured_marketplace_root;
use codex_config::CONFIG_TOML_FILE;
use codex_config::MarketplaceConfigUpdate;
use codex_config::record_user_marketplace;
use codex_core_plugins::marketplace::validate_marketplace_root;
use std::fs;
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
#[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>,
},
Local {
path: String,
},
}
pub(super) fn record_added_marketplace_entry(
codex_home: &Path,
marketplace_name: &str,
install_metadata: &MarketplaceInstallMetadata,
) -> Result<(), MarketplaceAddError> {
let source = install_metadata.config_source();
let timestamp = utc_timestamp_now()?;
let update = MarketplaceConfigUpdate {
last_updated: &timestamp,
last_revision: None,
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).map_err(|err| {
MarketplaceAddError::Internal(format!(
"failed to add marketplace '{marketplace_name}' to user config.toml: {err}"
))
})
}
pub(super) fn installed_marketplace_root_for_source(
codex_home: &Path,
install_root: &Path,
install_metadata: &MarketplaceInstallMetadata,
) -> Result<Option<PathBuf>, MarketplaceAddError> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
let config = match fs::read_to_string(&config_path) {
Ok(config) => config,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
Err(err) => {
return Err(MarketplaceAddError::Internal(format!(
"failed to read user config {}: {err}",
config_path.display()
)));
}
};
let config: toml::Value = toml::from_str(&config).map_err(|err| {
MarketplaceAddError::Internal(format!(
"failed to parse user config {}: {err}",
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 Some(root) =
resolve_configured_marketplace_root(marketplace_name, marketplace, install_root)
else {
continue;
};
if validate_marketplace_root(&root).is_ok() {
return Ok(Some(root));
}
}
Ok(None)
}
pub(super) fn find_marketplace_root_by_name(
codex_home: &Path,
install_root: &Path,
marketplace_name: &str,
) -> Result<Option<PathBuf>, MarketplaceAddError> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
let config = match fs::read_to_string(&config_path) {
Ok(config) => config,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
Err(err) => {
return Err(MarketplaceAddError::Internal(format!(
"failed to read user config {}: {err}",
config_path.display()
)));
}
};
let config: toml::Value = toml::from_str(&config).map_err(|err| {
MarketplaceAddError::Internal(format!(
"failed to parse user config {}: {err}",
config_path.display()
))
})?;
let Some(marketplace) = config
.get("marketplaces")
.and_then(toml::Value::as_table)
.and_then(|marketplaces| marketplaces.get(marketplace_name))
else {
return Ok(None);
};
let Some(root) =
resolve_configured_marketplace_root(marketplace_name, marketplace, install_root)
else {
return Ok(None);
};
if validate_marketplace_root(&root).is_ok() {
Ok(Some(root))
} else {
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(),
},
MarketplaceSource::Local { path } => InstalledMarketplaceSource::Local {
path: path.display().to_string(),
},
};
Self { source }
}
fn config_source_type(&self) -> &'static str {
match &self.source {
InstalledMarketplaceSource::Git { .. } => "git",
InstalledMarketplaceSource::Local { .. } => "local",
}
}
fn config_source(&self) -> String {
match &self.source {
InstalledMarketplaceSource::Git { url, .. } => url.clone(),
InstalledMarketplaceSource::Local { path } => path.clone(),
}
}
fn ref_name(&self) -> Option<&str> {
match &self.source {
InstalledMarketplaceSource::Git { ref_name, .. } => ref_name.as_deref(),
InstalledMarketplaceSource::Local { .. } => None,
}
}
fn sparse_paths(&self) -> &[String] {
match &self.source {
InstalledMarketplaceSource::Git { sparse_paths, .. } => sparse_paths,
InstalledMarketplaceSource::Local { .. } => &[],
}
}
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()
}
fn utc_timestamp_now() -> Result<String, MarketplaceAddError> {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|err| {
MarketplaceAddError::Internal(format!("system clock is before Unix epoch: {err}"))
})?;
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)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[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 installed_marketplace_root_for_source_propagates_config_read_errors() {
let codex_home = TempDir::new().unwrap();
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
fs::create_dir(&config_path).unwrap();
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!(
err.to_string().contains(&format!(
"failed to read user config {}:",
config_path.display()
)),
"unexpected error: {err}"
);
}
#[test]
fn installed_marketplace_root_for_source_uses_local_source_root() {
let codex_home = TempDir::new().unwrap();
let install_root = codex_home.path().join("marketplaces");
let source_root = codex_home.path().join("source");
fs::create_dir_all(source_root.join(".agents/plugins")).unwrap();
fs::write(
source_root.join(".agents/plugins/marketplace.json"),
r#"{"name":"debug","plugins":[]}"#,
)
.unwrap();
let source = MarketplaceSource::Local {
path: source_root.clone(),
};
let install_metadata = MarketplaceInstallMetadata::from_source(&source, &[]);
record_added_marketplace_entry(codex_home.path(), "debug", &install_metadata).unwrap();
let root = installed_marketplace_root_for_source(
codex_home.path(),
&install_root,
&install_metadata,
)
.unwrap();
assert_eq!(root, Some(source_root));
}
}
@@ -1,392 +0,0 @@
use super::MarketplaceAddError;
use crate::plugins::validate_plugin_segment;
use codex_core_plugins::marketplace::validate_marketplace_root;
use std::path::Path;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum MarketplaceSource {
Git {
url: String,
ref_name: Option<String>,
},
Local {
path: PathBuf,
},
}
pub(crate) fn parse_marketplace_source(
source: &str,
explicit_ref: Option<String>,
) -> Result<MarketplaceSource, MarketplaceAddError> {
let source = source.trim();
if source.is_empty() {
return Err(MarketplaceAddError::InvalidRequest(
"marketplace source must not be empty".to_string(),
));
}
let (base_source, parsed_ref) = split_source_ref(source);
let ref_name = explicit_ref.or(parsed_ref);
if looks_like_local_path(&base_source) {
if ref_name.is_some() {
return Err(MarketplaceAddError::InvalidRequest(
"--ref is only supported for git marketplace sources".to_string(),
));
}
let path = resolve_local_source_path(&base_source)?;
if path.is_file() {
return Err(MarketplaceAddError::InvalidRequest(
"local marketplace source must be a directory, not a file".to_string(),
));
}
return Ok(MarketplaceSource::Local { path });
}
if is_ssh_git_url(&base_source) || is_git_url(&base_source) {
return Ok(MarketplaceSource::Git {
url: normalize_git_url(&base_source),
ref_name,
});
}
if looks_like_github_shorthand(&base_source) {
return Ok(MarketplaceSource::Git {
url: format!("https://github.com/{base_source}.git"),
ref_name,
});
}
Err(MarketplaceAddError::InvalidRequest(format!(
"invalid marketplace source format: {source}"
)))
}
pub(super) fn stage_marketplace_source<F>(
source: &MarketplaceSource,
sparse_paths: &[String],
staged_root: &Path,
clone_source: F,
) -> Result<(), MarketplaceAddError>
where
F: Fn(&str, Option<&str>, &[String], &Path) -> Result<(), MarketplaceAddError>,
{
if !sparse_paths.is_empty() && !matches!(source, MarketplaceSource::Git { .. }) {
return Err(MarketplaceAddError::InvalidRequest(
"--sparse is only supported for git marketplace sources".to_string(),
));
}
match source {
MarketplaceSource::Git { url, ref_name } => {
clone_source(url, ref_name.as_deref(), sparse_paths, staged_root)
}
MarketplaceSource::Local { .. } => unreachable!(
"local marketplace sources are added without staging a copied install root"
),
}
}
pub(super) fn validate_marketplace_source_root(root: &Path) -> Result<String, MarketplaceAddError> {
let marketplace_name = validate_marketplace_root(root)
.map_err(|err| MarketplaceAddError::InvalidRequest(err.to_string()))?;
validate_plugin_segment(&marketplace_name, "marketplace name")
.map_err(MarketplaceAddError::InvalidRequest)?;
Ok(marketplace_name)
}
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 {
Path::new(source).is_absolute()
|| looks_like_windows_absolute_path(source)
|| source.starts_with("./")
|| source.starts_with(".\\")
|| source.starts_with("../")
|| source.starts_with("..\\")
|| source.starts_with("~/")
|| source == "."
|| source == ".."
}
fn looks_like_windows_absolute_path(source: &str) -> bool {
let bytes = source.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'\\' | b'/')
|| source.starts_with(r"\\")
}
fn resolve_local_source_path(source: &str) -> Result<PathBuf, MarketplaceAddError> {
let path = expand_tilde_path(source);
let path = if path.is_absolute() {
path
} else {
std::env::current_dir()
.map_err(|err| {
MarketplaceAddError::Internal(format!(
"failed to read current working directory for local marketplace source: {err}"
))
})?
.join(path)
};
path.canonicalize().map_err(|err| {
MarketplaceAddError::InvalidRequest(format!(
"failed to resolve local marketplace source {}: {err}",
path.display()
))
})
}
fn expand_tilde_path(source: &str) -> PathBuf {
let Some(rest) = source.strip_prefix("~/") else {
return PathBuf::from(source);
};
let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) else {
return PathBuf::from(source);
};
PathBuf::from(home).join(rest)
}
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, '-' | '_' | '.'))
}
impl MarketplaceSource {
pub(super) fn display(&self) -> String {
match self {
Self::Git { url, ref_name } => match ref_name {
Some(ref_name) => format!("{url}#{ref_name}"),
None => url.clone(),
},
Self::Local { path } => path.display().to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[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", 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_parses() {
let source = parse_marketplace_source(".", /*explicit_ref*/ None).unwrap();
let MarketplaceSource::Local { path } = source else {
panic!("expected local path source");
};
assert!(path.is_absolute());
}
#[test]
fn windows_absolute_paths_look_like_local_paths_on_every_host() {
assert!(looks_like_local_path(r"C:\Users\alice\marketplace"));
assert!(looks_like_local_path("C:/Users/alice/marketplace"));
assert!(looks_like_local_path(r"\\server\share\marketplace"));
assert!(!looks_like_local_path(r"C:relative\path"));
}
#[test]
fn local_file_source_is_rejected() {
let tempdir = TempDir::new().unwrap();
let file = tempdir.path().join("marketplace.json");
std::fs::write(&file, "{}").unwrap();
let err =
parse_marketplace_source(file.to_str().unwrap(), /*explicit_ref*/ None).unwrap_err();
assert!(
err.to_string()
.contains("local marketplace source must be a directory, not a file"),
"unexpected error: {err}"
);
}
#[test]
fn non_git_sources_reject_ref_override() {
let err = parse_marketplace_source("./marketplace", Some("main".to_string())).unwrap_err();
assert!(
err.to_string()
.contains("--ref is only supported for git marketplace sources"),
"unexpected error: {err}"
);
}
#[test]
fn non_git_sources_reject_sparse_checkout() {
let path = std::env::current_dir().unwrap();
let err = stage_marketplace_source(
&MarketplaceSource::Local { path },
&["plugins/foo".to_string()],
Path::new("/tmp"),
|_url, _ref_name, _sparse_paths, _staged_root| Ok(()),
)
.unwrap_err();
assert!(
err.to_string()
.contains("--sparse is only supported for git marketplace sources"),
"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()),
}
);
}
}
@@ -1,313 +0,0 @@
use crate::plugins::marketplace_install_root;
use crate::plugins::validate_plugin_segment;
use codex_config::RemoveMarketplaceConfigOutcome;
use codex_config::remove_user_marketplace_config;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplaceRemoveRequest {
pub marketplace_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplaceRemoveOutcome {
pub marketplace_name: String,
pub removed_installed_root: Option<AbsolutePathBuf>,
}
#[derive(Debug, thiserror::Error)]
pub enum MarketplaceRemoveError {
#[error("{0}")]
InvalidRequest(String),
#[error("{0}")]
Internal(String),
}
pub async fn remove_marketplace(
codex_home: PathBuf,
request: MarketplaceRemoveRequest,
) -> Result<MarketplaceRemoveOutcome, MarketplaceRemoveError> {
tokio::task::spawn_blocking(move || remove_marketplace_sync(codex_home.as_path(), request))
.await
.map_err(|err| {
MarketplaceRemoveError::Internal(format!("failed to remove marketplace: {err}"))
})?
}
fn remove_marketplace_sync(
codex_home: &Path,
request: MarketplaceRemoveRequest,
) -> Result<MarketplaceRemoveOutcome, MarketplaceRemoveError> {
let marketplace_name = request.marketplace_name;
validate_plugin_segment(&marketplace_name, "marketplace name")
.map_err(MarketplaceRemoveError::InvalidRequest)?;
let destination = marketplace_install_root(codex_home).join(&marketplace_name);
let config_outcome =
remove_user_marketplace_config(codex_home, &marketplace_name).map_err(|err| {
MarketplaceRemoveError::Internal(format!(
"failed to remove marketplace '{marketplace_name}' from user config.toml: {err}"
))
})?;
if let RemoveMarketplaceConfigOutcome::NameCaseMismatch { configured_name } = &config_outcome {
return Err(MarketplaceRemoveError::InvalidRequest(format!(
"marketplace `{marketplace_name}` does not match configured marketplace `{configured_name}` exactly"
)));
}
let removed_config = config_outcome == RemoveMarketplaceConfigOutcome::Removed;
let removed_installed_root = remove_marketplace_root(&destination)?;
if removed_installed_root.is_none() && !removed_config {
return Err(MarketplaceRemoveError::InvalidRequest(format!(
"marketplace `{marketplace_name}` is not configured or installed"
)));
}
Ok(MarketplaceRemoveOutcome {
marketplace_name,
removed_installed_root,
})
}
fn remove_marketplace_root(root: &Path) -> Result<Option<AbsolutePathBuf>, MarketplaceRemoveError> {
if !root.exists() {
return Ok(None);
}
let removed_root = AbsolutePathBuf::try_from(root.to_path_buf()).map_err(|err| {
MarketplaceRemoveError::Internal(format!(
"failed to resolve installed marketplace root {}: {err}",
root.display()
))
})?;
let metadata = fs::symlink_metadata(root).map_err(|err| {
MarketplaceRemoveError::Internal(format!(
"failed to inspect installed marketplace root {}: {err}",
root.display()
))
})?;
let remove_result = if metadata.is_dir() {
fs::remove_dir_all(root)
} else {
fs::remove_file(root)
};
remove_result.map_err(|err| {
MarketplaceRemoveError::Internal(format!(
"failed to remove installed marketplace root {}: {err}",
root.display()
))
})?;
Ok(Some(removed_root))
}
#[cfg(test)]
mod tests {
use super::*;
use codex_config::MarketplaceConfigUpdate;
use codex_config::record_user_marketplace;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[test]
fn remove_marketplace_sync_removes_config_and_installed_root() {
let codex_home = TempDir::new().unwrap();
record_user_marketplace(
codex_home.path(),
"debug",
&MarketplaceConfigUpdate {
last_updated: "2026-04-13T00:00:00Z",
last_revision: None,
source_type: "git",
source: "https://github.com/owner/repo.git",
ref_name: Some("main"),
sparse_paths: &[],
},
)
.unwrap();
let installed_root = marketplace_install_root(codex_home.path()).join("debug");
fs::create_dir_all(installed_root.join(".agents/plugins")).unwrap();
fs::write(
installed_root.join(".agents/plugins/marketplace.json"),
"{}",
)
.unwrap();
let outcome = remove_marketplace_sync(
codex_home.path(),
MarketplaceRemoveRequest {
marketplace_name: "debug".to_string(),
},
)
.unwrap();
assert_eq!(outcome.marketplace_name, "debug");
assert_eq!(
outcome.removed_installed_root,
Some(AbsolutePathBuf::try_from(installed_root.clone()).unwrap())
);
let config =
fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE)).unwrap();
assert!(!config.contains("[marketplaces.debug]"));
assert!(!installed_root.exists());
}
#[test]
fn remove_marketplace_sync_rejects_unknown_marketplace() {
let codex_home = TempDir::new().unwrap();
let err = remove_marketplace_sync(
codex_home.path(),
MarketplaceRemoveRequest {
marketplace_name: "debug".to_string(),
},
)
.unwrap_err();
assert_eq!(
err.to_string(),
"marketplace `debug` is not configured or installed"
);
}
#[test]
fn remove_marketplace_sync_rejects_case_mismatched_configured_name() {
let codex_home = TempDir::new().unwrap();
record_user_marketplace(
codex_home.path(),
"debug",
&MarketplaceConfigUpdate {
last_updated: "2026-04-13T00:00:00Z",
last_revision: None,
source_type: "git",
source: "https://github.com/owner/repo.git",
ref_name: Some("main"),
sparse_paths: &[],
},
)
.unwrap();
let installed_root = marketplace_install_root(codex_home.path()).join("debug");
fs::create_dir_all(&installed_root).unwrap();
let err = remove_marketplace_sync(
codex_home.path(),
MarketplaceRemoveRequest {
marketplace_name: "Debug".to_string(),
},
)
.unwrap_err();
assert_eq!(
err.to_string(),
"marketplace `Debug` does not match configured marketplace `debug` exactly"
);
assert!(installed_root.exists());
let config =
fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE)).unwrap();
assert!(config.contains("[marketplaces.debug]"));
}
#[test]
fn remove_marketplace_sync_keeps_installed_root_when_config_removal_fails() {
let codex_home = TempDir::new().unwrap();
fs::write(
codex_home.path().join(codex_config::CONFIG_TOML_FILE),
"[marketplaces.debug\n",
)
.unwrap();
let installed_root = marketplace_install_root(codex_home.path()).join("debug");
fs::create_dir_all(&installed_root).unwrap();
let err = remove_marketplace_sync(
codex_home.path(),
MarketplaceRemoveRequest {
marketplace_name: "debug".to_string(),
},
)
.unwrap_err();
assert!(
err.to_string()
.contains("failed to remove marketplace 'debug' from user config.toml")
);
assert!(installed_root.exists());
}
#[test]
fn remove_marketplace_sync_removes_file_installed_root() {
let codex_home = TempDir::new().unwrap();
record_user_marketplace(
codex_home.path(),
"debug",
&MarketplaceConfigUpdate {
last_updated: "2026-04-13T00:00:00Z",
last_revision: None,
source_type: "git",
source: "https://github.com/owner/repo.git",
ref_name: Some("main"),
sparse_paths: &[],
},
)
.unwrap();
let installed_root = marketplace_install_root(codex_home.path()).join("debug");
fs::create_dir_all(installed_root.parent().unwrap()).unwrap();
fs::write(&installed_root, "corrupt install root").unwrap();
let outcome = remove_marketplace_sync(
codex_home.path(),
MarketplaceRemoveRequest {
marketplace_name: "debug".to_string(),
},
)
.unwrap();
assert_eq!(
outcome,
MarketplaceRemoveOutcome {
marketplace_name: "debug".to_string(),
removed_installed_root: Some(
AbsolutePathBuf::try_from(installed_root.clone()).unwrap()
),
}
);
assert!(!installed_root.exists());
let config =
fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE)).unwrap();
assert!(!config.contains("[marketplaces.debug]"));
}
#[test]
fn remove_marketplace_sync_removes_inline_config_entry() {
let codex_home = TempDir::new().unwrap();
fs::write(
codex_home.path().join(codex_config::CONFIG_TOML_FILE),
r#"
marketplaces = { debug = { source_type = "git", source = "https://github.com/owner/repo.git" } }
"#,
)
.unwrap();
let installed_root = marketplace_install_root(codex_home.path()).join("debug");
fs::create_dir_all(&installed_root).unwrap();
let outcome = remove_marketplace_sync(
codex_home.path(),
MarketplaceRemoveRequest {
marketplace_name: "debug".to_string(),
},
)
.unwrap();
assert_eq!(outcome.marketplace_name, "debug");
assert_eq!(
outcome.removed_installed_root,
Some(AbsolutePathBuf::try_from(installed_root.clone()).unwrap())
);
assert!(!installed_root.exists());
let config =
fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE)).unwrap();
assert!(!config.contains("debug"));
}
}
-19
View File
@@ -2,10 +2,7 @@ use codex_config::types::McpServerConfig;
mod discoverable;
mod injection;
mod installed_marketplaces;
mod manager;
mod marketplace_add;
mod marketplace_remove;
mod mentions;
mod render;
mod startup_sync;
@@ -27,13 +24,9 @@ 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;
pub use manager::OPENAI_BUNDLED_MARKETPLACE_NAME;
pub use manager::OPENAI_CURATED_MARKETPLACE_NAME;
pub use manager::PluginDetail;
pub use manager::PluginDetailsUnavailableReason;
pub use manager::PluginInstallError;
@@ -45,19 +38,7 @@ pub use manager::PluginRemoteSyncError;
pub use manager::PluginUninstallError;
pub use manager::PluginsManager;
pub use manager::RemotePluginSyncResult;
pub use marketplace_add::MarketplaceAddError;
pub use marketplace_add::MarketplaceAddOutcome;
pub use marketplace_add::MarketplaceAddRequest;
pub use marketplace_add::add_marketplace;
pub use marketplace_add::is_local_marketplace_source;
pub use marketplace_remove::MarketplaceRemoveError;
pub use marketplace_remove::MarketplaceRemoveOutcome;
pub use marketplace_remove::MarketplaceRemoveRequest;
pub use marketplace_remove::remove_marketplace;
pub(crate) use render::render_explicit_plugin_instructions;
pub(crate) use startup_sync::curated_plugins_repo_path;
pub(crate) use startup_sync::read_curated_plugins_sha;
pub(crate) use startup_sync::sync_openai_plugins_repo;
pub(crate) use mentions::build_connector_slug_counts;
pub(crate) use mentions::build_skill_name_counts;
+5 -933
View File
@@ -1,235 +1,19 @@
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::process::Output;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use codex_otel::CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC;
use codex_otel::CURATED_PLUGINS_STARTUP_SYNC_METRIC;
use reqwest::Client;
use serde::Deserialize;
use tempfile::TempDir;
use crate::config::Config;
use crate::plugins::PluginsManager;
use codex_core_plugins::startup_sync::has_local_curated_plugins_snapshot;
use codex_login::AuthManager;
use tracing::info;
use tracing::warn;
use zip::ZipArchive;
use crate::config::Config;
use codex_login::AuthManager;
use codex_login::default_client::build_reqwest_client;
use super::PluginsManager;
const GITHUB_API_BASE_URL: &str = "https://api.github.com";
const GITHUB_API_ACCEPT_HEADER: &str = "application/vnd.github+json";
const GITHUB_API_VERSION_HEADER: &str = "2022-11-28";
const CURATED_PLUGINS_BACKUP_ARCHIVE_API_URL: &str =
"https://chatgpt.com/backend-api/plugins/export/curated";
const OPENAI_PLUGINS_OWNER: &str = "openai";
const OPENAI_PLUGINS_REPO: &str = "plugins";
const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins";
const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha";
const CURATED_PLUGINS_BACKUP_ARCHIVE_FALLBACK_VERSION: &str = "export-backup";
const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30);
const CURATED_PLUGINS_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
const CURATED_PLUGINS_BACKUP_ARCHIVE_TIMEOUT: Duration = Duration::from_secs(30);
// Keep this comfortably above a normal sync attempt so we do not race another Codex process.
const CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE: Duration = Duration::from_secs(10 * 60);
const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1";
const STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Deserialize)]
struct GitHubRepositorySummary {
default_branch: String,
}
#[derive(Debug, Deserialize)]
struct GitHubGitRefSummary {
object: GitHubGitRefObject,
}
#[derive(Debug, Deserialize)]
struct GitHubGitRefObject {
sha: String,
}
#[derive(Debug, Deserialize)]
struct CuratedPluginsBackupArchiveResponse {
download_url: String,
}
pub(crate) fn curated_plugins_repo_path(codex_home: &Path) -> PathBuf {
codex_home.join(CURATED_PLUGINS_RELATIVE_DIR)
}
pub(crate) fn read_curated_plugins_sha(codex_home: &Path) -> Option<String> {
read_sha_file(curated_plugins_sha_path(codex_home).as_path())
}
fn curated_plugins_sha_path(codex_home: &Path) -> PathBuf {
codex_home.join(CURATED_PLUGINS_SHA_FILE)
}
pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result<String, String> {
sync_openai_plugins_repo_with_transport_overrides(
codex_home,
"git",
GITHUB_API_BASE_URL,
CURATED_PLUGINS_BACKUP_ARCHIVE_API_URL,
)
}
fn sync_openai_plugins_repo_with_transport_overrides(
codex_home: &Path,
git_binary: &str,
api_base_url: &str,
backup_archive_api_url: &str,
) -> Result<String, String> {
match sync_openai_plugins_repo_via_git(codex_home, git_binary) {
Ok(remote_sha) => {
emit_curated_plugins_startup_sync_metric("git", "success");
emit_curated_plugins_startup_sync_final_metric("git", "success");
Ok(remote_sha)
}
Err(err) => {
emit_curated_plugins_startup_sync_metric("git", "failure");
warn!(
error = %err,
git_binary,
"git sync failed for curated plugin sync; falling back to GitHub HTTP"
);
match sync_openai_plugins_repo_via_http(codex_home, api_base_url) {
Ok(remote_sha) => {
emit_curated_plugins_startup_sync_metric("http", "success");
emit_curated_plugins_startup_sync_final_metric("http", "success");
Ok(remote_sha)
}
Err(http_err) => {
emit_curated_plugins_startup_sync_metric("http", "failure");
if has_local_curated_plugins_snapshot(codex_home) {
emit_curated_plugins_startup_sync_final_metric("http", "failure");
warn!(
error = %http_err,
"GitHub HTTP sync failed for curated plugin sync; skipping export archive fallback because a local curated plugins snapshot already exists"
);
Err(format!(
"git sync failed for curated plugin sync: {err}; GitHub HTTP sync failed for curated plugin sync: {http_err}; export archive fallback skipped because a local curated plugins snapshot already exists"
))
} else {
// The export archive is a lagging backup path. Only use it to bootstrap a
// missing local curated snapshot, never to refresh an existing one.
warn!(
error = %http_err,
backup_archive_api_url,
"GitHub HTTP sync failed for curated plugin sync; falling back to export archive"
);
let result = sync_openai_plugins_repo_via_backup_archive(
codex_home,
backup_archive_api_url,
);
let status = if result.is_ok() { "success" } else { "failure" };
emit_curated_plugins_startup_sync_metric("export_archive", status);
emit_curated_plugins_startup_sync_final_metric("export_archive", status);
result.map_err(|export_err| {
format!(
"git sync failed for curated plugin sync: {err}; GitHub HTTP sync failed for curated plugin sync: {http_err}; export archive sync failed for curated plugin sync: {export_err}"
)
})
}
}
}
}
}
}
fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Result<String, String> {
let repo_path = curated_plugins_repo_path(codex_home);
let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE);
let remote_sha = git_ls_remote_head_sha(git_binary)?;
let local_sha = read_local_git_or_sha_file(&repo_path, &sha_path, git_binary);
if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.join(".git").is_dir() {
return Ok(remote_sha);
}
let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?;
let clone_output = run_git_command_with_timeout(
Command::new(git_binary)
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("clone")
.arg("--depth")
.arg("1")
.arg("https://github.com/openai/plugins.git")
.arg(staged_repo_dir.path()),
"git clone curated plugins repo",
CURATED_PLUGINS_GIT_TIMEOUT,
)?;
ensure_git_success(&clone_output, "git clone curated plugins repo")?;
let cloned_sha = git_head_sha(staged_repo_dir.path(), git_binary)?;
if cloned_sha != remote_sha {
return Err(format!(
"curated plugins clone HEAD mismatch: expected {remote_sha}, got {cloned_sha}"
));
}
ensure_marketplace_manifest_exists(staged_repo_dir.path())?;
activate_curated_repo(&repo_path, staged_repo_dir)?;
write_curated_plugins_sha(&sha_path, &remote_sha)?;
Ok(remote_sha)
}
fn sync_openai_plugins_repo_via_http(
codex_home: &Path,
api_base_url: &str,
) -> Result<String, String> {
let repo_path = curated_plugins_repo_path(codex_home);
let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|err| format!("failed to create curated plugins sync runtime: {err}"))?;
let remote_sha = runtime.block_on(fetch_curated_repo_remote_sha(api_base_url))?;
let local_sha = read_sha_file(&sha_path);
if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.is_dir() {
return Ok(remote_sha);
}
let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?;
let zipball_bytes = runtime.block_on(fetch_curated_repo_zipball(api_base_url, &remote_sha))?;
extract_zipball_to_dir(&zipball_bytes, staged_repo_dir.path())?;
ensure_marketplace_manifest_exists(staged_repo_dir.path())?;
activate_curated_repo(&repo_path, staged_repo_dir)?;
write_curated_plugins_sha(&sha_path, &remote_sha)?;
Ok(remote_sha)
}
fn sync_openai_plugins_repo_via_backup_archive(
codex_home: &Path,
backup_archive_api_url: &str,
) -> Result<String, String> {
let repo_path = curated_plugins_repo_path(codex_home);
let sha_path = curated_plugins_sha_path(codex_home);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|err| format!("failed to create curated plugins sync runtime: {err}"))?;
let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?;
let zipball_bytes = runtime.block_on(fetch_curated_repo_backup_archive_zip(
backup_archive_api_url,
))?;
extract_zipball_to_dir(&zipball_bytes, staged_repo_dir.path())?;
ensure_marketplace_manifest_exists(staged_repo_dir.path())?;
let export_version = read_extracted_backup_archive_git_sha(staged_repo_dir.path())?
.unwrap_or_else(|| CURATED_PLUGINS_BACKUP_ARCHIVE_FALLBACK_VERSION.to_string());
activate_curated_repo(&repo_path, staged_repo_dir)?;
write_curated_plugins_sha(&sha_path, &export_version)?;
Ok(export_version)
}
pub(super) fn start_startup_remote_plugin_sync_once(
pub(crate) fn start_startup_remote_plugin_sync_once(
manager: Arc<PluginsManager>,
codex_home: PathBuf,
config: Config,
@@ -290,13 +74,6 @@ fn startup_remote_plugin_sync_marker_path(codex_home: &Path) -> PathBuf {
codex_home.join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE)
}
fn has_local_curated_plugins_snapshot(codex_home: &Path) -> bool {
curated_plugins_repo_path(codex_home)
.join(".agents/plugins/marketplace.json")
.is_file()
&& codex_home.join(CURATED_PLUGINS_SHA_FILE).is_file()
}
async fn wait_for_startup_remote_plugin_sync_prerequisites(codex_home: &Path) -> bool {
let deadline = tokio::time::Instant::now() + STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT;
loop {
@@ -318,711 +95,6 @@ async fn write_startup_remote_plugin_sync_marker(codex_home: &Path) -> std::io::
tokio::fs::write(marker_path, b"ok\n").await
}
fn prepare_curated_repo_parent_and_temp_dir(repo_path: &Path) -> Result<TempDir, String> {
let Some(parent) = repo_path.parent() else {
return Err(format!(
"failed to determine curated plugins parent directory for {}",
repo_path.display()
));
};
std::fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create curated plugins parent directory {}: {err}",
parent.display()
)
})?;
remove_stale_curated_repo_temp_dirs(parent, CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE);
let clone_dir = tempfile::Builder::new()
.prefix("plugins-clone-")
.tempdir_in(parent)
.map_err(|err| {
format!(
"failed to create temporary curated plugins directory in {}: {err}",
parent.display()
)
})?;
Ok(clone_dir)
}
fn remove_stale_curated_repo_temp_dirs(parent: &Path, max_age: Duration) {
let entries = match std::fs::read_dir(parent) {
Ok(entries) => entries,
Err(err) => {
warn!(
error = %err,
parent = %parent.display(),
"failed to list curated plugins temp directory parent for stale cleanup"
);
return;
}
};
for entry in entries.flatten() {
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(err) => {
warn!(
error = %err,
path = %entry.path().display(),
"failed to inspect curated plugins temp directory entry"
);
continue;
}
};
if !file_type.is_dir() {
continue;
}
let path = entry.path();
let is_plugins_clone_dir = path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("plugins-clone-"));
if !is_plugins_clone_dir {
continue;
}
let metadata = match entry.metadata() {
Ok(metadata) => metadata,
Err(err) => {
warn!(
error = %err,
path = %path.display(),
"failed to read curated plugins temp directory metadata"
);
continue;
}
};
let modified = match metadata.modified() {
Ok(modified) => modified,
Err(err) => {
warn!(
error = %err,
path = %path.display(),
"failed to read curated plugins temp directory modification time"
);
continue;
}
};
let age = match modified.elapsed() {
Ok(age) => age,
Err(err) => {
warn!(
error = %err,
path = %path.display(),
"failed to compute curated plugins temp directory age"
);
continue;
}
};
if age < max_age {
continue;
}
if let Err(err) = std::fs::remove_dir_all(&path) {
warn!(
error = %err,
path = %path.display(),
"failed to remove stale curated plugins temp directory"
);
}
}
}
fn emit_curated_plugins_startup_sync_metric(transport: &'static str, status: &'static str) {
emit_curated_plugins_startup_sync_counter(
CURATED_PLUGINS_STARTUP_SYNC_METRIC,
transport,
status,
);
}
fn emit_curated_plugins_startup_sync_final_metric(transport: &'static str, status: &'static str) {
emit_curated_plugins_startup_sync_counter(
CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC,
transport,
status,
);
}
fn emit_curated_plugins_startup_sync_counter(
metric_name: &str,
transport: &'static str,
status: &'static str,
) {
let Some(metrics) = codex_otel::global() else {
return;
};
let tags = [("transport", transport), ("status", status)];
let _ = metrics.counter(metric_name, /*inc*/ 1, &tags);
}
fn ensure_marketplace_manifest_exists(repo_path: &Path) -> Result<(), String> {
if repo_path.join(".agents/plugins/marketplace.json").is_file() {
return Ok(());
}
Err(format!(
"curated plugins archive missing marketplace manifest at {}",
repo_path.join(".agents/plugins/marketplace.json").display()
))
}
fn activate_curated_repo(repo_path: &Path, staged_repo_dir: TempDir) -> Result<(), String> {
let staged_repo_path = staged_repo_dir.path();
if repo_path.exists() {
let parent = repo_path.parent().ok_or_else(|| {
format!(
"failed to determine curated plugins parent directory for {}",
repo_path.display()
)
})?;
let backup_dir = tempfile::Builder::new()
.prefix("plugins-backup-")
.tempdir_in(parent)
.map_err(|err| {
format!(
"failed to create curated plugins backup directory in {}: {err}",
parent.display()
)
})?;
let backup_repo_path = backup_dir.path().join("repo");
std::fs::rename(repo_path, &backup_repo_path).map_err(|err| {
format!(
"failed to move previous curated plugins repo out of the way at {}: {err}",
repo_path.display()
)
})?;
if let Err(err) = std::fs::rename(staged_repo_path, repo_path) {
let rollback_result = std::fs::rename(&backup_repo_path, repo_path);
return match rollback_result {
Ok(()) => Err(format!(
"failed to activate new curated plugins repo at {}: {err}",
repo_path.display()
)),
Err(rollback_err) => {
let backup_path = backup_dir.keep().join("repo");
Err(format!(
"failed to activate new curated plugins repo at {}: {err}; failed to restore previous repo (left at {}): {rollback_err}",
repo_path.display(),
backup_path.display()
))
}
};
}
} else {
std::fs::rename(staged_repo_path, repo_path).map_err(|err| {
format!(
"failed to activate curated plugins repo at {}: {err}",
repo_path.display()
)
})?;
}
Ok(())
}
fn write_curated_plugins_sha(sha_path: &Path, remote_sha: &str) -> Result<(), String> {
if let Some(parent) = sha_path.parent() {
std::fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create curated plugins sha directory {}: {err}",
parent.display()
)
})?;
}
std::fs::write(sha_path, format!("{remote_sha}\n")).map_err(|err| {
format!(
"failed to write curated plugins sha file {}: {err}",
sha_path.display()
)
})
}
fn read_local_git_or_sha_file(
repo_path: &Path,
sha_path: &Path,
git_binary: &str,
) -> Option<String> {
if repo_path.join(".git").is_dir()
&& let Ok(sha) = git_head_sha(repo_path, git_binary)
{
return Some(sha);
}
read_sha_file(sha_path)
}
fn git_ls_remote_head_sha(git_binary: &str) -> Result<String, String> {
let output = run_git_command_with_timeout(
Command::new(git_binary)
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("ls-remote")
.arg("https://github.com/openai/plugins.git")
.arg("HEAD"),
"git ls-remote curated plugins repo",
CURATED_PLUGINS_GIT_TIMEOUT,
)?;
ensure_git_success(&output, "git ls-remote curated plugins repo")?;
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 curated plugins repo".to_string());
};
let Some((sha, _)) = first_line.split_once('\t') else {
return Err(format!(
"unexpected git ls-remote output for curated plugins repo: {first_line}"
));
};
if sha.is_empty() {
return Err("git ls-remote returned empty sha for curated plugins repo".to_string());
}
Ok(sha.to_string())
}
fn git_head_sha(repo_path: &Path, git_binary: &str) -> Result<String, String> {
let output = Command::new(git_binary)
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("-C")
.arg(repo_path)
.arg("rev-parse")
.arg("HEAD")
.output()
.map_err(|err| {
format!(
"failed to run git rev-parse HEAD in {}: {err}",
repo_path.display()
)
})?;
ensure_git_success(&output, "git rev-parse HEAD")?;
let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
if sha.is_empty() {
return Err(format!(
"git rev-parse HEAD returned empty output in {}",
repo_path.display()
));
}
Ok(sha)
}
fn run_git_command_with_timeout(
command: &mut Command,
context: &str,
timeout: Duration,
) -> Result<Output, String> {
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 {
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}")),
}
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
))
}
}
async fn fetch_curated_repo_remote_sha(api_base_url: &str) -> Result<String, String> {
let api_base_url = api_base_url.trim_end_matches('/');
let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}");
let client = build_reqwest_client();
let repo_body = fetch_github_text(&client, &repo_url, "get curated plugins repository").await?;
let repo_summary: GitHubRepositorySummary =
serde_json::from_str(&repo_body).map_err(|err| {
format!("failed to parse curated plugins repository response from {repo_url}: {err}")
})?;
if repo_summary.default_branch.is_empty() {
return Err(format!(
"curated plugins repository response from {repo_url} did not include a default branch"
));
}
let git_ref_url = format!("{repo_url}/git/ref/heads/{}", repo_summary.default_branch);
let git_ref_body =
fetch_github_text(&client, &git_ref_url, "get curated plugins HEAD ref").await?;
let git_ref: GitHubGitRefSummary = serde_json::from_str(&git_ref_body).map_err(|err| {
format!("failed to parse curated plugins ref response from {git_ref_url}: {err}")
})?;
if git_ref.object.sha.is_empty() {
return Err(format!(
"curated plugins ref response from {git_ref_url} did not include a HEAD sha"
));
}
Ok(git_ref.object.sha)
}
async fn fetch_curated_repo_zipball(
api_base_url: &str,
remote_sha: &str,
) -> Result<Vec<u8>, String> {
let api_base_url = api_base_url.trim_end_matches('/');
let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}");
let zipball_url = format!("{repo_url}/zipball/{remote_sha}");
let client = build_reqwest_client();
fetch_github_bytes(&client, &zipball_url, "download curated plugins archive").await
}
async fn fetch_curated_repo_backup_archive_zip(
backup_archive_api_url: &str,
) -> Result<Vec<u8>, String> {
let client = build_reqwest_client();
let export_body = fetch_public_text(
&client,
backup_archive_api_url,
"get curated plugins export archive metadata",
)
.await?;
let export_response: CuratedPluginsBackupArchiveResponse = serde_json::from_str(&export_body)
.map_err(|err| {
format!(
"failed to parse curated plugins backup archive response from {backup_archive_api_url}: {err}"
)
})?;
if export_response.download_url.is_empty() {
return Err(format!(
"curated plugins backup archive response from {backup_archive_api_url} did not include a download URL"
));
}
fetch_public_bytes(
&client,
&export_response.download_url,
"download curated plugins export archive",
)
.await
}
fn read_extracted_backup_archive_git_sha(repo_path: &Path) -> Result<Option<String>, String> {
let git_dir = repo_path.join(".git");
if !git_dir.is_dir() {
return Ok(None);
}
let head_path = git_dir.join("HEAD");
let head = std::fs::read_to_string(&head_path).map_err(|err| {
format!(
"failed to read curated plugins backup archive git HEAD {}: {err}",
head_path.display()
)
})?;
let head = head.trim();
if head.is_empty() {
return Err(format!(
"curated plugins backup archive git HEAD is empty at {}",
head_path.display()
));
}
if let Some(reference) = head.strip_prefix("ref: ") {
let reference = validate_backup_archive_git_ref(reference.trim())?;
return read_git_ref_sha(&git_dir, reference).map(Some);
}
Ok(Some(head.to_string()))
}
fn validate_backup_archive_git_ref(reference: &str) -> Result<&str, String> {
if !reference.starts_with("refs/") {
return Err(format!(
"curated plugins backup archive git ref must stay under refs/: {reference}"
));
}
let path = Path::new(reference);
if path.is_absolute() {
return Err(format!(
"curated plugins backup archive git ref must be relative: {reference}"
));
}
for component in path.components() {
match component {
std::path::Component::Normal(_) => {}
_ => {
return Err(format!(
"curated plugins backup archive git ref contains invalid path components: {reference}"
));
}
}
}
Ok(reference)
}
fn read_git_ref_sha(git_dir: &Path, reference: &str) -> Result<String, String> {
let ref_path = git_dir.join(reference);
if let Ok(sha) = std::fs::read_to_string(&ref_path) {
let sha = sha.trim();
if sha.is_empty() {
return Err(format!(
"curated plugins backup archive git ref {reference} is empty at {}",
ref_path.display()
));
}
return Ok(sha.to_string());
}
let packed_refs_path = git_dir.join("packed-refs");
if let Ok(packed_refs) = std::fs::read_to_string(&packed_refs_path)
&& let Some(sha) = packed_refs.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('^') {
return None;
}
let (sha, candidate_ref) = trimmed.split_once(' ')?;
(candidate_ref == reference).then_some(sha.to_string())
})
{
return Ok(sha);
}
Err(format!(
"failed to resolve curated plugins backup archive git ref {reference} from {}",
git_dir.display()
))
}
async fn fetch_github_text(client: &Client, url: &str, context: &str) -> Result<String, String> {
let response = github_request(client, url)
.send()
.await
.map_err(|err| format!("failed to {context} from {url}: {err}"))?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(format!(
"{context} from {url} failed with status {status}: {body}"
));
}
Ok(body)
}
async fn fetch_github_bytes(client: &Client, url: &str, context: &str) -> Result<Vec<u8>, String> {
let response = github_request(client, url)
.send()
.await
.map_err(|err| format!("failed to {context} from {url}: {err}"))?;
let status = response.status();
let body = response
.bytes()
.await
.map_err(|err| format!("failed to read {context} response from {url}: {err}"))?;
if !status.is_success() {
let body_text = String::from_utf8_lossy(&body);
return Err(format!(
"{context} from {url} failed with status {status}: {body_text}"
));
}
Ok(body.to_vec())
}
async fn fetch_public_text(client: &Client, url: &str, context: &str) -> Result<String, String> {
let response = client
.get(url)
.timeout(CURATED_PLUGINS_BACKUP_ARCHIVE_TIMEOUT)
.send()
.await
.map_err(|err| format!("failed to {context} from {url}: {err}"))?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(format!(
"{context} from {url} failed with status {status}: {body}"
));
}
Ok(body)
}
async fn fetch_public_bytes(client: &Client, url: &str, context: &str) -> Result<Vec<u8>, String> {
let response = client
.get(url)
.timeout(CURATED_PLUGINS_BACKUP_ARCHIVE_TIMEOUT)
.send()
.await
.map_err(|err| format!("failed to {context} from {url}: {err}"))?;
let status = response.status();
let body = response
.bytes()
.await
.map_err(|err| format!("failed to read {context} response from {url}: {err}"))?;
if !status.is_success() {
let body_text = String::from_utf8_lossy(&body);
return Err(format!(
"{context} from {url} failed with status {status}: {body_text}"
));
}
Ok(body.to_vec())
}
fn github_request(client: &Client, url: &str) -> reqwest::RequestBuilder {
client
.get(url)
.timeout(CURATED_PLUGINS_HTTP_TIMEOUT)
.header("accept", GITHUB_API_ACCEPT_HEADER)
.header("x-github-api-version", GITHUB_API_VERSION_HEADER)
}
fn read_sha_file(sha_path: &Path) -> Option<String> {
std::fs::read_to_string(sha_path)
.ok()
.map(|sha| sha.trim().to_string())
.filter(|sha| !sha.is_empty())
}
fn extract_zipball_to_dir(bytes: &[u8], destination: &Path) -> Result<(), String> {
std::fs::create_dir_all(destination).map_err(|err| {
format!(
"failed to create curated plugins extraction directory {}: {err}",
destination.display()
)
})?;
let cursor = std::io::Cursor::new(bytes);
let mut archive = ZipArchive::new(cursor)
.map_err(|err| format!("failed to open curated plugins zip archive: {err}"))?;
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|err| format!("failed to read curated plugins zip entry: {err}"))?;
let Some(relative_path) = entry.enclosed_name() else {
return Err(format!(
"curated plugins zip entry `{}` escapes extraction root",
entry.name()
));
};
let mut components = relative_path.components();
let Some(std::path::Component::Normal(_)) = components.next() else {
continue;
};
let output_relative = components.fold(PathBuf::new(), |mut path, component| {
if let std::path::Component::Normal(segment) = component {
path.push(segment);
}
path
});
if output_relative.as_os_str().is_empty() {
continue;
}
let output_path = destination.join(&output_relative);
if entry.is_dir() {
std::fs::create_dir_all(&output_path).map_err(|err| {
format!(
"failed to create curated plugins directory {}: {err}",
output_path.display()
)
})?;
continue;
}
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create curated plugins directory {}: {err}",
parent.display()
)
})?;
}
let mut output = std::fs::File::create(&output_path).map_err(|err| {
format!(
"failed to create curated plugins file {}: {err}",
output_path.display()
)
})?;
std::io::copy(&mut entry, &mut output).map_err(|err| {
format!(
"failed to write curated plugins file {}: {err}",
output_path.display()
)
})?;
apply_zip_permissions(&entry, &output_path)?;
}
Ok(())
}
#[cfg(unix)]
fn apply_zip_permissions(entry: &zip::read::ZipFile<'_>, output_path: &Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let Some(mode) = entry.unix_mode() else {
return Ok(());
};
std::fs::set_permissions(output_path, std::fs::Permissions::from_mode(mode)).map_err(|err| {
format!(
"failed to set permissions on curated plugins file {}: {err}",
output_path.display()
)
})
}
#[cfg(not(unix))]
fn apply_zip_permissions(
_entry: &zip::read::ZipFile<'_>,
_output_path: &Path,
) -> Result<(), String> {
Ok(())
}
#[cfg(test)]
#[path = "startup_sync_tests.rs"]
mod tests;
+5 -707
View File
@@ -1,14 +1,16 @@
use super::*;
use crate::config::CONFIG_TOML_FILE;
use crate::plugins::PluginsManager;
use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA;
use crate::plugins::test_support::write_curated_plugin_sha;
use crate::plugins::test_support::write_file;
use crate::plugins::test_support::write_openai_curated_marketplace;
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use pretty_assertions::assert_eq;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tempfile::tempdir;
use wiremock::Mock;
use wiremock::MockServer;
@@ -16,627 +18,6 @@ use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use zip::ZipWriter;
use zip::write::SimpleFileOptions;
fn has_plugins_clone_dirs(codex_home: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(codex_home.join(".tmp")) else {
return false;
};
entries.flatten().any(|entry| {
let path = entry.path();
path.is_dir()
&& path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("plugins-clone-"))
})
}
#[cfg(unix)]
fn write_executable_script(path: &Path, contents: &str) {
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
std::fs::write(path, contents).expect("write script");
#[cfg(unix)]
{
let mut permissions = std::fs::metadata(path).expect("metadata").permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(path, permissions).expect("chmod");
}
}
async fn mount_github_repo_and_ref(server: &MockServer, sha: &str) {
Mock::given(method("GET"))
.and(path("/repos/openai/plugins"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#))
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/repos/openai/plugins/git/ref/heads/main"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)),
)
.mount(server)
.await;
}
async fn mount_github_zipball(server: &MockServer, sha: &str, bytes: Vec<u8>) {
Mock::given(method("GET"))
.and(path(format!("/repos/openai/plugins/zipball/{sha}")))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/zip")
.set_body_bytes(bytes),
)
.mount(server)
.await;
}
async fn mount_export_archive(server: &MockServer, bytes: Vec<u8>) -> String {
let export_api_url = format!("{}/backend-api/plugins/export/curated", server.uri());
Mock::given(method("GET"))
.and(path("/backend-api/plugins/export/curated"))
.respond_with(ResponseTemplate::new(200).set_body_string(format!(
r#"{{"download_url":"{}/files/curated-plugins.zip"}}"#,
server.uri()
)))
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/files/curated-plugins.zip"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/zip")
.set_body_bytes(bytes),
)
.mount(server)
.await;
export_api_url
}
async fn run_sync_with_transport_overrides(
codex_home: PathBuf,
git_binary: impl Into<String>,
api_base_url: impl Into<String>,
backup_archive_api_url: impl Into<String>,
) -> Result<String, String> {
let git_binary = git_binary.into();
let api_base_url = api_base_url.into();
let backup_archive_api_url = backup_archive_api_url.into();
tokio::task::spawn_blocking(move || {
sync_openai_plugins_repo_with_transport_overrides(
codex_home.as_path(),
&git_binary,
&api_base_url,
&backup_archive_api_url,
)
})
.await
.expect("sync task should join")
}
async fn run_http_sync(
codex_home: PathBuf,
api_base_url: impl Into<String>,
) -> Result<String, String> {
let api_base_url = api_base_url.into();
tokio::task::spawn_blocking(move || {
sync_openai_plugins_repo_via_http(codex_home.as_path(), &api_base_url)
})
.await
.expect("sync task should join")
}
fn assert_curated_gmail_repo(repo_path: &Path) {
assert!(repo_path.join(".agents/plugins/marketplace.json").is_file());
assert!(
repo_path
.join("plugins/gmail/.codex-plugin/plugin.json")
.is_file()
);
}
#[test]
fn curated_plugins_repo_path_uses_codex_home_tmp_dir() {
let tmp = tempdir().expect("tempdir");
assert_eq!(
curated_plugins_repo_path(tmp.path()),
tmp.path().join(".tmp/plugins")
);
}
#[test]
fn read_curated_plugins_sha_reads_trimmed_sha_file() {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp");
std::fs::write(tmp.path().join(".tmp/plugins.sha"), "abc123\n").expect("write sha");
assert_eq!(
read_curated_plugins_sha(tmp.path()).as_deref(),
Some("abc123")
);
}
#[cfg(unix)]
#[test]
fn remove_stale_curated_repo_temp_dirs_removes_only_matching_directories() {
use std::os::unix::ffi::OsStrExt;
use std::time::SystemTime;
fn set_dir_mtime(path: &Path, age: Duration) -> Result<(), Box<dyn std::error::Error>> {
let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
let modified_at = now.saturating_sub(age);
let tv_sec = i64::try_from(modified_at.as_secs())?;
let ts = libc::timespec { tv_sec, tv_nsec: 0 };
let times = [ts, ts];
let c_path = std::ffi::CString::new(path.as_os_str().as_bytes())?;
let result = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
if result != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}
let tmp = tempdir().expect("tempdir");
let parent = tmp.path().join(".tmp");
let stale_clone_dir = parent.join("plugins-clone-stale");
let fresh_clone_dir = parent.join("plugins-clone-fresh");
let unrelated_dir = parent.join("plugins-cache");
std::fs::create_dir_all(&stale_clone_dir).expect("create stale clone dir");
std::fs::create_dir_all(&fresh_clone_dir).expect("create fresh clone dir");
std::fs::create_dir_all(&unrelated_dir).expect("create unrelated dir");
set_dir_mtime(
&stale_clone_dir,
CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE + Duration::from_secs(60),
)
.expect("age stale clone dir");
set_dir_mtime(&fresh_clone_dir, Duration::ZERO).expect("age fresh clone dir");
remove_stale_curated_repo_temp_dirs(&parent, CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE);
assert!(!stale_clone_dir.exists());
assert!(fresh_clone_dir.is_dir());
assert!(unrelated_dir.is_dir());
}
#[cfg(unix)]
#[test]
fn sync_openai_plugins_repo_prefers_git_when_available() {
let tmp = tempdir().expect("tempdir");
let bin_dir = tempfile::Builder::new()
.prefix("fake-git-")
.tempdir()
.expect("tempdir");
let git_path = bin_dir.path().join("git");
let sha = "0123456789abcdef0123456789abcdef01234567";
write_executable_script(
&git_path,
&format!(
r#"#!/bin/sh
if [ "$1" = "ls-remote" ]; then
printf '%s\tHEAD\n' "{sha}"
exit 0
fi
if [ "$1" = "clone" ]; then
dest="$5"
mkdir -p "$dest/.git" "$dest/.agents/plugins" "$dest/plugins/gmail/.codex-plugin"
cat > "$dest/.agents/plugins/marketplace.json" <<'EOF'
{{"name":"openai-curated","plugins":[{{"name":"gmail","source":{{"source":"local","path":"./plugins/gmail"}}}}]}}
EOF
printf '%s\n' '{{"name":"gmail"}}' > "$dest/plugins/gmail/.codex-plugin/plugin.json"
exit 0
fi
if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then
printf '%s\n' "{sha}"
exit 0
fi
echo "unexpected git invocation: $@" >&2
exit 1
"#
),
);
let synced_sha = sync_openai_plugins_repo_with_transport_overrides(
tmp.path(),
git_path.to_str().expect("utf8 path"),
"http://127.0.0.1:9",
"http://127.0.0.1:9/backend-api/plugins/export/curated",
)
.expect("git sync should succeed");
assert_eq!(synced_sha, sha);
let repo_path = curated_plugins_repo_path(tmp.path());
assert!(repo_path.join(".git").is_dir());
assert_curated_gmail_repo(&repo_path);
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
}
#[cfg(unix)]
#[test]
fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() {
let tmp = tempdir().expect("tempdir");
let repo_root = tempfile::Builder::new()
.prefix("curated-repo-success-")
.tempdir()
.expect("tempdir");
let work_repo = repo_root.path().join("work/plugins");
let remote_repo = repo_root.path().join("remotes/openai/plugins.git");
std::fs::create_dir_all(work_repo.join(".agents/plugins")).expect("create marketplace dir");
std::fs::create_dir_all(work_repo.join("plugins/gmail/.codex-plugin"))
.expect("create plugin dir");
std::fs::write(
work_repo.join(".agents/plugins/marketplace.json"),
r#"{"name":"openai-curated","plugins":[{"name":"gmail","source":{"source":"local","path":"./plugins/gmail"}}]}"#,
)
.expect("write marketplace");
std::fs::write(
work_repo.join("plugins/gmail/.codex-plugin/plugin.json"),
r#"{"name":"gmail"}"#,
)
.expect("write plugin manifest");
let init_status = Command::new("git")
.arg("-C")
.arg(&work_repo)
.arg("init")
.status()
.expect("run git init");
assert!(init_status.success());
let add_status = Command::new("git")
.arg("-C")
.arg(&work_repo)
.arg("add")
.arg(".")
.status()
.expect("run git add");
assert!(add_status.success());
let commit_status = Command::new("git")
.arg("-C")
.arg(&work_repo)
.arg("-c")
.arg("user.name=Codex Test")
.arg("-c")
.arg("user.email=codex@example.com")
.arg("commit")
.arg("-m")
.arg("init")
.status()
.expect("run git commit");
assert!(commit_status.success());
std::fs::create_dir_all(remote_repo.parent().expect("remote parent"))
.expect("create remote parent");
let clone_status = Command::new("git")
.arg("clone")
.arg("--bare")
.arg(&work_repo)
.arg(&remote_repo)
.status()
.expect("run git clone --bare");
assert!(clone_status.success());
let sha_output = Command::new("git")
.arg("-C")
.arg(&work_repo)
.arg("rev-parse")
.arg("HEAD")
.output()
.expect("run git rev-parse");
assert!(sha_output.status.success());
let sha = String::from_utf8_lossy(&sha_output.stdout)
.trim()
.to_string();
let git_config_path = repo_root.path().join("git-rewrite.conf");
std::fs::write(
&git_config_path,
format!(
"[url \"file://{}/\"]\n insteadOf = https://github.com/\n",
repo_root.path().join("remotes").display()
),
)
.expect("write git config");
let bin_dir = tempfile::Builder::new()
.prefix("git-rewrite-wrapper-")
.tempdir()
.expect("tempdir");
let git_wrapper = bin_dir.path().join("git");
write_executable_script(
&git_wrapper,
&format!(
"#!/bin/sh\nGIT_CONFIG_GLOBAL='{}' exec git \"$@\"\n",
git_config_path.display()
),
);
let synced_sha =
sync_openai_plugins_repo_via_git(tmp.path(), git_wrapper.to_str().expect("utf8 path"))
.expect("git sync should succeed");
assert_eq!(synced_sha, sha);
assert_curated_gmail_repo(&curated_plugins_repo_path(tmp.path()));
assert_eq!(
read_curated_plugins_sha(tmp.path()).as_deref(),
Some(sha.as_str())
);
assert!(!has_plugins_clone_dirs(tmp.path()));
}
#[tokio::test]
async fn sync_openai_plugins_repo_falls_back_to_http_when_git_is_unavailable() {
let tmp = tempdir().expect("tempdir");
let server = MockServer::start().await;
let sha = "0123456789abcdef0123456789abcdef01234567";
mount_github_repo_and_ref(&server, sha).await;
mount_github_zipball(&server, sha, curated_repo_zipball_bytes(sha)).await;
let synced_sha = run_sync_with_transport_overrides(
tmp.path().to_path_buf(),
"missing-git-for-test",
server.uri(),
"http://127.0.0.1:9/backend-api/plugins/export/curated",
)
.await
.expect("fallback sync should succeed");
let repo_path = curated_plugins_repo_path(tmp.path());
assert_eq!(synced_sha, sha);
assert_curated_gmail_repo(&repo_path);
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
}
#[cfg(unix)]
#[tokio::test]
async fn sync_openai_plugins_repo_falls_back_to_http_when_git_sync_fails() {
let tmp = tempdir().expect("tempdir");
let bin_dir = tempfile::Builder::new()
.prefix("fake-git-fail-")
.tempdir()
.expect("tempdir");
let git_path = bin_dir.path().join("git");
let sha = "0123456789abcdef0123456789abcdef01234567";
write_executable_script(
&git_path,
r#"#!/bin/sh
echo "simulated git failure" >&2
exit 1
"#,
);
let server = MockServer::start().await;
mount_github_repo_and_ref(&server, sha).await;
mount_github_zipball(&server, sha, curated_repo_zipball_bytes(sha)).await;
let synced_sha = run_sync_with_transport_overrides(
tmp.path().to_path_buf(),
git_path.to_str().expect("utf8 path"),
server.uri(),
"http://127.0.0.1:9/backend-api/plugins/export/curated",
)
.await
.expect("fallback sync should succeed");
let repo_path = curated_plugins_repo_path(tmp.path());
assert_eq!(synced_sha, sha);
assert_curated_gmail_repo(&repo_path);
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
}
#[cfg(unix)]
#[test]
fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_clone_failure() {
let tmp = tempdir().expect("tempdir");
let bin_dir = tempfile::Builder::new()
.prefix("fake-git-partial-fail-")
.tempdir()
.expect("tempdir");
let git_path = bin_dir.path().join("git");
let sha = "0123456789abcdef0123456789abcdef01234567";
write_executable_script(
&git_path,
&format!(
r#"#!/bin/sh
if [ "$1" = "ls-remote" ]; then
printf '%s\tHEAD\n' "{sha}"
exit 0
fi
if [ "$1" = "clone" ]; then
dest="$5"
mkdir -p "$dest/.git"
echo "fatal: early EOF" >&2
exit 128
fi
echo "unexpected git invocation: $@" >&2
exit 1
"#
),
);
let err = sync_openai_plugins_repo_via_git(tmp.path(), git_path.to_str().expect("utf8 path"))
.expect_err("git sync should fail");
assert!(err.contains("fatal: early EOF"));
assert!(!has_plugins_clone_dirs(tmp.path()));
}
#[tokio::test]
async fn sync_openai_plugins_repo_via_http_cleans_up_staged_dir_on_extract_failure() {
let tmp = tempdir().expect("tempdir");
let server = MockServer::start().await;
let sha = "0123456789abcdef0123456789abcdef01234567";
mount_github_repo_and_ref(&server, sha).await;
mount_github_zipball(&server, sha, b"not a zip archive".to_vec()).await;
let err = run_http_sync(tmp.path().to_path_buf(), server.uri())
.await
.expect_err("http sync should fail");
assert!(err.contains("failed to open curated plugins zip archive"));
assert!(!has_plugins_clone_dirs(tmp.path()));
}
#[tokio::test]
async fn sync_openai_plugins_repo_skips_archive_download_when_sha_matches() {
let tmp = tempdir().expect("tempdir");
let repo_path = curated_plugins_repo_path(tmp.path());
std::fs::create_dir_all(repo_path.join(".agents/plugins")).expect("create repo");
std::fs::write(
repo_path.join(".agents/plugins/marketplace.json"),
r#"{"name":"openai-curated","plugins":[]}"#,
)
.expect("write marketplace");
std::fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp");
let sha = "fedcba9876543210fedcba9876543210fedcba98";
std::fs::write(tmp.path().join(".tmp/plugins.sha"), format!("{sha}\n")).expect("write sha");
let server = MockServer::start().await;
mount_github_repo_and_ref(&server, sha).await;
run_sync_with_transport_overrides(
tmp.path().to_path_buf(),
"missing-git-for-test",
server.uri(),
"http://127.0.0.1:9/backend-api/plugins/export/curated",
)
.await
.expect("sync should succeed");
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
assert!(repo_path.join(".agents/plugins/marketplace.json").is_file());
}
#[tokio::test]
async fn sync_openai_plugins_repo_falls_back_to_export_archive_when_no_snapshot_exists() {
let tmp = tempdir().expect("tempdir");
let server = MockServer::start().await;
let export_sha = "1111111111111111111111111111111111111111";
Mock::given(method("GET"))
.and(path("/repos/openai/plugins"))
.respond_with(ResponseTemplate::new(500).set_body_string("github repo lookup failed"))
.mount(&server)
.await;
let export_api_url =
mount_export_archive(&server, curated_repo_backup_archive_zip_bytes(export_sha)).await;
let synced_sha = run_sync_with_transport_overrides(
tmp.path().to_path_buf(),
"missing-git-for-test",
server.uri(),
export_api_url,
)
.await
.expect("export fallback sync should succeed");
let repo_path = curated_plugins_repo_path(tmp.path());
assert_eq!(synced_sha, export_sha);
assert_curated_gmail_repo(&repo_path);
assert_eq!(
read_curated_plugins_sha(tmp.path()).as_deref(),
Some(export_sha)
);
}
#[tokio::test]
async fn sync_openai_plugins_repo_skips_export_archive_when_snapshot_exists() {
let tmp = tempdir().expect("tempdir");
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["linear"]);
write_curated_plugin_sha(tmp.path());
let plugin_manifest_path = curated_root.join("plugins/linear/.codex-plugin/plugin.json");
let original_manifest =
std::fs::read_to_string(&plugin_manifest_path).expect("read existing plugin manifest");
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/openai/plugins"))
.respond_with(ResponseTemplate::new(500).set_body_string("github repo lookup failed"))
.mount(&server)
.await;
let export_api_url = mount_export_archive(
&server,
curated_repo_backup_archive_zip_bytes("2222222222222222222222222222222222222222"),
)
.await;
let err = run_sync_with_transport_overrides(
tmp.path().to_path_buf(),
"missing-git-for-test",
server.uri(),
export_api_url,
)
.await
.expect_err("existing snapshot should suppress export fallback");
assert!(err.contains("export archive fallback skipped"));
assert_eq!(
std::fs::read_to_string(&plugin_manifest_path).expect("read plugin manifest after sync"),
original_manifest
);
assert_eq!(
read_curated_plugins_sha(tmp.path()).as_deref(),
Some(TEST_CURATED_PLUGIN_SHA)
);
}
#[test]
fn read_extracted_backup_archive_git_sha_reads_head_ref_from_extracted_repo() {
let tmp = tempdir().expect("tempdir");
let git_dir = tmp.path().join(".git/refs/heads");
std::fs::create_dir_all(&git_dir).expect("create git ref dir");
std::fs::write(tmp.path().join(".git/HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
std::fs::write(
git_dir.join("main"),
"3333333333333333333333333333333333333333\n",
)
.expect("write main ref");
assert_eq!(
read_extracted_backup_archive_git_sha(tmp.path())
.expect("read extracted backup archive git sha"),
Some("3333333333333333333333333333333333333333".to_string())
);
}
#[test]
fn read_extracted_backup_archive_git_sha_rejects_non_refs_head_target() {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join(".git")).expect("create git dir");
std::fs::write(tmp.path().join(".git/HEAD"), "ref: HEAD\n").expect("write HEAD");
let err = read_extracted_backup_archive_git_sha(tmp.path())
.expect_err("non-refs target should be rejected");
assert!(err.contains("must stay under refs/"));
}
#[test]
fn read_extracted_backup_archive_git_sha_rejects_path_traversal_ref() {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join(".git")).expect("create git dir");
std::fs::write(tmp.path().join(".git/HEAD"), "ref: refs/heads/../../evil\n")
.expect("write HEAD");
let err = read_extracted_backup_archive_git_sha(tmp.path())
.expect_err("path traversal ref should be rejected");
assert!(err.contains("invalid path components"));
}
#[tokio::test]
async fn startup_remote_plugin_sync_writes_marker_and_reconciles_state() {
@@ -707,86 +88,3 @@ enabled = false
let marker_contents = std::fs::read_to_string(marker_path).expect("marker should be readable");
assert_eq!(marker_contents, "ok\n");
}
fn curated_repo_zipball_bytes(sha: &str) -> Vec<u8> {
let cursor = std::io::Cursor::new(Vec::new());
let mut writer = ZipWriter::new(cursor);
let options = SimpleFileOptions::default();
let root = format!("openai-plugins-{sha}");
writer
.start_file(format!("{root}/.agents/plugins/marketplace.json"), options)
.expect("start marketplace entry");
writer
.write_all(
br#"{
"name": "openai-curated",
"plugins": [
{
"name": "gmail",
"source": {
"source": "local",
"path": "./plugins/gmail"
}
}
]
}"#,
)
.expect("write marketplace");
writer
.start_file(
format!("{root}/plugins/gmail/.codex-plugin/plugin.json"),
options,
)
.expect("start plugin manifest entry");
writer
.write_all(br#"{"name":"gmail"}"#)
.expect("write plugin manifest");
writer.finish().expect("finish zip writer").into_inner()
}
fn curated_repo_backup_archive_zip_bytes(sha: &str) -> Vec<u8> {
let cursor = std::io::Cursor::new(Vec::new());
let mut writer = ZipWriter::new(cursor);
let options = SimpleFileOptions::default();
writer
.start_file("plugins/.git/HEAD", options)
.expect("start HEAD entry");
writer
.write_all(b"ref: refs/heads/main\n")
.expect("write HEAD");
writer
.start_file("plugins/.git/refs/heads/main", options)
.expect("start main ref entry");
writer
.write_all(format!("{sha}\n").as_bytes())
.expect("write main ref");
writer
.start_file("plugins/.agents/plugins/marketplace.json", options)
.expect("start marketplace entry");
writer
.write_all(
br#"{
"name": "openai-curated",
"plugins": [
{
"name": "gmail",
"source": {
"source": "local",
"path": "./plugins/gmail"
}
}
]
}"#,
)
.expect("write marketplace");
writer
.start_file("plugins/plugins/gmail/.codex-plugin/plugin.json", options)
.expect("start plugin manifest entry");
writer
.write_all(br#"{"name":"gmail"}"#)
.expect("write plugin manifest");
writer.finish().expect("finish zip writer").into_inner()
}
+1 -1
View File
@@ -3,7 +3,7 @@ use crate::config::ConfigBuilder;
use std::fs;
use std::path::Path;
use super::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
pub(crate) const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";