mirror of
https://github.com/openai/codex.git
synced 2026-05-13 07:42:40 +00:00
Compare commits
9 Commits
xli-codex/
...
dev/bazel-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd56da42e4 | ||
|
|
ba19274ee9 | ||
|
|
d416b7af67 | ||
|
|
5a320ec2b0 | ||
|
|
cbf9643069 | ||
|
|
5066eddf79 | ||
|
|
7970439736 | ||
|
|
7e64f4ed8a | ||
|
|
90320fc51a |
136
.github/scripts/run-bazel-ci.sh
vendored
136
.github/scripts/run-bazel-ci.sh
vendored
@@ -2,15 +2,15 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
print_failed_bazel_test_logs=0
|
||||
summarize_failed_bazel_test_logs=0
|
||||
use_node_test_env=0
|
||||
remote_download_toplevel=0
|
||||
windows_msvc_host_platform=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--print-failed-test-logs)
|
||||
print_failed_bazel_test_logs=1
|
||||
--summarize-failed-test-logs)
|
||||
summarize_failed_bazel_test_logs=1
|
||||
shift
|
||||
;;
|
||||
--use-node-test-env)
|
||||
@@ -37,7 +37,7 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
echo "Usage: $0 [--print-failed-test-logs] [--use-node-test-env] [--remote-download-toplevel] [--windows-msvc-host-platform] -- <bazel args> -- <targets>" >&2
|
||||
echo "Usage: $0 [--summarize-failed-test-logs] [--use-node-test-env] [--remote-download-toplevel] [--windows-msvc-host-platform] -- <bazel args> -- <targets>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -65,44 +65,122 @@ case "${RUNNER_OS:-}" in
|
||||
;;
|
||||
esac
|
||||
|
||||
print_bazel_test_log_tails() {
|
||||
summarize_failed_bazel_test_logs() {
|
||||
local console_log="$1"
|
||||
local testlogs_dir
|
||||
local testlogs_dir=
|
||||
local -a bazel_info_cmd=(bazel)
|
||||
local rust_test_summary
|
||||
rust_test_summary="$(mktemp)"
|
||||
|
||||
if (( ${#bazel_startup_args[@]} > 0 )); then
|
||||
bazel_info_cmd+=("${bazel_startup_args[@]}")
|
||||
fi
|
||||
|
||||
testlogs_dir="$(run_bazel "${bazel_info_cmd[@]:1}" info bazel-testlogs 2>/dev/null || echo bazel-testlogs)"
|
||||
|
||||
local failed_targets=()
|
||||
while IFS= read -r target; do
|
||||
failed_targets+=("$target")
|
||||
local failed_target_logs=()
|
||||
while IFS= read -r target_log; do
|
||||
failed_target_logs+=("$target_log")
|
||||
done < <(
|
||||
grep -E '^FAIL: //' "$console_log" \
|
||||
| sed -E 's#^FAIL: (//[^ ]+).*#\1#' \
|
||||
| awk '{
|
||||
target = $2
|
||||
test_log = ""
|
||||
if (match($0, /\(see [^)]*test\.log\)/)) {
|
||||
test_log = substr($0, RSTART + 5, RLENGTH - 6)
|
||||
}
|
||||
if (!(target in test_logs) || test_logs[target] == "") {
|
||||
test_logs[target] = test_log
|
||||
}
|
||||
}
|
||||
END {
|
||||
for (target in test_logs) {
|
||||
print target "\t" test_logs[target]
|
||||
}
|
||||
}' \
|
||||
| sort -u
|
||||
)
|
||||
|
||||
if [[ ${#failed_targets[@]} -eq 0 ]]; then
|
||||
if [[ ${#failed_target_logs[@]} -eq 0 ]]; then
|
||||
echo "No failed Bazel test targets were found in console output."
|
||||
rm -f "$rust_test_summary"
|
||||
return
|
||||
fi
|
||||
|
||||
for target in "${failed_targets[@]}"; do
|
||||
local rel_path="${target#//}"
|
||||
rel_path="${rel_path/:/\/}"
|
||||
local test_log="${testlogs_dir}/${rel_path}/test.log"
|
||||
for target_log in "${failed_target_logs[@]}"; do
|
||||
local target="${target_log%%$'\t'*}"
|
||||
local test_log="${target_log#*$'\t'}"
|
||||
|
||||
echo "::group::Bazel test log tail for ${target}"
|
||||
if [[ -f "$test_log" ]]; then
|
||||
tail -n 200 "$test_log"
|
||||
else
|
||||
echo "Missing test log: $test_log"
|
||||
if [[ -z "$test_log" ]]; then
|
||||
if [[ -z "$testlogs_dir" ]]; then
|
||||
if (( ${#bazel_startup_args[@]} > 0 )); then
|
||||
bazel_info_cmd+=("${bazel_startup_args[@]}")
|
||||
fi
|
||||
|
||||
testlogs_dir="$(run_bazel "${bazel_info_cmd[@]:1}" info bazel-testlogs 2>/dev/null || echo bazel-testlogs)"
|
||||
fi
|
||||
|
||||
local rel_path="${target#//}"
|
||||
rel_path="${rel_path/:/\/}"
|
||||
test_log="${testlogs_dir}/${rel_path}/test.log"
|
||||
fi
|
||||
|
||||
if [[ -f "$test_log" ]]; then
|
||||
awk -v target="$target" '
|
||||
/^failures:$/ {
|
||||
in_failures = 1
|
||||
next
|
||||
}
|
||||
in_failures && /^ / {
|
||||
print "F\t" target "\t" substr($0, 5)
|
||||
next
|
||||
}
|
||||
in_failures && $0 !~ /^$/ {
|
||||
in_failures = 0
|
||||
}
|
||||
/^test result: FAILED\./ {
|
||||
line = $0
|
||||
sub(/^test result: FAILED\. /, "", line)
|
||||
sub(/; finished.*$/, "", line)
|
||||
field_count = split(line, fields, "; ")
|
||||
printf "T\t%s", target
|
||||
for (i = 1; i <= field_count; i++) {
|
||||
split(fields[i], parts, " ")
|
||||
printf "\t%d", parts[1]
|
||||
}
|
||||
print ""
|
||||
}
|
||||
' "$test_log" >> "$rust_test_summary"
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
echo
|
||||
awk -F '\t' '
|
||||
BEGIN {
|
||||
print "Rust test failures:"
|
||||
}
|
||||
$1 == "F" {
|
||||
saw_failure = 1
|
||||
print " FAIL " $2 " " $3
|
||||
}
|
||||
$1 == "T" {
|
||||
passed += $3
|
||||
failed += $4
|
||||
ignored += $5
|
||||
measured += $6
|
||||
filtered += $7
|
||||
result_count += 1
|
||||
}
|
||||
END {
|
||||
if (!saw_failure) {
|
||||
print " No Rust test failure names found in test logs."
|
||||
}
|
||||
|
||||
print ""
|
||||
print "Rust test result totals across failed Bazel targets:"
|
||||
if (result_count > 0) {
|
||||
printf " %d passed; %d failed; %d ignored; %d measured; %d filtered out\n",
|
||||
passed, failed, ignored, measured, filtered
|
||||
} else {
|
||||
print " No Rust test result totals found in test logs."
|
||||
}
|
||||
}
|
||||
' "$rust_test_summary"
|
||||
rm -f "$rust_test_summary"
|
||||
}
|
||||
|
||||
bazel_args=()
|
||||
@@ -271,8 +349,8 @@ else
|
||||
fi
|
||||
|
||||
if [[ ${bazel_status:-0} -ne 0 ]]; then
|
||||
if [[ $print_failed_bazel_test_logs -eq 1 ]]; then
|
||||
print_bazel_test_log_tails "$bazel_console_log"
|
||||
if [[ $summarize_failed_bazel_test_logs -eq 1 ]]; then
|
||||
summarize_failed_bazel_test_logs "$bazel_console_log"
|
||||
fi
|
||||
exit "$bazel_status"
|
||||
fi
|
||||
|
||||
3
.github/workflows/bazel.yml
vendored
3
.github/workflows/bazel.yml
vendored
@@ -97,7 +97,7 @@ jobs:
|
||||
)
|
||||
|
||||
bazel_wrapper_args=(
|
||||
--print-failed-test-logs
|
||||
--summarize-failed-test-logs
|
||||
--use-node-test-env
|
||||
)
|
||||
if [[ "${RUNNER_OS}" == "Windows" ]]; then
|
||||
@@ -108,6 +108,7 @@ jobs:
|
||||
"${bazel_wrapper_args[@]}" \
|
||||
-- \
|
||||
test \
|
||||
--test_output=errors \
|
||||
--test_tag_filters=-argument-comment-lint \
|
||||
--test_verbose_timeout_warnings \
|
||||
--build_metadata=COMMIT_SHA=${GITHUB_SHA} \
|
||||
|
||||
@@ -56,3 +56,11 @@ pub fn ansi_escape(s: &str) -> Text<'static> {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod preview_bazel_failed_logs_tests {
|
||||
#[test]
|
||||
fn preview_failed_log_from_ansi_escape() {
|
||||
panic!("intentional preview failure: ansi-escape test log");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,12 +38,10 @@ use supports_color::Stream;
|
||||
mod app_cmd;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod desktop_app;
|
||||
mod marketplace_cmd;
|
||||
mod mcp_cmd;
|
||||
#[cfg(not(windows))]
|
||||
mod wsl_paths;
|
||||
|
||||
use crate::marketplace_cmd::MarketplaceCli;
|
||||
use crate::mcp_cmd::McpCli;
|
||||
|
||||
use codex_core::config::Config;
|
||||
@@ -107,9 +105,6 @@ enum Subcommand {
|
||||
/// Manage external MCP servers for Codex.
|
||||
Mcp(McpCli),
|
||||
|
||||
/// Manage plugin marketplaces for Codex.
|
||||
Marketplace(MarketplaceCli),
|
||||
|
||||
/// Start Codex as an MCP server (stdio).
|
||||
McpServer,
|
||||
|
||||
@@ -696,18 +691,6 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
|
||||
prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone());
|
||||
mcp_cli.run().await?;
|
||||
}
|
||||
Some(Subcommand::Marketplace(mut marketplace_cli)) => {
|
||||
reject_remote_mode_for_subcommand(
|
||||
root_remote.as_deref(),
|
||||
root_remote_auth_token_env.as_deref(),
|
||||
"marketplace",
|
||||
)?;
|
||||
prepend_config_flags(
|
||||
&mut marketplace_cli.config_overrides,
|
||||
root_config_overrides.clone(),
|
||||
);
|
||||
marketplace_cli.run().await?;
|
||||
}
|
||||
Some(Subcommand::AppServer(app_server_cli)) => {
|
||||
let AppServerCommand {
|
||||
subcommand,
|
||||
|
||||
@@ -1,608 +0,0 @@
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use clap::Parser;
|
||||
use codex_config::MarketplaceConfigUpdate;
|
||||
use codex_config::record_user_marketplace;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use codex_core::plugins::marketplace_install_root;
|
||||
use codex_core::plugins::validate_marketplace_root;
|
||||
use codex_core::plugins::validate_plugin_segment;
|
||||
use codex_utils_cli::CliConfigOverrides;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
mod list;
|
||||
mod metadata;
|
||||
mod ops;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct MarketplaceCli {
|
||||
#[clap(flatten)]
|
||||
pub config_overrides: CliConfigOverrides,
|
||||
|
||||
#[command(subcommand)]
|
||||
subcommand: MarketplaceSubcommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Subcommand)]
|
||||
enum MarketplaceSubcommand {
|
||||
/// Add a marketplace repository or local marketplace directory.
|
||||
Add(AddMarketplaceArgs),
|
||||
/// List configured marketplaces.
|
||||
List(list::ListMarketplaceArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct AddMarketplaceArgs {
|
||||
/// Marketplace source. Supports owner/repo[@ref], git URLs, SSH URLs, or local directories.
|
||||
source: String,
|
||||
|
||||
/// Git ref to check out. Overrides any @ref or #ref suffix in SOURCE.
|
||||
#[arg(long = "ref", value_name = "REF")]
|
||||
ref_name: Option<String>,
|
||||
|
||||
/// Sparse-checkout path to use while cloning git sources. Repeat to include multiple paths.
|
||||
#[arg(
|
||||
long = "sparse",
|
||||
value_name = "PATH",
|
||||
action = clap::ArgAction::Append
|
||||
)]
|
||||
sparse_paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum MarketplaceSource {
|
||||
LocalDirectory {
|
||||
path: PathBuf,
|
||||
},
|
||||
Git {
|
||||
url: String,
|
||||
ref_name: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl MarketplaceCli {
|
||||
pub async fn run(self) -> Result<()> {
|
||||
let MarketplaceCli {
|
||||
config_overrides,
|
||||
subcommand,
|
||||
} = self;
|
||||
|
||||
// Validate overrides now. This command writes to CODEX_HOME only; marketplace discovery
|
||||
// happens from that cache root after the next plugin/list or app-server start.
|
||||
config_overrides
|
||||
.parse_overrides()
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
|
||||
match subcommand {
|
||||
MarketplaceSubcommand::Add(args) => run_add(args).await?,
|
||||
MarketplaceSubcommand::List(args) => list::run_list(args).await?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_add(args: AddMarketplaceArgs) -> Result<()> {
|
||||
let AddMarketplaceArgs {
|
||||
source,
|
||||
ref_name,
|
||||
sparse_paths,
|
||||
} = args;
|
||||
|
||||
let has_explicit_ref = ref_name.is_some();
|
||||
let source = parse_marketplace_source(&source, ref_name)?;
|
||||
let source_is_git = matches!(source, MarketplaceSource::Git { .. });
|
||||
if has_explicit_ref && !source_is_git {
|
||||
bail!("--ref can only be used with git marketplace sources");
|
||||
}
|
||||
if !sparse_paths.is_empty() && !source_is_git {
|
||||
bail!("--sparse can only be used with git marketplace sources");
|
||||
}
|
||||
|
||||
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
|
||||
let install_root = marketplace_install_root(&codex_home);
|
||||
fs::create_dir_all(&install_root).with_context(|| {
|
||||
format!(
|
||||
"failed to create marketplace install directory {}",
|
||||
install_root.display()
|
||||
)
|
||||
})?;
|
||||
let install_metadata =
|
||||
metadata::MarketplaceInstallMetadata::from_source(&source, &sparse_paths);
|
||||
if let Some(existing_root) = metadata::installed_marketplace_root_for_source(
|
||||
&codex_home,
|
||||
&install_root,
|
||||
&install_metadata,
|
||||
)? {
|
||||
let marketplace_name = validate_marketplace_root(&existing_root).with_context(|| {
|
||||
format!(
|
||||
"failed to validate installed marketplace at {}",
|
||||
existing_root.display()
|
||||
)
|
||||
})?;
|
||||
record_added_marketplace(&codex_home, &marketplace_name, &install_metadata)?;
|
||||
println!(
|
||||
"Marketplace `{marketplace_name}` is already added from {}.",
|
||||
source.display()
|
||||
);
|
||||
println!("Installed marketplace root: {}", existing_root.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let staging_root = ops::marketplace_staging_root(&install_root);
|
||||
fs::create_dir_all(&staging_root).with_context(|| {
|
||||
format!(
|
||||
"failed to create marketplace staging directory {}",
|
||||
staging_root.display()
|
||||
)
|
||||
})?;
|
||||
let staged_dir = tempfile::Builder::new()
|
||||
.prefix("marketplace-add-")
|
||||
.tempdir_in(&staging_root)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to create temporary marketplace directory in {}",
|
||||
staging_root.display()
|
||||
)
|
||||
})?;
|
||||
let staged_root = staged_dir.path().to_path_buf();
|
||||
|
||||
match &source {
|
||||
MarketplaceSource::LocalDirectory { path } => {
|
||||
ops::copy_dir_recursive(path, &staged_root).with_context(|| {
|
||||
format!(
|
||||
"failed to copy marketplace source {} into {}",
|
||||
path.display(),
|
||||
staged_root.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
MarketplaceSource::Git { url, ref_name } => {
|
||||
ops::clone_git_source(url, ref_name.as_deref(), &sparse_paths, &staged_root)?;
|
||||
}
|
||||
}
|
||||
|
||||
let marketplace_name = validate_marketplace_source_root(&staged_root)
|
||||
.with_context(|| format!("failed to validate marketplace from {}", source.display()))?;
|
||||
if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
|
||||
bail!(
|
||||
"marketplace `{OPENAI_CURATED_MARKETPLACE_NAME}` is reserved and cannot be added from {}",
|
||||
source.display()
|
||||
);
|
||||
}
|
||||
let destination = install_root.join(safe_marketplace_dir_name(&marketplace_name)?);
|
||||
ensure_marketplace_destination_is_inside_install_root(&install_root, &destination)?;
|
||||
if destination.exists() {
|
||||
bail!(
|
||||
"marketplace `{marketplace_name}` is already added from a different source; remove it before adding {}",
|
||||
source.display()
|
||||
);
|
||||
}
|
||||
ops::replace_marketplace_root(&staged_root, &destination)
|
||||
.with_context(|| format!("failed to install marketplace at {}", destination.display()))?;
|
||||
record_added_marketplace(&codex_home, &marketplace_name, &install_metadata)?;
|
||||
|
||||
println!(
|
||||
"Added marketplace `{marketplace_name}` from {}.",
|
||||
source.display()
|
||||
);
|
||||
println!("Installed marketplace root: {}", destination.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_added_marketplace(
|
||||
codex_home: &Path,
|
||||
marketplace_name: &str,
|
||||
install_metadata: &metadata::MarketplaceInstallMetadata,
|
||||
) -> Result<()> {
|
||||
let source = install_metadata.config_source();
|
||||
let last_updated = utc_timestamp_now()?;
|
||||
let update = MarketplaceConfigUpdate {
|
||||
last_updated: &last_updated,
|
||||
source_type: install_metadata.config_source_type(),
|
||||
source: &source,
|
||||
ref_name: install_metadata.ref_name(),
|
||||
sparse_paths: install_metadata.sparse_paths(),
|
||||
};
|
||||
record_user_marketplace(codex_home, marketplace_name, &update).with_context(|| {
|
||||
format!("failed to add marketplace `{marketplace_name}` to user config.toml")
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_marketplace_source_root(root: &Path) -> Result<String> {
|
||||
let marketplace_name = validate_marketplace_root(root)?;
|
||||
validate_plugin_segment(&marketplace_name, "marketplace name").map_err(anyhow::Error::msg)?;
|
||||
Ok(marketplace_name)
|
||||
}
|
||||
|
||||
fn parse_marketplace_source(
|
||||
source: &str,
|
||||
explicit_ref: Option<String>,
|
||||
) -> Result<MarketplaceSource> {
|
||||
let source = source.trim();
|
||||
if source.is_empty() {
|
||||
bail!("marketplace source must not be empty");
|
||||
}
|
||||
|
||||
let source = expand_home(source);
|
||||
let (base_source, parsed_ref) = split_source_ref(&source);
|
||||
let ref_name = explicit_ref.or(parsed_ref);
|
||||
|
||||
if is_ssh_git_url(&base_source) || is_http_git_url(&base_source) {
|
||||
let url = normalize_git_url(&base_source);
|
||||
return Ok(MarketplaceSource::Git { url, ref_name });
|
||||
}
|
||||
|
||||
let path = PathBuf::from(&source);
|
||||
let path_exists = path.try_exists().with_context(|| {
|
||||
format!(
|
||||
"failed to access local marketplace source {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
if path_exists || looks_like_local_path(&source) {
|
||||
if !path_exists {
|
||||
bail!(
|
||||
"local marketplace source does not exist: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
let metadata = path.metadata().with_context(|| {
|
||||
format!("failed to read local marketplace source {}", path.display())
|
||||
})?;
|
||||
if metadata.is_file() {
|
||||
if path
|
||||
.extension()
|
||||
.is_some_and(|extension| extension == "json")
|
||||
{
|
||||
bail!(
|
||||
"local marketplace JSON files are not supported yet; pass the marketplace root directory containing .agents/plugins/marketplace.json: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
bail!(
|
||||
"local marketplace source file must be a JSON marketplace manifest or a directory containing .agents/plugins/marketplace.json: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
if !metadata.is_dir() {
|
||||
bail!(
|
||||
"local marketplace source must be a file or directory: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
let path = path
|
||||
.canonicalize()
|
||||
.with_context(|| format!("failed to resolve {}", path.display()))?;
|
||||
return Ok(MarketplaceSource::LocalDirectory { path });
|
||||
}
|
||||
|
||||
if looks_like_github_shorthand(&base_source) {
|
||||
let url = format!("https://github.com/{base_source}.git");
|
||||
return Ok(MarketplaceSource::Git { url, ref_name });
|
||||
}
|
||||
|
||||
bail!("invalid marketplace source format: {source}");
|
||||
}
|
||||
|
||||
fn split_source_ref(source: &str) -> (String, Option<String>) {
|
||||
if let Some((base, ref_name)) = source.rsplit_once('#') {
|
||||
return (base.to_string(), non_empty_ref(ref_name));
|
||||
}
|
||||
if !source.contains("://")
|
||||
&& !is_ssh_git_url(source)
|
||||
&& let Some((base, ref_name)) = source.rsplit_once('@')
|
||||
{
|
||||
return (base.to_string(), non_empty_ref(ref_name));
|
||||
}
|
||||
(source.to_string(), None)
|
||||
}
|
||||
|
||||
fn non_empty_ref(ref_name: &str) -> Option<String> {
|
||||
let ref_name = ref_name.trim();
|
||||
(!ref_name.is_empty()).then(|| ref_name.to_string())
|
||||
}
|
||||
|
||||
fn normalize_git_url(url: &str) -> String {
|
||||
let url = url.trim_end_matches('/');
|
||||
if url.starts_with("https://github.com/") && !url.ends_with(".git") {
|
||||
format!("{url}.git")
|
||||
} else {
|
||||
url.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_local_path(source: &str) -> bool {
|
||||
source.starts_with("./")
|
||||
|| source.starts_with("../")
|
||||
|| source.starts_with('/')
|
||||
|| source.starts_with("~/")
|
||||
|| source == "."
|
||||
|| source == ".."
|
||||
}
|
||||
|
||||
fn expand_home(source: &str) -> String {
|
||||
let Some(rest) = source.strip_prefix("~/") else {
|
||||
return source.to_string();
|
||||
};
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
return PathBuf::from(home).join(rest).display().to_string();
|
||||
}
|
||||
source.to_string()
|
||||
}
|
||||
|
||||
fn is_ssh_git_url(source: &str) -> bool {
|
||||
source.starts_with("ssh://") || source.starts_with("git@") && source.contains(':')
|
||||
}
|
||||
|
||||
fn is_http_git_url(source: &str) -> bool {
|
||||
source.starts_with("http://") || source.starts_with("https://")
|
||||
}
|
||||
|
||||
fn looks_like_github_shorthand(source: &str) -> bool {
|
||||
let mut segments = source.split('/');
|
||||
let owner = segments.next();
|
||||
let repo = segments.next();
|
||||
let extra = segments.next();
|
||||
owner.is_some_and(is_github_shorthand_segment)
|
||||
&& repo.is_some_and(is_github_shorthand_segment)
|
||||
&& extra.is_none()
|
||||
}
|
||||
|
||||
fn is_github_shorthand_segment(segment: &str) -> bool {
|
||||
!segment.is_empty()
|
||||
&& segment
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
|
||||
}
|
||||
|
||||
fn safe_marketplace_dir_name(marketplace_name: &str) -> Result<String> {
|
||||
let safe = marketplace_name
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
|
||||
ch
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
let safe = safe.trim_matches('.').to_string();
|
||||
if safe.is_empty() || safe == ".." {
|
||||
bail!("marketplace name `{marketplace_name}` cannot be used as an install directory");
|
||||
}
|
||||
Ok(safe)
|
||||
}
|
||||
|
||||
fn ensure_marketplace_destination_is_inside_install_root(
|
||||
install_root: &Path,
|
||||
destination: &Path,
|
||||
) -> Result<()> {
|
||||
let install_root = install_root.canonicalize().with_context(|| {
|
||||
format!(
|
||||
"failed to resolve marketplace install root {}",
|
||||
install_root.display()
|
||||
)
|
||||
})?;
|
||||
let destination_parent = destination
|
||||
.parent()
|
||||
.context("marketplace destination has no parent")?
|
||||
.canonicalize()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to resolve marketplace destination parent {}",
|
||||
destination.display()
|
||||
)
|
||||
})?;
|
||||
if !destination_parent.starts_with(&install_root) {
|
||||
bail!(
|
||||
"marketplace destination {} is outside install root {}",
|
||||
destination.display(),
|
||||
install_root.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn utc_timestamp_now() -> Result<String> {
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.context("system clock is before Unix epoch")?;
|
||||
Ok(format_utc_timestamp(duration.as_secs() as i64))
|
||||
}
|
||||
|
||||
fn format_utc_timestamp(seconds_since_epoch: i64) -> String {
|
||||
const SECONDS_PER_DAY: i64 = 86_400;
|
||||
let days = seconds_since_epoch.div_euclid(SECONDS_PER_DAY);
|
||||
let seconds_of_day = seconds_since_epoch.rem_euclid(SECONDS_PER_DAY);
|
||||
let (year, month, day) = civil_from_days(days);
|
||||
let hour = seconds_of_day / 3_600;
|
||||
let minute = (seconds_of_day % 3_600) / 60;
|
||||
let second = seconds_of_day % 60;
|
||||
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
|
||||
}
|
||||
|
||||
fn civil_from_days(days_since_epoch: i64) -> (i64, i64, i64) {
|
||||
let days = days_since_epoch + 719_468;
|
||||
let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
|
||||
let day_of_era = days - era * 146_097;
|
||||
let year_of_era =
|
||||
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
|
||||
let mut year = year_of_era + era * 400;
|
||||
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
|
||||
let month_prime = (5 * day_of_year + 2) / 153;
|
||||
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
|
||||
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
|
||||
year += if month <= 2 { 1 } else { 0 };
|
||||
(year, month, day)
|
||||
}
|
||||
|
||||
impl MarketplaceSource {
|
||||
fn display(&self) -> String {
|
||||
match self {
|
||||
Self::LocalDirectory { path } => path.display().to_string(),
|
||||
Self::Git { url, ref_name } => {
|
||||
if let Some(ref_name) = ref_name {
|
||||
format!("{url}#{ref_name}")
|
||||
} else {
|
||||
url.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn github_shorthand_parses_ref_suffix() {
|
||||
assert_eq!(
|
||||
parse_marketplace_source("owner/repo@main", /* explicit_ref */ None).unwrap(),
|
||||
MarketplaceSource::Git {
|
||||
url: "https://github.com/owner/repo.git".to_string(),
|
||||
ref_name: Some("main".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_url_parses_fragment_ref() {
|
||||
assert_eq!(
|
||||
parse_marketplace_source(
|
||||
"https://example.com/team/repo.git#v1",
|
||||
/* explicit_ref */ None,
|
||||
)
|
||||
.unwrap(),
|
||||
MarketplaceSource::Git {
|
||||
url: "https://example.com/team/repo.git".to_string(),
|
||||
ref_name: Some("v1".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_ref_overrides_source_ref() {
|
||||
assert_eq!(
|
||||
parse_marketplace_source(
|
||||
"owner/repo@main",
|
||||
/* explicit_ref */ Some("release".to_string()),
|
||||
)
|
||||
.unwrap(),
|
||||
MarketplaceSource::Git {
|
||||
url: "https://github.com/owner/repo.git".to_string(),
|
||||
ref_name: Some("release".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_shorthand_and_git_url_normalize_to_same_source() {
|
||||
let shorthand = parse_marketplace_source("owner/repo", /* explicit_ref */ None).unwrap();
|
||||
let git_url = parse_marketplace_source(
|
||||
"https://github.com/owner/repo.git",
|
||||
/* explicit_ref */ None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(shorthand, git_url);
|
||||
assert_eq!(
|
||||
shorthand,
|
||||
MarketplaceSource::Git {
|
||||
url: "https://github.com/owner/repo.git".to_string(),
|
||||
ref_name: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_url_with_trailing_slash_normalizes_without_extra_path_segment() {
|
||||
assert_eq!(
|
||||
parse_marketplace_source(
|
||||
"https://github.com/owner/repo/",
|
||||
/* explicit_ref */ None
|
||||
)
|
||||
.unwrap(),
|
||||
MarketplaceSource::Git {
|
||||
url: "https://github.com/owner/repo.git".to_string(),
|
||||
ref_name: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_github_https_source_parses_as_git_url() {
|
||||
assert_eq!(
|
||||
parse_marketplace_source(
|
||||
"https://gitlab.com/owner/repo",
|
||||
/* explicit_ref */ None
|
||||
)
|
||||
.unwrap(),
|
||||
MarketplaceSource::Git {
|
||||
url: "https://gitlab.com/owner/repo".to_string(),
|
||||
ref_name: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_url_parses_as_git_url() {
|
||||
assert_eq!(
|
||||
parse_marketplace_source(
|
||||
"ssh://git@github.com/owner/repo.git#main",
|
||||
/* explicit_ref */ None,
|
||||
)
|
||||
.unwrap(),
|
||||
MarketplaceSource::Git {
|
||||
url: "ssh://git@github.com/owner/repo.git".to_string(),
|
||||
ref_name: Some("main".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utc_timestamp_formats_unix_epoch_as_rfc3339_utc() {
|
||||
assert_eq!(format_utc_timestamp(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 =
|
||||
AddMarketplaceArgs::try_parse_from(["add", "--sparse", "plugins/foo", "owner/repo"])
|
||||
.unwrap();
|
||||
assert_eq!(sparse_before_source.source, "owner/repo");
|
||||
assert_eq!(sparse_before_source.sparse_paths, vec!["plugins/foo"]);
|
||||
|
||||
let sparse_after_source =
|
||||
AddMarketplaceArgs::try_parse_from(["add", "owner/repo", "--sparse", "plugins/foo"])
|
||||
.unwrap();
|
||||
assert_eq!(sparse_after_source.source, "owner/repo");
|
||||
assert_eq!(sparse_after_source.sparse_paths, vec!["plugins/foo"]);
|
||||
|
||||
let repeated_sparse = AddMarketplaceArgs::try_parse_from([
|
||||
"add",
|
||||
"--sparse",
|
||||
"plugins/foo",
|
||||
"--sparse",
|
||||
"skills/bar",
|
||||
"owner/repo",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(repeated_sparse.source, "owner/repo");
|
||||
assert_eq!(
|
||||
repeated_sparse.sparse_paths,
|
||||
vec!["plugins/foo", "skills/bar"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
use super::metadata;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::plugins::marketplace_install_root;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub(super) struct ListMarketplaceArgs {
|
||||
/// Print the configured marketplaces as JSON.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
pub(super) async fn run_list(args: ListMarketplaceArgs) -> Result<()> {
|
||||
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
|
||||
let install_root = marketplace_install_root(&codex_home);
|
||||
let marketplaces = metadata::configured_marketplaces(&codex_home, &install_root)?;
|
||||
|
||||
if args.json {
|
||||
let json = marketplaces
|
||||
.into_iter()
|
||||
.map(|marketplace| {
|
||||
let install_metadata = marketplace.install_metadata;
|
||||
let mut json = serde_json::Map::new();
|
||||
json.insert(
|
||||
"name".to_string(),
|
||||
serde_json::Value::String(marketplace.name),
|
||||
);
|
||||
json.insert(
|
||||
"sourceType".to_string(),
|
||||
serde_json::Value::String(install_metadata.config_source_type().to_string()),
|
||||
);
|
||||
match install_metadata.config_source_type() {
|
||||
"directory" => {
|
||||
json.insert(
|
||||
"path".to_string(),
|
||||
serde_json::Value::String(install_metadata.config_source()),
|
||||
);
|
||||
}
|
||||
"git" => {
|
||||
json.insert(
|
||||
"url".to_string(),
|
||||
serde_json::Value::String(install_metadata.config_source()),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
json.insert(
|
||||
"installLocation".to_string(),
|
||||
serde_json::Value::String(marketplace.install_root.display().to_string()),
|
||||
);
|
||||
serde_json::Value::Object(json)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let output = serde_json::to_string_pretty(&json)?;
|
||||
println!("{output}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if marketplaces.is_empty() {
|
||||
println!("No marketplaces configured.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for marketplace in marketplaces {
|
||||
println!("{}", marketplace.name);
|
||||
println!(
|
||||
" Source: {} {}",
|
||||
marketplace.install_metadata.config_source_type(),
|
||||
marketplace.install_metadata.source_display()
|
||||
);
|
||||
println!(" Install root: {}", marketplace.install_root.display());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
use super::MarketplaceSource;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_config::types::MarketplaceConfig;
|
||||
use codex_config::types::MarketplaceSourceType;
|
||||
use codex_core::plugins::validate_marketplace_root;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct ConfiguredMarketplace {
|
||||
pub(super) name: String,
|
||||
pub(super) install_root: PathBuf,
|
||||
pub(super) install_metadata: MarketplaceInstallMetadata,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct MarketplaceInstallMetadata {
|
||||
source: InstalledMarketplaceSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum InstalledMarketplaceSource {
|
||||
LocalDirectory {
|
||||
path: PathBuf,
|
||||
},
|
||||
Git {
|
||||
url: String,
|
||||
ref_name: Option<String>,
|
||||
sparse_paths: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) fn installed_marketplace_root_for_source(
|
||||
codex_home: &Path,
|
||||
install_root: &Path,
|
||||
install_metadata: &MarketplaceInstallMetadata,
|
||||
) -> Result<Option<PathBuf>> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let 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 = install_root.join(marketplace_name);
|
||||
if validate_marketplace_root(&root).is_ok() {
|
||||
return Ok(Some(root));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(super) fn configured_marketplaces(
|
||||
codex_home: &Path,
|
||||
install_root: &Path,
|
||||
) -> Result<Vec<ConfiguredMarketplace>> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let config = read_user_config(&config_path)?;
|
||||
let mut marketplaces = config
|
||||
.marketplaces
|
||||
.into_iter()
|
||||
.map(|(name, config)| {
|
||||
let install_metadata = MarketplaceInstallMetadata::from_config(&name, &config)?;
|
||||
Ok(ConfiguredMarketplace {
|
||||
install_root: install_root.join(&name),
|
||||
name,
|
||||
install_metadata,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
marketplaces.sort_unstable_by(|left, right| left.name.cmp(&right.name));
|
||||
Ok(marketplaces)
|
||||
}
|
||||
|
||||
impl MarketplaceInstallMetadata {
|
||||
pub(super) fn from_source(source: &MarketplaceSource, sparse_paths: &[String]) -> Self {
|
||||
let source = match source {
|
||||
MarketplaceSource::LocalDirectory { path } => {
|
||||
InstalledMarketplaceSource::LocalDirectory { path: path.clone() }
|
||||
}
|
||||
MarketplaceSource::Git { url, ref_name } => InstalledMarketplaceSource::Git {
|
||||
url: url.clone(),
|
||||
ref_name: ref_name.clone(),
|
||||
sparse_paths: sparse_paths.to_vec(),
|
||||
},
|
||||
};
|
||||
Self { source }
|
||||
}
|
||||
|
||||
fn from_config(marketplace_name: &str, config: &MarketplaceConfig) -> Result<Self> {
|
||||
let Some(source_type) = config.source_type else {
|
||||
bail!("marketplace `{marketplace_name}` is missing source_type in user config.toml");
|
||||
};
|
||||
let Some(source) = config.source.as_ref() else {
|
||||
bail!("marketplace `{marketplace_name}` is missing source in user config.toml");
|
||||
};
|
||||
|
||||
let source = match source_type {
|
||||
MarketplaceSourceType::Directory => InstalledMarketplaceSource::LocalDirectory {
|
||||
path: PathBuf::from(source),
|
||||
},
|
||||
MarketplaceSourceType::Git => InstalledMarketplaceSource::Git {
|
||||
url: source.clone(),
|
||||
ref_name: config.ref_name.clone(),
|
||||
sparse_paths: config.sparse_paths.clone().unwrap_or_default(),
|
||||
},
|
||||
};
|
||||
Ok(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,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn source_display(&self) -> String {
|
||||
match &self.source {
|
||||
InstalledMarketplaceSource::LocalDirectory { path } => path.display().to_string(),
|
||||
InstalledMarketplaceSource::Git { url, ref_name, .. } => match ref_name {
|
||||
Some(ref_name) => format!("{url}#{ref_name}"),
|
||||
None => url.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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 read_user_config(config_path: &Path) -> Result<ConfigToml> {
|
||||
match std::fs::read_to_string(config_path) {
|
||||
Ok(raw) => toml::from_str::<ConfigToml>(&raw)
|
||||
.with_context(|| format!("failed to parse user config {}", config_path.display())),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(ConfigToml::default()),
|
||||
Err(err) => Err(err)
|
||||
.with_context(|| format!("failed to read user config {}", config_path.display())),
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
pub(super) fn clone_git_source(
|
||||
url: &str,
|
||||
ref_name: Option<&str>,
|
||||
sparse_paths: &[String],
|
||||
destination: &Path,
|
||||
) -> Result<()> {
|
||||
let destination = destination.to_string_lossy().to_string();
|
||||
if sparse_paths.is_empty() {
|
||||
run_git(&["clone", url, destination.as_str()], /*cwd*/ None)?;
|
||||
if let Some(ref_name) = ref_name {
|
||||
run_git(&["checkout", ref_name], Some(Path::new(&destination)))?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
run_git(
|
||||
&[
|
||||
"clone",
|
||||
"--filter=blob:none",
|
||||
"--no-checkout",
|
||||
url,
|
||||
destination.as_str(),
|
||||
],
|
||||
/*cwd*/ None,
|
||||
)?;
|
||||
let mut sparse_args = vec!["sparse-checkout", "set"];
|
||||
sparse_args.extend(sparse_paths.iter().map(String::as_str));
|
||||
let destination = Path::new(&destination);
|
||||
run_git(&sparse_args, Some(destination))?;
|
||||
run_git(&["checkout", ref_name.unwrap_or("HEAD")], Some(destination))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_git(args: &[&str], cwd: Option<&Path>) -> Result<()> {
|
||||
let mut command = Command::new("git");
|
||||
command.args(args);
|
||||
command.env("GIT_TERMINAL_PROMPT", "0");
|
||||
if let Some(cwd) = cwd {
|
||||
command.current_dir(cwd);
|
||||
}
|
||||
|
||||
let output = command
|
||||
.output()
|
||||
.with_context(|| format!("failed to run git {}", args.join(" ")))?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
bail!(
|
||||
"git {} failed with status {}\nstdout:\n{}\nstderr:\n{}",
|
||||
args.join(" "),
|
||||
output.status,
|
||||
stdout.trim(),
|
||||
stderr.trim()
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn copy_dir_recursive(source: &Path, target: &Path) -> Result<()> {
|
||||
fs::create_dir_all(target)?;
|
||||
for entry in fs::read_dir(source)? {
|
||||
let entry = entry?;
|
||||
let source_path = entry.path();
|
||||
let target_path = target.join(entry.file_name());
|
||||
let file_type = entry.file_type()?;
|
||||
|
||||
if file_type.is_dir() {
|
||||
if entry.file_name().to_str() == Some(".git") {
|
||||
continue;
|
||||
}
|
||||
copy_dir_recursive(&source_path, &target_path)?;
|
||||
} else if file_type.is_file() {
|
||||
fs::copy(&source_path, &target_path)?;
|
||||
} else if file_type.is_symlink() {
|
||||
copy_symlink_target(&source_path, &target_path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn copy_symlink_target(source: &Path, target: &Path) -> Result<()> {
|
||||
std::os::unix::fs::symlink(fs::read_link(source)?, target)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn copy_symlink_target(source: &Path, target: &Path) -> Result<()> {
|
||||
let metadata = fs::metadata(source)?;
|
||||
if metadata.is_dir() {
|
||||
copy_dir_recursive(source, target)
|
||||
} else {
|
||||
fs::copy(source, target).map(|_| ()).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn replace_marketplace_root(staged_root: &Path, destination: &Path) -> Result<()> {
|
||||
if let Some(parent) = destination.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
if destination.exists() {
|
||||
bail!(
|
||||
"marketplace destination already exists: {}",
|
||||
destination.display()
|
||||
);
|
||||
}
|
||||
|
||||
fs::rename(staged_root, destination).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub(super) fn marketplace_staging_root(install_root: &Path) -> PathBuf {
|
||||
install_root.join(".staging")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn replace_marketplace_root_rejects_existing_destination() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let staged_root = temp_dir.path().join("staged");
|
||||
let destination = temp_dir.path().join("destination");
|
||||
fs::create_dir_all(&staged_root).unwrap();
|
||||
fs::write(staged_root.join("marker.txt"), "staged").unwrap();
|
||||
fs::create_dir_all(&destination).unwrap();
|
||||
fs::write(destination.join("marker.txt"), "installed").unwrap();
|
||||
|
||||
let err = replace_marketplace_root(&staged_root, &destination).unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("marketplace destination already exists"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(staged_root.join("marker.txt")).unwrap(),
|
||||
"staged"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(destination.join("marker.txt")).unwrap(),
|
||||
"installed"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_core::plugins::marketplace_install_root;
|
||||
use codex_core::plugins::validate_marketplace_root;
|
||||
use predicates::str::contains;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value as JsonValue;
|
||||
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<()> {
|
||||
write_marketplace_source_with_name(source, "debug", marker)
|
||||
}
|
||||
|
||||
fn write_marketplace_source_with_name(
|
||||
source: &Path,
|
||||
marketplace_name: &str,
|
||||
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"),
|
||||
format!(
|
||||
r#"{{
|
||||
"name": "{marketplace_name}",
|
||||
"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_local_directory_installs_valid_marketplace_root() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let source = TempDir::new()?;
|
||||
write_marketplace_source(source.path(), "first install")?;
|
||||
|
||||
let mut add_cmd = codex_command(codex_home.path())?;
|
||||
add_cmd
|
||||
.args(["marketplace", "add", source.path().to_str().unwrap()])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Added marketplace `debug`"));
|
||||
|
||||
let installed_root = marketplace_install_root(codex_home.path()).join("debug");
|
||||
assert_eq!(validate_marketplace_root(&installed_root)?, "debug");
|
||||
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(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marketplace_add_rejects_invalid_marketplace_name() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let source = TempDir::new()?;
|
||||
write_marketplace_source_with_name(source.path(), "debug.market", "invalid marketplace")?;
|
||||
|
||||
codex_command(codex_home.path())?
|
||||
.args(["marketplace", "add", source.path().to_str().unwrap()])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains(
|
||||
"invalid marketplace name: only ASCII letters, digits, `_`, and `-` are allowed",
|
||||
));
|
||||
|
||||
assert!(
|
||||
!marketplace_install_root(codex_home.path())
|
||||
.join("debug.market")
|
||||
.exists()
|
||||
);
|
||||
assert!(
|
||||
!codex_home
|
||||
.path()
|
||||
.join(".tmp/known_marketplaces.json")
|
||||
.exists()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marketplace_add_same_source_is_idempotent() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let source = TempDir::new()?;
|
||||
write_marketplace_source(source.path(), "first install")?;
|
||||
|
||||
codex_command(codex_home.path())?
|
||||
.args(["marketplace", "add", source.path().to_str().unwrap()])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Added marketplace `debug`"));
|
||||
|
||||
std::fs::write(
|
||||
source.path().join("plugins/sample/marker.txt"),
|
||||
"source changed after add",
|
||||
)?;
|
||||
|
||||
codex_command(codex_home.path())?
|
||||
.args(["marketplace", "add", source.path().to_str().unwrap()])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Marketplace `debug` is already added"));
|
||||
|
||||
let installed_root = marketplace_install_root(codex_home.path()).join("debug");
|
||||
assert_eq!(
|
||||
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(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marketplace_add_rejects_same_name_from_different_source() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let first_source = TempDir::new()?;
|
||||
let second_source = TempDir::new()?;
|
||||
write_marketplace_source(first_source.path(), "first install")?;
|
||||
write_marketplace_source(second_source.path(), "replacement install")?;
|
||||
|
||||
codex_command(codex_home.path())?
|
||||
.args(["marketplace", "add", first_source.path().to_str().unwrap()])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Added marketplace `debug`"));
|
||||
|
||||
codex_command(codex_home.path())?
|
||||
.args(["marketplace", "add", second_source.path().to_str().unwrap()])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains(
|
||||
"marketplace `debug` is already added from a different source",
|
||||
));
|
||||
|
||||
let installed_root = marketplace_install_root(codex_home.path()).join("debug");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(installed_root.join("plugins/sample/marker.txt"))?,
|
||||
"first install"
|
||||
);
|
||||
|
||||
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()?;
|
||||
let source = TempDir::new()?;
|
||||
let source = source.path().to_str().unwrap();
|
||||
let sparse_requires_git = "--sparse can only be used with git marketplace sources";
|
||||
|
||||
codex_command(codex_home.path())?
|
||||
.args(["marketplace", "add", "--sparse", "plugins/foo", source])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains(sparse_requires_git));
|
||||
|
||||
codex_command(codex_home.path())?
|
||||
.args(["marketplace", "add", source, "--sparse", "plugins/foo"])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains(sparse_requires_git));
|
||||
|
||||
codex_command(codex_home.path())?
|
||||
.args([
|
||||
"marketplace",
|
||||
"add",
|
||||
"--sparse",
|
||||
"plugins/foo",
|
||||
"--sparse",
|
||||
"skills/bar",
|
||||
source,
|
||||
])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains(sparse_requires_git));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marketplace_add_rejects_ref_for_local_directory() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let source = TempDir::new()?;
|
||||
write_marketplace_source(source.path(), "local ref")?;
|
||||
|
||||
codex_command(codex_home.path())?
|
||||
.args([
|
||||
"marketplace",
|
||||
"add",
|
||||
"--ref",
|
||||
"main",
|
||||
source.path().to_str().unwrap(),
|
||||
])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains(
|
||||
"--ref can only be used with git marketplace sources",
|
||||
));
|
||||
|
||||
assert!(
|
||||
!marketplace_install_root(codex_home.path())
|
||||
.join("debug")
|
||||
.exists()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marketplace_list_prints_configured_marketplaces() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let source = TempDir::new()?;
|
||||
write_marketplace_source(source.path(), "first install")?;
|
||||
|
||||
codex_command(codex_home.path())?
|
||||
.args(["marketplace", "add", source.path().to_str().unwrap()])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
codex_command(codex_home.path())?
|
||||
.args(["marketplace", "list"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("debug"))
|
||||
.stdout(contains("Source: directory"))
|
||||
.stdout(contains(
|
||||
source.path().canonicalize()?.display().to_string(),
|
||||
));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marketplace_list_json_includes_source_and_install_location() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let source = TempDir::new()?;
|
||||
write_marketplace_source(source.path(), "first install")?;
|
||||
|
||||
codex_command(codex_home.path())?
|
||||
.args(["marketplace", "add", source.path().to_str().unwrap()])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let output = codex_command(codex_home.path())?
|
||||
.args(["marketplace", "list", "--json"])
|
||||
.output()?;
|
||||
assert!(output.status.success());
|
||||
|
||||
let json: JsonValue = serde_json::from_slice(&output.stdout)?;
|
||||
let entries = json
|
||||
.as_array()
|
||||
.context("list --json should return an array")?;
|
||||
let expected_source = source.path().canonicalize()?.display().to_string();
|
||||
let expected_install_root = marketplace_install_root(codex_home.path())
|
||||
.join("debug")
|
||||
.canonicalize()?
|
||||
.display()
|
||||
.to_string();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(
|
||||
entries[0].get("name").and_then(JsonValue::as_str),
|
||||
Some("debug")
|
||||
);
|
||||
assert_eq!(
|
||||
entries[0].get("sourceType").and_then(JsonValue::as_str),
|
||||
Some("directory")
|
||||
);
|
||||
assert_eq!(
|
||||
entries[0].get("path").and_then(JsonValue::as_str),
|
||||
Some(expected_source.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
entries[0]
|
||||
.get("installLocation")
|
||||
.and_then(JsonValue::as_str),
|
||||
Some(expected_install_root.as_str())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marketplace_list_without_marketplaces_prints_empty_message() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
codex_command(codex_home.path())?
|
||||
.args(["marketplace", "list"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("No marketplaces configured."));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -565,4 +565,14 @@ mod tests {
|
||||
assert!(description.contains("`setTimeout(callback: () => void, delayMs?: number)`"));
|
||||
assert!(description.contains("`clearTimeout(timeoutId?: number)`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_failed_log_from_code_mode() {
|
||||
let actual = "code-mode actual preview value";
|
||||
let expected = "code-mode expected preview value";
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"intentional preview failure: code-mode assertion diff"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ 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;
|
||||
@@ -326,10 +325,6 @@ 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,7 +4,6 @@ pub mod config_toml;
|
||||
mod constraint;
|
||||
mod diagnostics;
|
||||
mod fingerprint;
|
||||
mod marketplace_edit;
|
||||
mod mcp_edit;
|
||||
mod mcp_types;
|
||||
mod merge;
|
||||
@@ -58,8 +57,6 @@ 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;
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
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,33 +578,6 @@ 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 {
|
||||
|
||||
@@ -41,3 +41,9 @@ fn deserialize_skill_config_with_path_selector() {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_failed_log_from_config() {
|
||||
eprintln!("intentional preview stderr: config before panic");
|
||||
panic!("intentional preview failure: config panic after stderr");
|
||||
}
|
||||
|
||||
@@ -754,51 +754,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"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.",
|
||||
@@ -2367,14 +2322,6 @@
|
||||
],
|
||||
"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",
|
||||
|
||||
@@ -4089,18 +4089,13 @@ async fn handle_output_item_done_records_image_save_history_message() {
|
||||
let image_output_dir = image_output_path
|
||||
.parent()
|
||||
.expect("generated image path should have a parent");
|
||||
let save_message: ResponseItem = DeveloperInstructions::new(format!(
|
||||
"Generated images are saved to {} as {} by default.",
|
||||
let image_message: ResponseItem = DeveloperInstructions::new(format!(
|
||||
"Generated images are saved to {} as {} by default.\nIf you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.",
|
||||
image_output_dir.display(),
|
||||
image_output_path.display(),
|
||||
))
|
||||
.into();
|
||||
let copy_message: ResponseItem = DeveloperInstructions::new(
|
||||
"If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it."
|
||||
.to_string(),
|
||||
)
|
||||
.into();
|
||||
assert_eq!(history.raw_items(), &[save_message, copy_message, item]);
|
||||
assert_eq!(history.raw_items(), &[image_message, item]);
|
||||
assert_eq!(
|
||||
std::fs::read(&expected_saved_path).expect("saved file"),
|
||||
b"foo"
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
use crate::config::Config;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tracing::warn;
|
||||
|
||||
use super::validate_plugin_segment;
|
||||
|
||||
pub const INSTALLED_MARKETPLACES_DIR: &str = ".tmp/marketplaces";
|
||||
|
||||
pub fn marketplace_install_root(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join(INSTALLED_MARKETPLACES_DIR)
|
||||
}
|
||||
|
||||
pub(crate) fn installed_marketplace_roots_from_config(
|
||||
config: &Config,
|
||||
codex_home: &Path,
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
let Some(user_layer) = config.config_layer_stack.get_user_layer() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(marketplaces_value) = user_layer.config.get("marketplaces") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(marketplaces) = marketplaces_value.as_table() else {
|
||||
warn!("invalid marketplaces config: expected table");
|
||||
return Vec::new();
|
||||
};
|
||||
let default_install_root = marketplace_install_root(codex_home);
|
||||
let mut roots = marketplaces
|
||||
.iter()
|
||||
.filter_map(|(marketplace_name, marketplace)| {
|
||||
if !marketplace.is_table() {
|
||||
warn!(
|
||||
marketplace_name,
|
||||
"ignoring invalid configured marketplace entry"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if let Err(err) = validate_plugin_segment(marketplace_name, "marketplace name") {
|
||||
warn!(
|
||||
marketplace_name,
|
||||
error = %err,
|
||||
"ignoring invalid configured marketplace name"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let path = default_install_root.join(marketplace_name);
|
||||
path.join(".agents/plugins/marketplace.json")
|
||||
.is_file()
|
||||
.then_some(path)
|
||||
})
|
||||
.filter_map(|path| AbsolutePathBuf::try_from(path).ok())
|
||||
.collect::<Vec<_>>();
|
||||
roots.sort_unstable_by(|left, right| left.as_path().cmp(right.as_path()));
|
||||
roots
|
||||
}
|
||||
@@ -2,7 +2,6 @@ use super::LoadedPlugin;
|
||||
use super::PluginLoadOutcome;
|
||||
use super::PluginManifestPaths;
|
||||
use super::curated_plugins_repo_path;
|
||||
use super::installed_marketplaces::installed_marketplace_roots_from_config;
|
||||
use super::load_plugin_manifest;
|
||||
use super::manifest::PluginManifestInterface;
|
||||
use super::marketplace::MarketplaceError;
|
||||
@@ -875,8 +874,7 @@ impl PluginsManager {
|
||||
}
|
||||
|
||||
let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config);
|
||||
let marketplace_outcome =
|
||||
list_marketplaces(&self.marketplace_roots(config, additional_roots))?;
|
||||
let marketplace_outcome = list_marketplaces(&self.marketplace_roots(additional_roots))?;
|
||||
let mut seen_plugin_keys = HashSet::new();
|
||||
let marketplaces = marketplace_outcome
|
||||
.marketplaces
|
||||
@@ -1220,18 +1218,10 @@ impl PluginsManager {
|
||||
(installed_plugins, enabled_plugins)
|
||||
}
|
||||
|
||||
fn marketplace_roots(
|
||||
&self,
|
||||
config: &Config,
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
fn marketplace_roots(&self, additional_roots: &[AbsolutePathBuf]) -> Vec<AbsolutePathBuf> {
|
||||
// Treat the curated catalog as an extra marketplace root so plugin listing can surface it
|
||||
// without requiring every caller to know where it is stored.
|
||||
let mut roots = additional_roots.to_vec();
|
||||
roots.extend(installed_marketplace_roots_from_config(
|
||||
config,
|
||||
self.codex_home.as_path(),
|
||||
));
|
||||
let curated_repo_root = curated_plugins_repo_path(self.codex_home.as_path());
|
||||
if curated_repo_root.is_dir()
|
||||
&& let Ok(curated_repo_root) = AbsolutePathBuf::try_from(curated_repo_root)
|
||||
|
||||
@@ -8,7 +8,6 @@ use crate::config_loader::ConfigRequirementsToml;
|
||||
use crate::plugins::LoadedPlugin;
|
||||
use crate::plugins::MarketplacePluginInstallPolicy;
|
||||
use crate::plugins::PluginLoadOutcome;
|
||||
use crate::plugins::marketplace_install_root;
|
||||
use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA;
|
||||
use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha;
|
||||
use crate::plugins::test_support::write_file;
|
||||
@@ -1505,174 +1504,6 @@ plugins = true
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_marketplaces_includes_installed_marketplace_roots() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let marketplace_root = marketplace_install_root(tmp.path()).join("debug");
|
||||
let plugin_root = marketplace_root.join("plugins/sample");
|
||||
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[marketplaces.debug]
|
||||
last_updated = "2026-04-10T12:34:56Z"
|
||||
source_type = "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();
|
||||
let config = load_config(tmp.path(), tmp.path()).await;
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[])
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
let marketplace = marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| marketplace.name == "debug")
|
||||
.expect("installed marketplace should be listed");
|
||||
|
||||
assert_eq!(
|
||||
marketplace.path,
|
||||
AbsolutePathBuf::try_from(marketplace_root.join(".agents/plugins/marketplace.json"))
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(marketplace.plugins.len(), 1);
|
||||
assert_eq!(marketplace.plugins[0].id, "sample@debug");
|
||||
assert_eq!(
|
||||
marketplace.plugins[0].source,
|
||||
MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(plugin_root).unwrap(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_marketplaces_uses_config_when_known_registry_is_malformed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let marketplace_root = marketplace_install_root(tmp.path()).join("debug");
|
||||
let plugin_root = marketplace_root.join("plugins/sample");
|
||||
let registry_path = tmp.path().join(".tmp/known_marketplaces.json");
|
||||
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[marketplaces.debug]
|
||||
last_updated = "2026-04-10T12:34:56Z"
|
||||
source_type = "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();
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
||||
fs::write(
|
||||
marketplace_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "debug",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "sample",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/sample"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let config = load_config(tmp.path(), tmp.path()).await;
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[])
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert!(marketplaces.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_marketplaces_uses_first_duplicate_plugin_entry() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -211,17 +211,6 @@ pub fn list_marketplaces(
|
||||
list_marketplaces_with_home(additional_roots, home_dir().as_deref())
|
||||
}
|
||||
|
||||
pub fn validate_marketplace_root(root: &Path) -> Result<String, MarketplaceError> {
|
||||
let path = AbsolutePathBuf::try_from(root.join(MARKETPLACE_RELATIVE_PATH)).map_err(|err| {
|
||||
MarketplaceError::InvalidMarketplaceFile {
|
||||
path: root.join(MARKETPLACE_RELATIVE_PATH),
|
||||
message: format!("marketplace path must resolve to an absolute path: {err}"),
|
||||
}
|
||||
})?;
|
||||
let marketplace = load_marketplace(&path)?;
|
||||
Ok(marketplace.name)
|
||||
}
|
||||
|
||||
pub(crate) fn load_marketplace(path: &AbsolutePathBuf) -> Result<Marketplace, MarketplaceError> {
|
||||
let marketplace = load_raw_marketplace_manifest(path)?;
|
||||
let mut plugins = Vec::new();
|
||||
|
||||
@@ -2,7 +2,6 @@ use codex_config::types::McpServerConfig;
|
||||
|
||||
mod discoverable;
|
||||
mod injection;
|
||||
mod installed_marketplaces;
|
||||
mod manager;
|
||||
mod manifest;
|
||||
mod marketplace;
|
||||
@@ -21,15 +20,12 @@ pub use codex_plugin::PluginCapabilitySummary;
|
||||
pub use codex_plugin::PluginId;
|
||||
pub use codex_plugin::PluginIdError;
|
||||
pub use codex_plugin::PluginTelemetryMetadata;
|
||||
pub use codex_plugin::validate_plugin_segment;
|
||||
|
||||
pub type LoadedPlugin = codex_plugin::LoadedPlugin<McpServerConfig>;
|
||||
pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome<McpServerConfig>;
|
||||
|
||||
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
|
||||
pub(crate) use injection::build_plugin_injections;
|
||||
pub use installed_marketplaces::INSTALLED_MARKETPLACES_DIR;
|
||||
pub use installed_marketplaces::marketplace_install_root;
|
||||
pub use manager::ConfiguredMarketplace;
|
||||
pub use manager::ConfiguredMarketplaceListOutcome;
|
||||
pub use manager::ConfiguredMarketplacePlugin;
|
||||
@@ -57,7 +53,6 @@ pub use marketplace::MarketplacePluginAuthPolicy;
|
||||
pub use marketplace::MarketplacePluginInstallPolicy;
|
||||
pub use marketplace::MarketplacePluginPolicy;
|
||||
pub use marketplace::MarketplacePluginSource;
|
||||
pub use marketplace::validate_marketplace_root;
|
||||
pub use remote::RemotePluginFetchError;
|
||||
pub use remote::fetch_remote_featured_plugin_ids;
|
||||
pub(crate) use render::render_explicit_plugin_instructions;
|
||||
|
||||
@@ -379,17 +379,12 @@ pub(crate) async fn handle_non_tool_response_item(
|
||||
.parent()
|
||||
.unwrap_or(turn_context.config.codex_home.as_path());
|
||||
let message: ResponseItem = DeveloperInstructions::new(format!(
|
||||
"Generated images are saved to {} as {} by default.",
|
||||
"Generated images are saved to {} as {} by default.\nIf you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.",
|
||||
image_output_dir.display(),
|
||||
image_output_path.display(),
|
||||
))
|
||||
.into();
|
||||
let copy_message: ResponseItem = DeveloperInstructions::new(
|
||||
"If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it."
|
||||
.to_string(),
|
||||
)
|
||||
.into();
|
||||
sess.record_conversation_items(turn_context, &[message, copy_message])
|
||||
sess.record_conversation_items(turn_context, &[message])
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
|
||||
@@ -67,6 +67,11 @@ fn test_invalid_bytes_still_fall_back_to_lossy() {
|
||||
assert_eq!(decode_shell_output(bytes), String::from_utf8_lossy(bytes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_failed_log_from_protocol() -> Result<(), String> {
|
||||
Err("intentional preview failure: protocol returned Result::Err".to_string())
|
||||
}
|
||||
|
||||
fn decode_shell_output(bytes: &[u8]) -> String {
|
||||
StreamOutput {
|
||||
text: bytes.to_vec(),
|
||||
|
||||
@@ -279,3 +279,12 @@ fn byte_count_conversion_clamps_non_positive_values() {
|
||||
assert_eq!(approx_tokens_from_byte_count_i64(/*bytes*/ 0), 0);
|
||||
assert_eq!(approx_tokens_from_byte_count_i64(/*bytes*/ 5), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_failed_log_from_output_truncation() {
|
||||
let output = "output-truncation preview value";
|
||||
assert!(
|
||||
output.contains("truncated marker"),
|
||||
"intentional preview failure: output-truncation predicate assertion"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user