Files
codex/codex-rs/cli/tests/marketplace_add.rs
T
Dmitriy Kovalenko 69727ae170 Clean up failed and abandoned marketplace staging directories (#55689)
## Why

Failed marketplace adds can leave temporary checkouts behind, and interrupted adds or upgrades can leave abandoned staging directories.

## What changed

- Share a staging guard across marketplace adds and upgrades so temporary checkouts are removed on completion or failure, including config-write rollback.
- Reclaim up to four abandoned directories per cleanup pass during add and upgrade operations. Use file locks to preserve active checkouts and operating-system-specific names to avoid reclaiming another platform's staging directories.
- Skip symlinks and unrelated entries, and report cleanup failures as warnings.

## Testing

Add regression coverage for failed adds, CLI and automatic-upgrade cleanup, cross-process ownership and abandonment, lock contention, bounded cleanup, and symlink handling.

GitOrigin-RevId: 20999da0eab861dfb8025aad5e9fba2bcaf818b8
2026-10-20 22:25:54 +00:00

254 lines
8.2 KiB
Rust

use anyhow::Result;
use codex_config::CONFIG_TOML_FILE;
use codex_core_plugins::installed_marketplaces::marketplace_install_root;
use codex_utils_absolute_path::AbsolutePathBuf;
use predicates::str::contains;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::path::Path;
use tempfile::TempDir;
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
cmd.env("CODEX_HOME", codex_home);
Ok(cmd)
}
fn write_marketplace_source(source: &Path, marker: &str) -> Result<()> {
std::fs::create_dir_all(source.join(".agents/plugins"))?;
std::fs::create_dir_all(source.join("plugins/sample/.codex-plugin"))?;
std::fs::write(
source.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
}
}
]
}"#,
)?;
std::fs::write(
source.join("plugins/sample/.codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)?;
std::fs::write(source.join("plugins/sample/marker.txt"), marker)?;
Ok(())
}
#[tokio::test]
async fn marketplace_add_git_source_cleans_staging_after_failures() -> Result<()> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_marketplace_source(source.path(), "git source")?;
for args in [
vec!["init", "--quiet"],
vec!["add", "."],
vec![
"-c",
"user.name=Codex Tests",
"-c",
"user.email=codex@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"marketplace",
],
] {
let output = std::process::Command::new("git")
.current_dir(source.path())
.args(args)
.output()?;
anyhow::ensure!(
output.status.success(),
"git fixture failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
// Exercise real Git clones without using the network or changing the process environment.
let source_url = "https://example.com/marketplace.git";
let rewrite_key = format!("url.{}.insteadOf", source.path().display());
let install_root = marketplace_install_root(codex_home.path());
let staging_root = install_root.join(".staging");
let abandoned = staging_root.join(format!("marketplace-{}-abandoned", std::env::consts::OS));
std::fs::create_dir_all(abandoned.join("checkout"))?;
std::fs::write(abandoned.join(".lock"), "")?;
std::fs::write(abandoned.join("checkout/payload"), "abandoned clone")?;
for (args, error) in [
(["--ref", "missing-ref"], "git checkout missing-ref failed"),
(
["--sparse", "plugins/sample"],
"marketplace root does not contain a supported manifest",
),
] {
for _ in 0..2 {
codex_command(codex_home.path())?
.current_dir(codex_home.path())
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", &rewrite_key)
.env("GIT_CONFIG_VALUE_0", source_url)
.args(["plugin", "marketplace", "add", source_url])
.args(args)
.assert()
.failure()
.stderr(contains(error));
assert_eq!(std::fs::read_dir(&staging_root)?.count(), 0);
assert!(!install_root.join("debug").exists());
assert!(!codex_home.path().join(CONFIG_TOML_FILE).exists());
}
}
codex_command(codex_home.path())?
.current_dir(codex_home.path())
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", &rewrite_key)
.env("GIT_CONFIG_VALUE_0", source_url)
.args(["plugin", "marketplace", "add", source_url])
.assert()
.success();
let config = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?;
// A different ref bypasses the already-added shortcut and conflicts after cloning.
codex_command(codex_home.path())?
.current_dir(codex_home.path())
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", &rewrite_key)
.env("GIT_CONFIG_VALUE_0", source_url)
.args(["plugin", "marketplace", "add", source_url, "--ref", "HEAD"])
.assert()
.failure()
.stderr(contains("already added from a different source"));
assert_eq!(std::fs::read_dir(&staging_root)?.count(), 0);
assert_eq!(
std::fs::read_to_string(install_root.join("debug/plugins/sample/marker.txt"))?,
"git source"
);
assert_eq!(
std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?,
config
);
Ok(())
}
#[tokio::test]
async fn marketplace_add_local_directory_source() -> Result<()> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_marketplace_source(source.path(), "local ref")?;
let source_parent = source.path().parent().unwrap();
let source_arg = format!("./{}", source.path().file_name().unwrap().to_string_lossy());
codex_command(codex_home.path())?
.current_dir(source_parent)
.args(["plugin", "marketplace", "add", source_arg.as_str()])
.assert()
.success();
let installed_root = marketplace_install_root(codex_home.path()).join("debug");
assert!(!installed_root.exists());
let config = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?;
let config: toml::Value = toml::from_str(&config)?;
let expected_source = source.path().canonicalize()?.display().to_string();
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(())
}
#[tokio::test]
async fn marketplace_add_json_prints_add_outcome() -> Result<()> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_marketplace_source(source.path(), "local ref")?;
let source_parent = source.path().parent().unwrap();
let source_arg = format!("./{}", source.path().file_name().unwrap().to_string_lossy());
let assert = codex_command(codex_home.path())?
.current_dir(source_parent)
.args([
"plugin",
"marketplace",
"add",
source_arg.as_str(),
"--json",
])
.assert()
.success();
let stdout = assert.get_output().stdout.as_slice();
let actual: serde_json::Value = serde_json::from_slice(stdout)?;
let expected_installed_root = AbsolutePathBuf::try_from(source.path().canonicalize()?)?;
assert_eq!(
actual,
json!({
"marketplaceName": "debug",
"installedRoot": expected_installed_root.as_path().display().to_string(),
"alreadyAdded": false,
})
);
Ok(())
}
#[tokio::test]
async fn marketplace_add_rejects_local_manifest_file_source() -> Result<()> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_marketplace_source(source.path(), "local ref")?;
let manifest_path = source.path().join(".agents/plugins/marketplace.json");
codex_command(codex_home.path())?
.args([
"plugin",
"marketplace",
"add",
manifest_path.to_str().unwrap(),
])
.assert()
.failure()
.stderr(contains(
"local marketplace source must be a directory, not a file",
));
Ok(())
}
#[tokio::test]
async fn marketplace_add_rejects_sparse_for_local_directory_source() -> Result<()> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_marketplace_source(source.path(), "local ref")?;
codex_command(codex_home.path())?
.args([
"plugin",
"marketplace",
"add",
"--sparse",
".agents",
source.path().to_str().unwrap(),
])
.assert()
.failure()
.stderr(contains(
"--sparse is only supported for git marketplace sources",
));
Ok(())
}