mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Store marketplace metadata in config
This commit is contained in:
@@ -2,10 +2,11 @@ use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use clap::Parser;
|
||||
use codex_config::MarketplaceConfigUpdate;
|
||||
use codex_config::record_user_marketplace;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use codex_core::plugins::marketplace_install_root;
|
||||
use codex_core::plugins::record_installed_marketplace_root;
|
||||
use codex_core::plugins::validate_marketplace_root;
|
||||
use codex_core::plugins::validate_plugin_segment;
|
||||
use codex_utils_cli::CliConfigOverrides;
|
||||
@@ -13,6 +14,8 @@ use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
mod metadata;
|
||||
|
||||
@@ -53,12 +56,10 @@ struct AddMarketplaceArgs {
|
||||
pub(super) enum MarketplaceSource {
|
||||
LocalDirectory {
|
||||
path: PathBuf,
|
||||
source_id: String,
|
||||
},
|
||||
Git {
|
||||
url: String,
|
||||
ref_name: Option<String>,
|
||||
source_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -105,15 +106,18 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> {
|
||||
})?;
|
||||
let install_metadata =
|
||||
metadata::MarketplaceInstallMetadata::from_source(&source, &sparse_paths);
|
||||
if let Some(existing_root) =
|
||||
metadata::installed_marketplace_root_for_source(&install_root, &install_metadata.source_id)?
|
||||
{
|
||||
if let Some(existing_root) = metadata::installed_marketplace_root_for_source(
|
||||
&codex_home,
|
||||
&install_root,
|
||||
&install_metadata,
|
||||
)? {
|
||||
let marketplace_name = validate_marketplace_root(&existing_root).with_context(|| {
|
||||
format!(
|
||||
"failed to validate installed marketplace at {}",
|
||||
existing_root.display()
|
||||
)
|
||||
})?;
|
||||
record_added_marketplace(&codex_home, &marketplace_name, &install_metadata)?;
|
||||
println!(
|
||||
"Marketplace `{marketplace_name}` is already added from {}.",
|
||||
source.display()
|
||||
@@ -141,7 +145,7 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> {
|
||||
let staged_root = staged_dir.path().to_path_buf();
|
||||
|
||||
match &source {
|
||||
MarketplaceSource::LocalDirectory { path, .. } => {
|
||||
MarketplaceSource::LocalDirectory { path } => {
|
||||
copy_dir_recursive(path, &staged_root).with_context(|| {
|
||||
format!(
|
||||
"failed to copy marketplace source {} into {}",
|
||||
@@ -150,7 +154,7 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> {
|
||||
)
|
||||
})?;
|
||||
}
|
||||
MarketplaceSource::Git { url, ref_name, .. } => {
|
||||
MarketplaceSource::Git { url, ref_name } => {
|
||||
clone_git_source(url, ref_name.as_deref(), &sparse_paths, &staged_root)?;
|
||||
}
|
||||
}
|
||||
@@ -163,7 +167,6 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> {
|
||||
source.display()
|
||||
);
|
||||
}
|
||||
metadata::write_marketplace_source_metadata(&staged_root, &install_metadata)?;
|
||||
let destination = install_root.join(safe_marketplace_dir_name(&marketplace_name)?);
|
||||
ensure_marketplace_destination_is_inside_install_root(&install_root, &destination)?;
|
||||
if destination.exists() {
|
||||
@@ -174,8 +177,7 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> {
|
||||
}
|
||||
replace_marketplace_root(&staged_root, &destination)
|
||||
.with_context(|| format!("failed to install marketplace at {}", destination.display()))?;
|
||||
record_installed_marketplace_root(&codex_home, &marketplace_name, &destination)
|
||||
.with_context(|| format!("failed to record marketplace `{marketplace_name}`"))?;
|
||||
record_added_marketplace(&codex_home, &marketplace_name, &install_metadata)?;
|
||||
|
||||
println!(
|
||||
"Added marketplace `{marketplace_name}` from {}.",
|
||||
@@ -186,6 +188,26 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_added_marketplace(
|
||||
codex_home: &Path,
|
||||
marketplace_name: &str,
|
||||
install_metadata: &metadata::MarketplaceInstallMetadata,
|
||||
) -> Result<()> {
|
||||
let source = install_metadata.config_source();
|
||||
let last_updated = utc_timestamp_now()?;
|
||||
let update = MarketplaceConfigUpdate {
|
||||
last_updated: &last_updated,
|
||||
source_type: install_metadata.config_source_type(),
|
||||
source: &source,
|
||||
ref_name: install_metadata.ref_name(),
|
||||
sparse_paths: install_metadata.sparse_paths(),
|
||||
};
|
||||
record_user_marketplace(codex_home, marketplace_name, &update).with_context(|| {
|
||||
format!("failed to add marketplace `{marketplace_name}` to user config.toml")
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_marketplace_source_root(root: &Path) -> Result<String> {
|
||||
let marketplace_name = validate_marketplace_root(root)?;
|
||||
validate_plugin_segment(&marketplace_name, "marketplace name").map_err(anyhow::Error::msg)?;
|
||||
@@ -243,10 +265,7 @@ fn parse_marketplace_source(
|
||||
let path = path
|
||||
.canonicalize()
|
||||
.with_context(|| format!("failed to resolve {}", path.display()))?;
|
||||
return Ok(MarketplaceSource::LocalDirectory {
|
||||
source_id: format!("directory:{}", path.display()),
|
||||
path,
|
||||
});
|
||||
return Ok(MarketplaceSource::LocalDirectory { path });
|
||||
}
|
||||
|
||||
let (base_source, parsed_ref) = split_source_ref(&source);
|
||||
@@ -254,20 +273,12 @@ fn parse_marketplace_source(
|
||||
|
||||
if is_ssh_git_url(&base_source) || is_http_git_url(&base_source) {
|
||||
let url = normalize_git_url(&base_source);
|
||||
return Ok(MarketplaceSource::Git {
|
||||
source_id: git_source_id("git", &url, ref_name.as_deref()),
|
||||
url,
|
||||
ref_name,
|
||||
});
|
||||
return Ok(MarketplaceSource::Git { url, ref_name });
|
||||
}
|
||||
|
||||
if looks_like_github_shorthand(&base_source) {
|
||||
let url = format!("https://github.com/{base_source}.git");
|
||||
return Ok(MarketplaceSource::Git {
|
||||
source_id: git_source_id("github", &base_source, ref_name.as_deref()),
|
||||
url,
|
||||
ref_name,
|
||||
});
|
||||
return Ok(MarketplaceSource::Git { url, ref_name });
|
||||
}
|
||||
|
||||
if base_source.starts_with("http://") || base_source.starts_with("https://") {
|
||||
@@ -279,14 +290,6 @@ fn parse_marketplace_source(
|
||||
bail!("invalid marketplace source format: {source}");
|
||||
}
|
||||
|
||||
fn git_source_id(kind: &str, source: &str, ref_name: Option<&str>) -> String {
|
||||
if let Some(ref_name) = ref_name {
|
||||
format!("{kind}:{source}#{ref_name}")
|
||||
} else {
|
||||
format!("{kind}:{source}")
|
||||
}
|
||||
}
|
||||
|
||||
fn split_source_ref(source: &str) -> (String, Option<String>) {
|
||||
if let Some((base, ref_name)) = source.rsplit_once('#') {
|
||||
return (base.to_string(), non_empty_ref(ref_name));
|
||||
@@ -521,17 +524,44 @@ fn ensure_marketplace_destination_is_inside_install_root(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl MarketplaceSource {
|
||||
fn source_id(&self) -> &str {
|
||||
match self {
|
||||
Self::LocalDirectory { source_id, .. } | Self::Git { source_id, .. } => source_id,
|
||||
}
|
||||
}
|
||||
fn utc_timestamp_now() -> Result<String> {
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.context("system clock is before Unix epoch")?;
|
||||
Ok(format_utc_timestamp(duration.as_secs() as i64))
|
||||
}
|
||||
|
||||
fn format_utc_timestamp(seconds_since_epoch: i64) -> String {
|
||||
const SECONDS_PER_DAY: i64 = 86_400;
|
||||
let days = seconds_since_epoch.div_euclid(SECONDS_PER_DAY);
|
||||
let seconds_of_day = seconds_since_epoch.rem_euclid(SECONDS_PER_DAY);
|
||||
let (year, month, day) = civil_from_days(days);
|
||||
let hour = seconds_of_day / 3_600;
|
||||
let minute = (seconds_of_day % 3_600) / 60;
|
||||
let second = seconds_of_day % 60;
|
||||
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
|
||||
}
|
||||
|
||||
fn civil_from_days(days_since_epoch: i64) -> (i64, i64, i64) {
|
||||
let days = days_since_epoch + 719_468;
|
||||
let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
|
||||
let day_of_era = days - era * 146_097;
|
||||
let year_of_era =
|
||||
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
|
||||
let mut year = year_of_era + era * 400;
|
||||
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
|
||||
let month_prime = (5 * day_of_year + 2) / 153;
|
||||
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
|
||||
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
|
||||
year += if month <= 2 { 1 } else { 0 };
|
||||
(year, month, day)
|
||||
}
|
||||
|
||||
impl MarketplaceSource {
|
||||
fn display(&self) -> String {
|
||||
match self {
|
||||
Self::LocalDirectory { path, .. } => path.display().to_string(),
|
||||
Self::Git { url, ref_name, .. } => {
|
||||
Self::LocalDirectory { path } => path.display().to_string(),
|
||||
Self::Git { url, ref_name } => {
|
||||
if let Some(ref_name) = ref_name {
|
||||
format!("{url}#{ref_name}")
|
||||
} else {
|
||||
@@ -555,7 +585,6 @@ mod tests {
|
||||
MarketplaceSource::Git {
|
||||
url: "https://github.com/owner/repo.git".to_string(),
|
||||
ref_name: Some("main".to_string()),
|
||||
source_id: "github:owner/repo#main".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -571,7 +600,6 @@ mod tests {
|
||||
MarketplaceSource::Git {
|
||||
url: "https://example.com/team/repo.git".to_string(),
|
||||
ref_name: Some("v1".to_string()),
|
||||
source_id: "git:https://example.com/team/repo.git#v1".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -587,13 +615,12 @@ mod tests {
|
||||
MarketplaceSource::Git {
|
||||
url: "https://github.com/owner/repo.git".to_string(),
|
||||
ref_name: Some("release".to_string()),
|
||||
source_id: "github:owner/repo#release".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_shorthand_and_git_url_have_different_source_ids() {
|
||||
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",
|
||||
@@ -601,25 +628,22 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(shorthand.source_id(), git_url.source_id());
|
||||
assert_eq!(shorthand, git_url);
|
||||
assert_eq!(
|
||||
shorthand,
|
||||
MarketplaceSource::Git {
|
||||
url: "https://github.com/owner/repo.git".to_string(),
|
||||
ref_name: None,
|
||||
source_id: "github:owner/repo".to_string(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
git_url,
|
||||
MarketplaceSource::Git {
|
||||
url: "https://github.com/owner/repo.git".to_string(),
|
||||
ref_name: None,
|
||||
source_id: "git:https://github.com/owner/repo.git".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utc_timestamp_formats_unix_epoch_as_rfc3339_utc() {
|
||||
assert_eq!(format_utc_timestamp(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(format_utc_timestamp(1_775_779_200), "2026-04-10T00:00:00Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_paths_parse_before_or_after_source() {
|
||||
let sparse_before_source =
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
use super::MarketplaceSource;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_core::plugins::validate_marketplace_root;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const MARKETPLACE_ADD_SOURCE_FILE: &str = ".codex-marketplace-source";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct MarketplaceInstallMetadata {
|
||||
pub(super) source_id: String,
|
||||
source: InstalledMarketplaceSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) enum InstalledMarketplaceSource {
|
||||
enum InstalledMarketplaceSource {
|
||||
LocalDirectory {
|
||||
path: PathBuf,
|
||||
},
|
||||
@@ -28,164 +24,96 @@ pub(super) enum InstalledMarketplaceSource {
|
||||
}
|
||||
|
||||
pub(super) fn installed_marketplace_root_for_source(
|
||||
codex_home: &Path,
|
||||
install_root: &Path,
|
||||
source_id: &str,
|
||||
install_metadata: &MarketplaceInstallMetadata,
|
||||
) -> Result<Option<PathBuf>> {
|
||||
let entries = fs::read_dir(install_root).with_context(|| {
|
||||
format!(
|
||||
"failed to read marketplace install directory {}",
|
||||
install_root.display()
|
||||
)
|
||||
})?;
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
if !entry.file_type()?.is_dir() {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let Ok(config) = std::fs::read_to_string(&config_path) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let config: toml::Value = toml::from_str(&config)
|
||||
.with_context(|| format!("failed to parse user config {}", config_path.display()))?;
|
||||
let Some(marketplaces) = config.get("marketplaces").and_then(toml::Value::as_table) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
for (marketplace_name, marketplace) in marketplaces {
|
||||
if !install_metadata.matches_config(marketplace) {
|
||||
continue;
|
||||
}
|
||||
let root = entry.path();
|
||||
let metadata_path = root.join(MARKETPLACE_ADD_SOURCE_FILE);
|
||||
if !metadata_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let metadata = read_marketplace_source_metadata(&root)?;
|
||||
if metadata.source_id == source_id && validate_marketplace_root(&root).is_ok() {
|
||||
let root = install_root.join(marketplace_name);
|
||||
if validate_marketplace_root(&root).is_ok() {
|
||||
return Ok(Some(root));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(super) fn write_marketplace_source_metadata(
|
||||
root: &Path,
|
||||
metadata: &MarketplaceInstallMetadata,
|
||||
) -> Result<()> {
|
||||
let source = match &metadata.source {
|
||||
InstalledMarketplaceSource::LocalDirectory { path } => serde_json::json!({
|
||||
"kind": "directory",
|
||||
"path": path,
|
||||
}),
|
||||
InstalledMarketplaceSource::Git {
|
||||
url,
|
||||
ref_name,
|
||||
sparse_paths,
|
||||
} => serde_json::json!({
|
||||
"kind": "git",
|
||||
"url": url,
|
||||
"ref": ref_name,
|
||||
"sparsePaths": sparse_paths,
|
||||
}),
|
||||
};
|
||||
let content = serde_json::to_string_pretty(&serde_json::json!({
|
||||
"version": 1,
|
||||
"sourceId": metadata.source_id,
|
||||
"source": source,
|
||||
}))?;
|
||||
fs::write(root.join(MARKETPLACE_ADD_SOURCE_FILE), content).with_context(|| {
|
||||
format!(
|
||||
"failed to write marketplace source metadata in {}",
|
||||
root.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn read_marketplace_source_metadata(root: &Path) -> Result<MarketplaceInstallMetadata> {
|
||||
let path = root.join(MARKETPLACE_ADD_SOURCE_FILE);
|
||||
let content = fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"failed to read marketplace source metadata {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
if !content.trim_start().starts_with('{') {
|
||||
return Ok(MarketplaceInstallMetadata {
|
||||
source_id: content.trim().to_string(),
|
||||
source: InstalledMarketplaceSource::LocalDirectory {
|
||||
path: root.to_path_buf(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let json: serde_json::Value = serde_json::from_str(&content).with_context(|| {
|
||||
format!(
|
||||
"failed to parse marketplace source metadata {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let source_id = json
|
||||
.get("sourceId")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.context("marketplace source metadata is missing sourceId")?
|
||||
.to_string();
|
||||
let source = json
|
||||
.get("source")
|
||||
.context("marketplace source metadata is missing source")?;
|
||||
let kind = source
|
||||
.get("kind")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.context("marketplace source metadata is missing source.kind")?;
|
||||
let source = match kind {
|
||||
"directory" => {
|
||||
let path = source
|
||||
.get("path")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.context("marketplace directory metadata is missing path")?;
|
||||
InstalledMarketplaceSource::LocalDirectory {
|
||||
path: PathBuf::from(path),
|
||||
}
|
||||
}
|
||||
"git" => {
|
||||
let url = source
|
||||
.get("url")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.context("marketplace git metadata is missing url")?
|
||||
.to_string();
|
||||
let ref_name = source
|
||||
.get("ref")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string);
|
||||
let sparse_paths = source
|
||||
.get("sparsePaths")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|paths| {
|
||||
paths
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
InstalledMarketplaceSource::Git {
|
||||
url,
|
||||
ref_name,
|
||||
sparse_paths,
|
||||
}
|
||||
}
|
||||
other => bail!("unsupported marketplace source metadata kind `{other}`"),
|
||||
};
|
||||
Ok(MarketplaceInstallMetadata { source_id, source })
|
||||
}
|
||||
|
||||
impl MarketplaceInstallMetadata {
|
||||
pub(super) fn from_source(source: &MarketplaceSource, sparse_paths: &[String]) -> Self {
|
||||
let source_id = if sparse_paths.is_empty() {
|
||||
source.source_id().to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{}?sparse={}",
|
||||
source.source_id(),
|
||||
serde_json::to_string(sparse_paths).unwrap_or_else(|_| "[]".to_string())
|
||||
)
|
||||
};
|
||||
let source = match source {
|
||||
MarketplaceSource::LocalDirectory { path, .. } => {
|
||||
MarketplaceSource::LocalDirectory { path } => {
|
||||
InstalledMarketplaceSource::LocalDirectory { path: path.clone() }
|
||||
}
|
||||
MarketplaceSource::Git { url, ref_name, .. } => InstalledMarketplaceSource::Git {
|
||||
MarketplaceSource::Git { url, ref_name } => InstalledMarketplaceSource::Git {
|
||||
url: url.clone(),
|
||||
ref_name: ref_name.clone(),
|
||||
sparse_paths: sparse_paths.to_vec(),
|
||||
},
|
||||
};
|
||||
Self { source_id, source }
|
||||
Self { source }
|
||||
}
|
||||
|
||||
pub(super) fn config_source_type(&self) -> &'static str {
|
||||
match &self.source {
|
||||
InstalledMarketplaceSource::LocalDirectory { .. } => "directory",
|
||||
InstalledMarketplaceSource::Git { .. } => "git",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn config_source(&self) -> String {
|
||||
match &self.source {
|
||||
InstalledMarketplaceSource::LocalDirectory { path } => path.display().to_string(),
|
||||
InstalledMarketplaceSource::Git { url, .. } => url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn ref_name(&self) -> Option<&str> {
|
||||
match &self.source {
|
||||
InstalledMarketplaceSource::LocalDirectory { .. } => None,
|
||||
InstalledMarketplaceSource::Git { ref_name, .. } => ref_name.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn sparse_paths(&self) -> &[String] {
|
||||
match &self.source {
|
||||
InstalledMarketplaceSource::LocalDirectory { .. } => &[],
|
||||
InstalledMarketplaceSource::Git { sparse_paths, .. } => sparse_paths,
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_config(&self, marketplace: &toml::Value) -> bool {
|
||||
marketplace.get("source_type").and_then(toml::Value::as_str)
|
||||
== Some(self.config_source_type())
|
||||
&& marketplace.get("source").and_then(toml::Value::as_str)
|
||||
== Some(self.config_source().as_str())
|
||||
&& marketplace.get("ref").and_then(toml::Value::as_str) == self.ref_name()
|
||||
&& config_sparse_paths(marketplace) == self.sparse_paths()
|
||||
}
|
||||
}
|
||||
|
||||
fn config_sparse_paths(marketplace: &toml::Value) -> Vec<String> {
|
||||
marketplace
|
||||
.get("sparse_paths")
|
||||
.and_then(toml::Value::as_array)
|
||||
.map(|paths| {
|
||||
paths
|
||||
.iter()
|
||||
.filter_map(toml::Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_core::plugins::marketplace_install_root;
|
||||
use codex_core::plugins::validate_marketplace_root;
|
||||
@@ -63,14 +64,19 @@ async fn marketplace_add_local_directory_installs_valid_marketplace_root() -> Re
|
||||
|
||||
let installed_root = marketplace_install_root(codex_home.path()).join("debug");
|
||||
assert_eq!(validate_marketplace_root(&installed_root)?, "debug");
|
||||
let registry = std::fs::read_to_string(codex_home.path().join(".tmp/known_marketplaces.json"))?;
|
||||
assert!(registry.contains(r#""name": "debug""#));
|
||||
assert!(registry.contains(r#""installLocation""#));
|
||||
assert_marketplace_config(codex_home.path(), "debug", &source.path().canonicalize()?)?;
|
||||
assert!(
|
||||
installed_root
|
||||
.join("plugins/sample/.codex-plugin/plugin.json")
|
||||
.is_file()
|
||||
);
|
||||
assert!(!installed_root.join(".codex-marketplace-source").exists());
|
||||
assert!(
|
||||
!codex_home
|
||||
.path()
|
||||
.join(".tmp/known_marketplaces.json")
|
||||
.exists()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -132,6 +138,14 @@ async fn marketplace_add_same_source_is_idempotent() -> Result<()> {
|
||||
std::fs::read_to_string(installed_root.join("plugins/sample/marker.txt"))?,
|
||||
"first install"
|
||||
);
|
||||
assert_marketplace_config(codex_home.path(), "debug", &source.path().canonicalize()?)?;
|
||||
assert!(!installed_root.join(".codex-marketplace-source").exists());
|
||||
assert!(
|
||||
!codex_home
|
||||
.path()
|
||||
.join(".tmp/known_marketplaces.json")
|
||||
.exists()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -167,6 +181,45 @@ async fn marketplace_add_rejects_same_name_from_different_source() -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assert_marketplace_config(
|
||||
codex_home: &Path,
|
||||
marketplace_name: &str,
|
||||
source: &Path,
|
||||
) -> Result<()> {
|
||||
let config = std::fs::read_to_string(codex_home.join("config.toml"))?;
|
||||
let config: toml::Value = toml::from_str(&config)?;
|
||||
let marketplace = config
|
||||
.get("marketplaces")
|
||||
.and_then(|marketplaces| marketplaces.get(marketplace_name))
|
||||
.context("marketplace config should be written")?;
|
||||
let expected_source = source.to_string_lossy().to_string();
|
||||
|
||||
assert!(
|
||||
marketplace
|
||||
.get("last_updated")
|
||||
.and_then(toml::Value::as_str)
|
||||
.is_some_and(|last_updated| {
|
||||
last_updated.len() == "2026-04-10T12:34:56Z".len() && last_updated.ends_with('Z')
|
||||
}),
|
||||
"last_updated should be an RFC3339-like UTC timestamp"
|
||||
);
|
||||
assert_eq!(
|
||||
marketplace.get("source_type").and_then(toml::Value::as_str),
|
||||
Some("directory")
|
||||
);
|
||||
assert_eq!(
|
||||
marketplace.get("source").and_then(toml::Value::as_str),
|
||||
Some(expected_source.as_str())
|
||||
);
|
||||
assert_eq!(marketplace.get("ref").and_then(toml::Value::as_str), None);
|
||||
assert!(marketplace.get("sparse_paths").is_none());
|
||||
assert!(marketplace.get("source_id").is_none());
|
||||
assert!(marketplace.get("install_root").is_none());
|
||||
assert!(marketplace.get("install_location").is_none());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marketplace_add_sparse_flag_parses_before_and_after_source() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::types::AppsConfigToml;
|
||||
use crate::types::AuthCredentialsStoreMode;
|
||||
use crate::types::FeedbackConfigToml;
|
||||
use crate::types::History;
|
||||
use crate::types::MarketplaceConfig;
|
||||
use crate::types::McpServerConfig;
|
||||
use crate::types::MemoriesToml;
|
||||
use crate::types::Notice;
|
||||
@@ -325,6 +326,10 @@ pub struct ConfigToml {
|
||||
#[serde(default)]
|
||||
pub plugins: HashMap<String, PluginConfig>,
|
||||
|
||||
/// User-level marketplace entries keyed by marketplace name.
|
||||
#[serde(default)]
|
||||
pub marketplaces: HashMap<String, MarketplaceConfig>,
|
||||
|
||||
/// Centralized feature flags (new). Prefer this over individual toggles.
|
||||
#[serde(default)]
|
||||
// Injects known feature keys into the schema and forbids unknown keys.
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod config_toml;
|
||||
mod constraint;
|
||||
mod diagnostics;
|
||||
mod fingerprint;
|
||||
mod marketplace_edit;
|
||||
mod mcp_edit;
|
||||
mod mcp_types;
|
||||
mod merge;
|
||||
@@ -57,6 +58,8 @@ pub use diagnostics::format_config_error;
|
||||
pub use diagnostics::format_config_error_with_source;
|
||||
pub use diagnostics::io_error_from_config_error;
|
||||
pub use fingerprint::version_for_toml;
|
||||
pub use marketplace_edit::MarketplaceConfigUpdate;
|
||||
pub use marketplace_edit::record_user_marketplace;
|
||||
pub use mcp_edit::ConfigEditsBuilder;
|
||||
pub use mcp_edit::load_global_mcp_servers;
|
||||
pub use mcp_types::AppToolApproval;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
|
||||
use toml_edit::DocumentMut;
|
||||
use toml_edit::Item as TomlItem;
|
||||
use toml_edit::Table as TomlTable;
|
||||
use toml_edit::Value as TomlValue;
|
||||
use toml_edit::value;
|
||||
|
||||
use crate::CONFIG_TOML_FILE;
|
||||
|
||||
pub struct MarketplaceConfigUpdate<'a> {
|
||||
pub last_updated: &'a str,
|
||||
pub source_type: &'a str,
|
||||
pub source: &'a str,
|
||||
pub ref_name: Option<&'a str>,
|
||||
pub sparse_paths: &'a [String],
|
||||
}
|
||||
|
||||
pub fn record_user_marketplace(
|
||||
codex_home: &Path,
|
||||
marketplace_name: &str,
|
||||
update: &MarketplaceConfigUpdate<'_>,
|
||||
) -> std::io::Result<()> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let mut doc = read_or_create_document(&config_path)?;
|
||||
upsert_marketplace(&mut doc, marketplace_name, update);
|
||||
fs::create_dir_all(codex_home)?;
|
||||
fs::write(config_path, doc.to_string())
|
||||
}
|
||||
|
||||
fn read_or_create_document(config_path: &Path) -> std::io::Result<DocumentMut> {
|
||||
match fs::read_to_string(config_path) {
|
||||
Ok(raw) => raw
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err)),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok(DocumentMut::new()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_marketplace(
|
||||
doc: &mut DocumentMut,
|
||||
marketplace_name: &str,
|
||||
update: &MarketplaceConfigUpdate<'_>,
|
||||
) {
|
||||
let root = doc.as_table_mut();
|
||||
if !root.contains_key("marketplaces") {
|
||||
root.insert("marketplaces", TomlItem::Table(new_implicit_table()));
|
||||
}
|
||||
|
||||
let Some(marketplaces_item) = root.get_mut("marketplaces") else {
|
||||
return;
|
||||
};
|
||||
if !marketplaces_item.is_table() {
|
||||
*marketplaces_item = TomlItem::Table(new_implicit_table());
|
||||
}
|
||||
|
||||
let Some(marketplaces) = marketplaces_item.as_table_mut() else {
|
||||
return;
|
||||
};
|
||||
let mut entry = TomlTable::new();
|
||||
entry.set_implicit(false);
|
||||
entry["last_updated"] = value(update.last_updated.to_string());
|
||||
entry["source_type"] = value(update.source_type.to_string());
|
||||
entry["source"] = value(update.source.to_string());
|
||||
if let Some(ref_name) = update.ref_name {
|
||||
entry["ref"] = value(ref_name.to_string());
|
||||
}
|
||||
if !update.sparse_paths.is_empty() {
|
||||
entry["sparse_paths"] = TomlItem::Value(TomlValue::Array(
|
||||
update.sparse_paths.iter().map(String::as_str).collect(),
|
||||
));
|
||||
}
|
||||
marketplaces.insert(marketplace_name, TomlItem::Table(entry));
|
||||
}
|
||||
|
||||
fn new_implicit_table() -> TomlTable {
|
||||
let mut table = TomlTable::new();
|
||||
table.set_implicit(true);
|
||||
table
|
||||
}
|
||||
@@ -578,6 +578,33 @@ pub struct PluginConfig {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct MarketplaceConfig {
|
||||
/// Last time Codex successfully added or refreshed this marketplace.
|
||||
#[serde(default)]
|
||||
pub last_updated: Option<String>,
|
||||
/// Source kind used to install this marketplace.
|
||||
#[serde(default)]
|
||||
pub source_type: Option<MarketplaceSourceType>,
|
||||
/// Source location used when the marketplace was added.
|
||||
#[serde(default)]
|
||||
pub source: Option<String>,
|
||||
/// Git ref to check out when `source_type` is `git`.
|
||||
#[serde(default, rename = "ref")]
|
||||
pub ref_name: Option<String>,
|
||||
/// Sparse checkout paths used when `source_type` is `git`.
|
||||
#[serde(default)]
|
||||
pub sparse_paths: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MarketplaceSourceType {
|
||||
Directory,
|
||||
Git,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SandboxWorkspaceWrite {
|
||||
|
||||
@@ -754,6 +754,51 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"MarketplaceConfig": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"last_updated": {
|
||||
"default": null,
|
||||
"description": "Last time Codex successfully added or refreshed this marketplace.",
|
||||
"type": "string"
|
||||
},
|
||||
"ref": {
|
||||
"default": null,
|
||||
"description": "Git ref to check out when `source_type` is `git`.",
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"default": null,
|
||||
"description": "Source location used when the marketplace was added.",
|
||||
"type": "string"
|
||||
},
|
||||
"source_type": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MarketplaceSourceType"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Source kind used to install this marketplace."
|
||||
},
|
||||
"sparse_paths": {
|
||||
"default": null,
|
||||
"description": "Sparse checkout paths used when `source_type` is `git`.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"MarketplaceSourceType": {
|
||||
"enum": [
|
||||
"directory",
|
||||
"git"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"McpServerToolConfig": {
|
||||
"additionalProperties": false,
|
||||
"description": "Per-tool approval settings for a single MCP server tool.",
|
||||
@@ -2322,6 +2367,14 @@
|
||||
],
|
||||
"description": "Directory where Codex writes log files, for example `codex-tui.log`. Defaults to `$CODEX_HOME/log`."
|
||||
},
|
||||
"marketplaces": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/MarketplaceConfig"
|
||||
},
|
||||
"default": {},
|
||||
"description": "User-level marketplace entries keyed by marketplace name.",
|
||||
"type": "object"
|
||||
},
|
||||
"mcp_oauth_callback_port": {
|
||||
"description": "Optional fixed port for the local HTTP callback server used during MCP OAuth login. When unset, Codex will bind to an ephemeral port chosen by the OS.",
|
||||
"format": "uint16",
|
||||
|
||||
@@ -54,11 +54,11 @@ use codex_plugin::PluginId;
|
||||
use codex_plugin::PluginIdError;
|
||||
use codex_plugin::PluginTelemetryMetadata;
|
||||
use codex_plugin::prompt_safe_plugin_description;
|
||||
use codex_plugin::validate_plugin_segment;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Map as JsonMap;
|
||||
use serde_json::Value as JsonValue;
|
||||
use serde_json::json;
|
||||
@@ -81,7 +81,6 @@ const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
|
||||
const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
|
||||
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
|
||||
pub const INSTALLED_MARKETPLACES_DIR: &str = ".tmp/marketplaces";
|
||||
const KNOWN_MARKETPLACES_FILE: &str = "known_marketplaces.json";
|
||||
pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
|
||||
pub const OPENAI_CURATED_MARKETPLACE_DISPLAY_NAME: &str = "OpenAI Curated";
|
||||
static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false);
|
||||
@@ -877,7 +876,8 @@ impl PluginsManager {
|
||||
}
|
||||
|
||||
let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config);
|
||||
let marketplace_outcome = list_marketplaces(&self.marketplace_roots(additional_roots))?;
|
||||
let marketplace_outcome =
|
||||
list_marketplaces(&self.marketplace_roots(config, additional_roots))?;
|
||||
let mut seen_plugin_keys = HashSet::new();
|
||||
let marketplaces = marketplace_outcome
|
||||
.marketplaces
|
||||
@@ -1221,11 +1221,18 @@ impl PluginsManager {
|
||||
(installed_plugins, enabled_plugins)
|
||||
}
|
||||
|
||||
fn marketplace_roots(&self, additional_roots: &[AbsolutePathBuf]) -> Vec<AbsolutePathBuf> {
|
||||
fn marketplace_roots(
|
||||
&self,
|
||||
config: &Config,
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
// Treat the curated catalog as an extra marketplace root so plugin listing can surface it
|
||||
// without requiring every caller to know where it is stored.
|
||||
let mut roots = additional_roots.to_vec();
|
||||
roots.extend(installed_marketplace_roots(self.codex_home.as_path()));
|
||||
roots.extend(installed_marketplace_roots_from_config(
|
||||
config,
|
||||
self.codex_home.as_path(),
|
||||
));
|
||||
let curated_repo_root = curated_plugins_repo_path(self.codex_home.as_path());
|
||||
if curated_repo_root.is_dir()
|
||||
&& let Ok(curated_repo_root) = AbsolutePathBuf::try_from(curated_repo_root)
|
||||
@@ -1242,57 +1249,42 @@ pub fn marketplace_install_root(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join(INSTALLED_MARKETPLACES_DIR)
|
||||
}
|
||||
|
||||
pub fn record_installed_marketplace_root(
|
||||
fn installed_marketplace_roots_from_config(
|
||||
config: &Config,
|
||||
codex_home: &Path,
|
||||
marketplace_name: &str,
|
||||
install_location: &Path,
|
||||
) -> std::io::Result<()> {
|
||||
let registry_path = marketplace_registry_path(codex_home);
|
||||
let mut registry = if registry_path.is_file() {
|
||||
read_marketplace_registry(®istry_path)?
|
||||
} else {
|
||||
KnownMarketplacesRegistry::default()
|
||||
};
|
||||
|
||||
registry
|
||||
.marketplaces
|
||||
.retain(|marketplace| marketplace.name != marketplace_name);
|
||||
registry.marketplaces.push(KnownMarketplaceRegistryEntry {
|
||||
name: marketplace_name.to_string(),
|
||||
install_location: install_location.to_path_buf(),
|
||||
});
|
||||
registry
|
||||
.marketplaces
|
||||
.sort_unstable_by(|left, right| left.name.cmp(&right.name));
|
||||
write_marketplace_registry(®istry_path, ®istry)
|
||||
}
|
||||
|
||||
fn installed_marketplace_roots(codex_home: &Path) -> Vec<AbsolutePathBuf> {
|
||||
let registry_path = marketplace_registry_path(codex_home);
|
||||
if registry_path.is_file() {
|
||||
match installed_marketplace_roots_from_registry(®istry_path) {
|
||||
Ok(roots) => return roots,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
path = %registry_path.display(),
|
||||
error = %err,
|
||||
"failed to read installed marketplace registry; falling back to installed marketplace directory scan"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let install_root = marketplace_install_root(codex_home);
|
||||
let Ok(entries) = fs::read_dir(&install_root) else {
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
let Some(user_layer) = config.config_layer_stack.get_user_layer() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut roots = entries
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|entry| {
|
||||
let path = entry.path();
|
||||
let file_type = entry.file_type().ok()?;
|
||||
(file_type.is_dir() && path.join(".agents/plugins/marketplace.json").is_file())
|
||||
let Some(marketplaces_value) = user_layer.config.get("marketplaces") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(marketplaces) = marketplaces_value.as_table() else {
|
||||
warn!("invalid marketplaces config: expected table");
|
||||
return Vec::new();
|
||||
};
|
||||
let default_install_root = marketplace_install_root(codex_home);
|
||||
let mut roots = marketplaces
|
||||
.iter()
|
||||
.filter_map(|(marketplace_name, marketplace)| {
|
||||
if !marketplace.is_table() {
|
||||
warn!(
|
||||
marketplace_name,
|
||||
"ignoring invalid configured marketplace entry"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if let Err(err) = validate_plugin_segment(marketplace_name, "marketplace name") {
|
||||
warn!(
|
||||
marketplace_name,
|
||||
error = %err,
|
||||
"ignoring invalid configured marketplace name"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let path = default_install_root.join(marketplace_name);
|
||||
path.join(".agents/plugins/marketplace.json")
|
||||
.is_file()
|
||||
.then_some(path)
|
||||
})
|
||||
.filter_map(|path| AbsolutePathBuf::try_from(path).ok())
|
||||
@@ -1301,78 +1293,6 @@ fn installed_marketplace_roots(codex_home: &Path) -> Vec<AbsolutePathBuf> {
|
||||
roots
|
||||
}
|
||||
|
||||
fn marketplace_registry_path(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join(".tmp").join(KNOWN_MARKETPLACES_FILE)
|
||||
}
|
||||
|
||||
fn installed_marketplace_roots_from_registry(
|
||||
registry_path: &Path,
|
||||
) -> std::io::Result<Vec<AbsolutePathBuf>> {
|
||||
let registry = read_marketplace_registry(registry_path)?;
|
||||
|
||||
let mut roots = registry
|
||||
.marketplaces
|
||||
.into_iter()
|
||||
.filter_map(|marketplace| {
|
||||
let path = marketplace.install_location;
|
||||
if path.join(".agents/plugins/marketplace.json").is_file() {
|
||||
AbsolutePathBuf::try_from(path).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
roots.sort_unstable_by(|left, right| left.as_path().cmp(right.as_path()));
|
||||
Ok(roots)
|
||||
}
|
||||
|
||||
fn read_marketplace_registry(path: &Path) -> std::io::Result<KnownMarketplacesRegistry> {
|
||||
let contents = fs::read_to_string(path)?;
|
||||
serde_json::from_str(&contents).map_err(|err| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"failed to parse marketplace registry {}: {err}",
|
||||
path.display()
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn write_marketplace_registry(
|
||||
path: &Path,
|
||||
registry: &KnownMarketplacesRegistry,
|
||||
) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let contents = serde_json::to_vec_pretty(registry).map_err(std::io::Error::other)?;
|
||||
fs::write(path, contents)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KnownMarketplacesRegistry {
|
||||
version: u32,
|
||||
marketplaces: Vec<KnownMarketplaceRegistryEntry>,
|
||||
}
|
||||
|
||||
impl Default for KnownMarketplacesRegistry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: 1,
|
||||
marketplaces: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KnownMarketplaceRegistryEntry {
|
||||
name: String,
|
||||
install_location: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PluginInstallError {
|
||||
#[error("{0}")]
|
||||
|
||||
@@ -1514,6 +1514,11 @@ async fn list_marketplaces_includes_installed_marketplace_roots() {
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[marketplaces.debug]
|
||||
last_updated = "2026-04-10T12:34:56Z"
|
||||
source_type = "directory"
|
||||
source = "/tmp/debug"
|
||||
"#,
|
||||
);
|
||||
fs::create_dir_all(marketplace_root.join(".agents/plugins")).unwrap();
|
||||
@@ -1539,9 +1544,6 @@ plugins = true
|
||||
r#"{"name":"sample"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
record_installed_marketplace_root(tmp.path(), "debug", &marketplace_root)
|
||||
.expect("record installed marketplace");
|
||||
|
||||
let config = load_config(tmp.path(), tmp.path()).await;
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[])
|
||||
@@ -1569,7 +1571,7 @@ plugins = true
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_marketplaces_scans_installed_roots_when_registry_is_malformed() {
|
||||
async fn list_marketplaces_uses_config_when_known_registry_is_malformed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let marketplace_root = marketplace_install_root(tmp.path()).join("debug");
|
||||
let plugin_root = marketplace_root.join("plugins/sample");
|
||||
@@ -1579,6 +1581,63 @@ async fn list_marketplaces_scans_installed_roots_when_registry_is_malformed() {
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[marketplaces.debug]
|
||||
last_updated = "2026-04-10T12:34:56Z"
|
||||
source_type = "directory"
|
||||
source = "/tmp/debug"
|
||||
"#,
|
||||
);
|
||||
fs::create_dir_all(marketplace_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
||||
fs::write(
|
||||
marketplace_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "debug",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "sample",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/sample"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(registry_path.parent().unwrap()).unwrap();
|
||||
fs::write(registry_path, "{not valid json").unwrap();
|
||||
|
||||
let config = load_config(tmp.path(), tmp.path()).await;
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[])
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
let marketplace = marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| marketplace.name == "debug")
|
||||
.expect("configured marketplace should be discovered");
|
||||
|
||||
assert_eq!(marketplace.plugins[0].id, "sample@debug");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_marketplaces_ignores_installed_roots_missing_from_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let marketplace_root = marketplace_install_root(tmp.path()).join("debug");
|
||||
let plugin_root = marketplace_root.join("plugins/sample");
|
||||
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
"#,
|
||||
);
|
||||
fs::create_dir_all(marketplace_root.join(".agents/plugins")).unwrap();
|
||||
@@ -1604,20 +1663,13 @@ plugins = true
|
||||
r#"{"name":"sample"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(registry_path, "{not valid json").unwrap();
|
||||
|
||||
let config = load_config(tmp.path(), tmp.path()).await;
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[])
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
let marketplace = marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| marketplace.name == "debug")
|
||||
.expect("installed marketplace should be discovered by disk scan fallback");
|
||||
|
||||
assert_eq!(marketplace.plugins[0].id, "sample@debug");
|
||||
assert!(marketplaces.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -47,7 +47,6 @@ pub use manager::load_plugin_apps;
|
||||
pub use manager::load_plugin_mcp_servers;
|
||||
pub use manager::marketplace_install_root;
|
||||
pub use manager::plugin_telemetry_metadata_from_root;
|
||||
pub use manager::record_installed_marketplace_root;
|
||||
pub use manifest::PluginManifestInterface;
|
||||
pub(crate) use manifest::PluginManifestPaths;
|
||||
pub(crate) use manifest::load_plugin_manifest;
|
||||
|
||||
Reference in New Issue
Block a user