Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 31 additions & 10 deletions codex-rs/app-server/tests/suite/v2/marketplace_add.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::MarketplaceAddParams;
use codex_app_server_protocol::MarketplaceAddResponse;
use codex_app_server_protocol::RequestId;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::Duration;
use tokio::time::timeout;

const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);

#[tokio::test]
async fn marketplace_add_rejects_local_directory_source() -> Result<()> {
async fn marketplace_add_local_directory_source() -> Result<()> {
let codex_home = TempDir::new()?;
let source = codex_home.path().join("marketplace");
std::fs::create_dir_all(source.join(".agents/plugins"))?;
std::fs::create_dir_all(source.join("plugins/sample/.codex-plugin"))?;
std::fs::write(
source.join(".agents/plugins/marketplace.json"),
r#"{"name":"debug","plugins":[]}"#,
)?;
std::fs::write(
source.join("plugins/sample/.codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)?;
std::fs::write(source.join("plugins/sample/marker.txt"), "local ref")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;

Expand All @@ -22,19 +38,24 @@ async fn marketplace_add_rejects_local_directory_source() -> Result<()> {
})
.await?;

let err = timeout(
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let MarketplaceAddResponse {
marketplace_name,
installed_root,
already_added,
} = to_response(response)?;
let expected_root = source.canonicalize()?;

assert_eq!(err.error.code, -32600);
assert!(
err.error.message.contains(
"local marketplace sources are not supported yet; use an HTTP(S) Git URL, SSH Git URL, or GitHub owner/repo"
),
"unexpected error: {}",
err.error.message
assert_eq!(marketplace_name, "debug");
assert_eq!(installed_root.as_path(), expected_root.as_path());
assert!(!already_added);
assert_eq!(
std::fs::read_to_string(installed_root.as_path().join("plugins/sample/marker.txt"))?,
"local ref"
);
Ok(())
}
3 changes: 2 additions & 1 deletion codex-rs/cli/src/marketplace_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ enum MarketplaceSubcommand {

#[derive(Debug, Parser)]
struct AddMarketplaceArgs {
/// Marketplace source. Supports owner/repo[@ref], HTTP(S) Git URLs, or SSH URLs.
/// Marketplace source. Supports owner/repo[@ref], HTTP(S) Git URLs, SSH URLs,
/// or local marketplace root directories.
source: String,

/// Git ref to check out. Overrides any @ref or #ref suffix in SOURCE.
Expand Down
42 changes: 34 additions & 8 deletions codex-rs/cli/tests/marketplace_add.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use anyhow::Result;
use codex_config::CONFIG_TOML_FILE;
use codex_core::plugins::marketplace_install_root;
use predicates::str::contains;
use pretty_assertions::assert_eq;
use std::path::Path;
use tempfile::TempDir;

Expand Down Expand Up @@ -37,7 +39,7 @@ fn write_marketplace_source(source: &Path, marker: &str) -> Result<()> {
}

#[tokio::test]
async fn marketplace_add_rejects_local_directory_source() -> Result<()> {
async fn marketplace_add_local_directory_source() -> Result<()> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_marketplace_source(source.path(), "local ref")?;
Expand All @@ -48,16 +50,40 @@ async fn marketplace_add_rejects_local_directory_source() -> Result<()> {
.current_dir(source_parent)
.args(["marketplace", "add", source_arg.as_str()])
.assert()
.success();

let installed_root = marketplace_install_root(codex_home.path()).join("debug");
assert!(!installed_root.exists());

let config = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?;
let config: toml::Value = toml::from_str(&config)?;
let expected_source = source.path().canonicalize()?.display().to_string();
assert_eq!(
config["marketplaces"]["debug"]["source_type"].as_str(),
Some("local")
);
assert_eq!(
config["marketplaces"]["debug"]["source"].as_str(),
Some(expected_source.as_str())
);

Ok(())
}

#[tokio::test]
async fn marketplace_add_rejects_local_manifest_file_source() -> Result<()> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_marketplace_source(source.path(), "local ref")?;
let manifest_path = source.path().join(".agents/plugins/marketplace.json");

codex_command(codex_home.path())?
.args(["marketplace", "add", manifest_path.to_str().unwrap()])
.assert()
.failure()
.stderr(contains(
"local marketplace sources are not supported yet; use an HTTP(S) Git URL, SSH Git URL, or GitHub owner/repo",
"local marketplace source must be a directory, not a file",
));

assert!(
!marketplace_install_root(codex_home.path())
.join("debug")
.exists()
);

Ok(())
}
1 change: 1 addition & 0 deletions codex-rs/config/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,7 @@ pub struct MarketplaceConfig {
#[serde(rename_all = "snake_case")]
pub enum MarketplaceSourceType {
Git,
Local,
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
Expand Down
3 changes: 2 additions & 1 deletion codex-rs/core/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -810,7 +810,8 @@
},
"MarketplaceSourceType": {
"enum": [
"git"
"git",
"local"
],
"type": "string"
},
Expand Down
21 changes: 20 additions & 1 deletion codex-rs/core/src/plugins/installed_marketplaces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ pub(crate) fn installed_marketplace_roots_from_config(
);
return None;
}
let path = default_install_root.join(marketplace_name);
let path = resolve_configured_marketplace_root(
marketplace_name,
marketplace,
&default_install_root,
)?;
path.join(".agents/plugins/marketplace.json")
.is_file()
.then_some(path)
Expand All @@ -55,3 +59,18 @@ pub(crate) fn installed_marketplace_roots_from_config(
roots.sort_unstable_by(|left, right| left.as_path().cmp(right.as_path()));
roots
}

pub(crate) fn resolve_configured_marketplace_root(
marketplace_name: &str,
marketplace: &toml::Value,
default_install_root: &Path,
) -> Option<PathBuf> {
match marketplace.get("source_type").and_then(toml::Value::as_str) {
Some("local") => marketplace
.get("source")
.and_then(toml::Value::as_str)
.filter(|source| !source.is_empty())
.map(PathBuf::from),
_ => Some(default_install_root.join(marketplace_name)),
}
}
101 changes: 99 additions & 2 deletions codex-rs/core/src/plugins/marketplace_add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ use install::marketplace_staging_root;
use install::replace_marketplace_root;
use install::safe_marketplace_dir_name;
use metadata::MarketplaceInstallMetadata;
use metadata::find_marketplace_root_by_name;
use metadata::installed_marketplace_root_for_source;
use metadata::record_added_marketplace_entry;
use source::MarketplaceSource;
use source::parse_marketplace_source;
use source::stage_marketplace_source;
use source::validate_marketplace_source_root;

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -102,6 +104,33 @@ where
});
}

if let MarketplaceSource::Local { path } = &source {
let marketplace_name = validate_marketplace_source_root(path)?;
if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
return Err(MarketplaceAddError::InvalidRequest(format!(
"marketplace '{OPENAI_CURATED_MARKETPLACE_NAME}' is reserved and cannot be added from {}",
source.display()
)));
}
if find_marketplace_root_by_name(codex_home, &install_root, &marketplace_name)?.is_some() {
return Err(MarketplaceAddError::InvalidRequest(format!(
"marketplace '{marketplace_name}' is already added from a different source; remove it before adding {}",
source.display()
)));
}
record_added_marketplace_entry(codex_home, &marketplace_name, &install_metadata)?;
return Ok(MarketplaceAddOutcome {
marketplace_name,
source_display: source.display(),
installed_root: AbsolutePathBuf::try_from(path.clone()).map_err(|err| {
MarketplaceAddError::Internal(format!(
"failed to resolve installed marketplace root: {err}"
))
})?,
already_added: false,
});
}

let staging_root = marketplace_staging_root(&install_root);
fs::create_dir_all(&staging_root).map_err(|err| {
MarketplaceAddError::Internal(format!(
Expand All @@ -120,8 +149,7 @@ where
})?;
let staged_root = staged_root.keep();

let MarketplaceSource::Git { url, ref_name } = &source;
clone_source(url, ref_name.as_deref(), &sparse_paths, &staged_root)?;
stage_marketplace_source(&source, &sparse_paths, &staged_root, clone_source)?;

let marketplace_name = validate_marketplace_source_root(&staged_root)?;
if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
Expand Down Expand Up @@ -214,6 +242,75 @@ mod tests {
Ok(())
}

#[test]
fn add_marketplace_sync_installs_local_directory_source_and_updates_config() -> Result<()> {
let codex_home = TempDir::new()?;
let source_root = TempDir::new()?;
write_marketplace_source(source_root.path(), "local copy")?;

let result = add_marketplace_sync_with_cloner(
codex_home.path(),
MarketplaceAddRequest {
source: source_root.path().display().to_string(),
ref_name: None,
sparse_paths: Vec::new(),
},
|_url, _ref_name, _sparse_paths, _destination| {
panic!("git cloner should not be called for local marketplace sources")
},
)?;

let expected_source = source_root.path().canonicalize()?.display().to_string();
assert_eq!(result.marketplace_name, "debug");
assert_eq!(result.source_display, expected_source);
assert_eq!(
result.installed_root.as_path(),
source_root.path().canonicalize()?
);
assert!(!result.already_added);
assert!(
!marketplace_install_root(codex_home.path())
.join("debug")
.exists()
);

let config = fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE))?;
assert!(config.contains("[marketplaces.debug]"));
assert!(config.contains("source_type = \"local\""));
assert!(config.contains(&format!("source = \"{expected_source}\"")));
Ok(())
}

#[test]
fn add_marketplace_sync_treats_existing_local_directory_source_as_already_added() -> Result<()>
{
let codex_home = TempDir::new()?;
let source_root = TempDir::new()?;
write_marketplace_source(source_root.path(), "local copy")?;

let request = MarketplaceAddRequest {
source: source_root.path().display().to_string(),
ref_name: None,
sparse_paths: Vec::new(),
};
let first_result = add_marketplace_sync_with_cloner(codex_home.path(), request.clone(), {
|_url, _ref_name, _sparse_paths, _destination| {
panic!("git cloner should not be called for local marketplace sources")
}
})?;
let second_result = add_marketplace_sync_with_cloner(codex_home.path(), request, {
|_url, _ref_name, _sparse_paths, _destination| {
panic!("git cloner should not be called for local marketplace sources")
}
})?;

assert!(!first_result.already_added);
assert!(second_result.already_added);
assert_eq!(second_result.installed_root, first_result.installed_root);

Ok(())
}

fn write_marketplace_source(source: &Path, marker: &str) -> std::io::Result<()> {
fs::create_dir_all(source.join(".agents/plugins"))?;
fs::create_dir_all(source.join("plugins/sample/.codex-plugin"))?;
Expand Down
Loading
Loading