Download codex-rs/cli/tests/plugin_cli.rs from SaylorTwift/codex: direct link, hf CLI and curl.
- Browser
- Download file 62.4 kB
-
https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/cli/tests/plugin_cli.rs
- Command line
-
hf download hf://SaylorTwift/codex/codex-rs/cli/tests/plugin_cli.rs
-
curl -L -o plugin_cli.rs https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/cli/tests/plugin_cli.rs
62.4 kB
| use anyhow::Result; | |
| use anyhow::ensure; | |
| use app_test_support::ChatGptAuthFixture; | |
| use app_test_support::write_chatgpt_auth; | |
| use codex_config::CONFIG_TOML_FILE; | |
| use codex_config::MarketplaceConfigUpdate; | |
| use codex_config::record_user_marketplace; | |
| use codex_config::types::AuthCredentialsStoreMode; | |
| use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks; | |
| use flate2::Compression; | |
| use flate2::write::GzEncoder; | |
| use predicates::prelude::PredicateBooleanExt; | |
| use predicates::str::contains; | |
| use pretty_assertions::assert_eq; | |
| use serde_json::Value; | |
| use serde_json::json; | |
| use std::path::Path; | |
| use std::process::Output; | |
| use std::sync::Arc; | |
| use std::sync::atomic::AtomicBool; | |
| use std::sync::atomic::Ordering; | |
| use tempfile::TempDir; | |
| use tokio::process::Command; | |
| use wiremock::Mock; | |
| use wiremock::MockServer; | |
| use wiremock::ResponseTemplate; | |
| use wiremock::matchers::header; | |
| use wiremock::matchers::method; | |
| use wiremock::matchers::path; | |
| use wiremock::matchers::query_param; | |
| const MARKETPLACE_HEADER: &str = "MARKETPLACE"; | |
| const MARKETPLACE_LIST_HEADER: &str = "MARKETPLACE ROOT"; | |
| fn marketplace_list_row(marketplace_name: &str, root: &Path) -> String { | |
| format!( | |
| "{marketplace_name:<width$} {}", | |
| root.display(), | |
| width = MARKETPLACE_HEADER.len() | |
| ) | |
| } | |
| 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); | |
| cmd.env("HOME", codex_home); | |
| Ok(cmd) | |
| } | |
| fn codex_command_in(codex_home: &Path, current_dir: &Path) -> Result<assert_cmd::Command> { | |
| let mut cmd = codex_command(codex_home)?; | |
| cmd.current_dir(current_dir); | |
| Ok(cmd) | |
| } | |
| fn configured_local_marketplace(source: &str) -> MarketplaceConfigUpdate<'_> { | |
| MarketplaceConfigUpdate { | |
| source_type: "local", | |
| source, | |
| ref_name: None, | |
| sparse_paths: &[], | |
| } | |
| } | |
| fn write_plugins_enabled_config(codex_home: &Path) -> Result<()> { | |
| std::fs::write( | |
| codex_home.join(CONFIG_TOML_FILE), | |
| r#"[features] | |
| plugins = true | |
| "#, | |
| )?; | |
| Ok(()) | |
| } | |
| fn write_marketplace_source_with_manifest(source: &Path, marketplace_manifest: &str) -> Result<()> { | |
| std::fs::create_dir_all(source.join(".agents").join("plugins"))?; | |
| std::fs::create_dir_all(source.join("plugins").join("sample").join(".codex-plugin"))?; | |
| std::fs::write( | |
| source | |
| .join(".agents") | |
| .join("plugins") | |
| .join("marketplace.json"), | |
| marketplace_manifest, | |
| )?; | |
| std::fs::write( | |
| source | |
| .join("plugins") | |
| .join("sample") | |
| .join(".codex-plugin") | |
| .join("plugin.json"), | |
| r#"{"name":"sample","version":"1.2.3","description":"Sample plugin"}"#, | |
| )?; | |
| Ok(()) | |
| } | |
| fn write_marketplace_source(source: &Path) -> Result<()> { | |
| write_marketplace_source_with_manifest( | |
| source, | |
| r#"{ | |
| "name": "debug", | |
| "plugins": [ | |
| { | |
| "name": "sample", | |
| "source": { | |
| "source": "local", | |
| "path": "./plugins/sample" | |
| } | |
| } | |
| ] | |
| }"#, | |
| ) | |
| } | |
| fn write_marketplace_source_with_explicit_empty_products(source: &Path) -> Result<()> { | |
| write_marketplace_source_with_manifest( | |
| source, | |
| r#"{ | |
| "name": "debug", | |
| "plugins": [ | |
| { | |
| "name": "sample", | |
| "source": { | |
| "source": "local", | |
| "path": "./plugins/sample" | |
| }, | |
| "policy": { | |
| "products": [] | |
| } | |
| } | |
| ] | |
| }"#, | |
| ) | |
| } | |
| fn setup_local_marketplace() -> Result<(TempDir, TempDir)> { | |
| let codex_home = TempDir::new()?; | |
| let source = TempDir::new()?; | |
| write_plugins_enabled_config(codex_home.path())?; | |
| write_marketplace_source(source.path())?; | |
| let source_path = source.path().to_string_lossy().into_owned(); | |
| record_user_marketplace( | |
| codex_home.path(), | |
| "debug", | |
| &configured_local_marketplace(&source_path), | |
| )?; | |
| Ok((codex_home, source)) | |
| } | |
| fn setup_unconfigured_local_marketplace() -> Result<(TempDir, TempDir)> { | |
| let codex_home = TempDir::new()?; | |
| let source = TempDir::new()?; | |
| write_plugins_enabled_config(codex_home.path())?; | |
| write_marketplace_source(source.path())?; | |
| Ok((codex_home, source)) | |
| } | |
| fn setup_local_marketplace_with_explicit_empty_products() -> Result<(TempDir, TempDir)> { | |
| let codex_home = TempDir::new()?; | |
| let source = TempDir::new()?; | |
| write_plugins_enabled_config(codex_home.path())?; | |
| write_marketplace_source_with_explicit_empty_products(source.path())?; | |
| let source_path = source.path().to_string_lossy().into_owned(); | |
| record_user_marketplace( | |
| codex_home.path(), | |
| "debug", | |
| &configured_local_marketplace(&source_path), | |
| )?; | |
| Ok((codex_home, source)) | |
| } | |
| fn setup_configured_marketplace_without_manifest() -> Result<(TempDir, TempDir)> { | |
| let codex_home = TempDir::new()?; | |
| let source = TempDir::new()?; | |
| write_plugins_enabled_config(codex_home.path())?; | |
| let source_path = source.path().to_string_lossy().into_owned(); | |
| record_user_marketplace( | |
| codex_home.path(), | |
| "debug", | |
| &configured_local_marketplace(&source_path), | |
| )?; | |
| Ok((codex_home, source)) | |
| } | |
| fn setup_configured_marketplace_with_malformed_manifest() -> Result<(TempDir, TempDir)> { | |
| let codex_home = TempDir::new()?; | |
| let source = TempDir::new()?; | |
| write_plugins_enabled_config(codex_home.path())?; | |
| std::fs::create_dir_all(source.path().join(".agents").join("plugins"))?; | |
| std::fs::write( | |
| source | |
| .path() | |
| .join(".agents") | |
| .join("plugins") | |
| .join("marketplace.json"), | |
| "{not valid json", | |
| )?; | |
| let source_path = source.path().to_string_lossy().into_owned(); | |
| record_user_marketplace( | |
| codex_home.path(), | |
| "debug", | |
| &configured_local_marketplace(&source_path), | |
| )?; | |
| Ok((codex_home, source)) | |
| } | |
| fn setup_local_marketplace_with_implicit_system_roots() -> Result<(TempDir, TempDir, TempDir)> { | |
| let (codex_home, source) = setup_local_marketplace()?; | |
| let bundled_root = codex_home | |
| .path() | |
| .join(".tmp") | |
| .join("bundled-marketplaces") | |
| .join("openai-bundled"); | |
| std::fs::create_dir_all(&bundled_root)?; | |
| let bundled_source = bundled_root.display().to_string(); | |
| record_user_marketplace( | |
| codex_home.path(), | |
| "openai-bundled", | |
| &configured_local_marketplace(&bundled_source), | |
| )?; | |
| let cache_home = TempDir::new()?; | |
| let runtime_root = cache_home | |
| .path() | |
| .join(".cache") | |
| .join("codex-runtimes") | |
| .join("codex-primary-runtime") | |
| .join("plugins") | |
| .join("openai-primary-runtime"); | |
| std::fs::create_dir_all(&runtime_root)?; | |
| let runtime_source = runtime_root.display().to_string(); | |
| record_user_marketplace( | |
| codex_home.path(), | |
| "openai-primary-runtime", | |
| &configured_local_marketplace(&runtime_source), | |
| )?; | |
| Ok((codex_home, source, cache_home)) | |
| } | |
| fn setup_custom_marketplace_under_implicit_system_root() -> Result<(TempDir, std::path::PathBuf)> { | |
| let codex_home = TempDir::new()?; | |
| write_plugins_enabled_config(codex_home.path())?; | |
| let custom_root = codex_home | |
| .path() | |
| .join(".tmp") | |
| .join("bundled-marketplaces") | |
| .join("custom-marketplace"); | |
| std::fs::create_dir_all(&custom_root)?; | |
| let custom_source = custom_root.display().to_string(); | |
| record_user_marketplace( | |
| codex_home.path(), | |
| "custom-marketplace", | |
| &configured_local_marketplace(&custom_source), | |
| )?; | |
| Ok((codex_home, custom_root)) | |
| } | |
| fn remove_installed_plugin_config(codex_home: &Path, plugin_key: &str) -> Result<()> { | |
| let config_path = codex_home.join(CONFIG_TOML_FILE); | |
| let plugin_header = format!("[plugins.\"{plugin_key}\"]"); | |
| let config = std::fs::read_to_string(&config_path)?; | |
| let mut rewritten = Vec::new(); | |
| let mut skipping = false; | |
| for line in config.lines() { | |
| if line == plugin_header { | |
| skipping = true; | |
| continue; | |
| } | |
| if skipping && line.starts_with('[') { | |
| skipping = false; | |
| } | |
| if !skipping { | |
| rewritten.push(line); | |
| } | |
| } | |
| std::fs::write(config_path, format!("{}\n", rewritten.join("\n")))?; | |
| Ok(()) | |
| } | |
| fn setup_configured_local_marketplace_with_missing_source() -> Result<TempDir> { | |
| let codex_home = TempDir::new()?; | |
| std::fs::write( | |
| codex_home.path().join(CONFIG_TOML_FILE), | |
| r#"[features] | |
| plugins = true | |
| [marketplaces.debug] | |
| source_type = "local" | |
| "#, | |
| )?; | |
| Ok(codex_home) | |
| } | |
| fn setup_configured_local_marketplace_with_invalid_name() -> Result<TempDir> { | |
| let codex_home = TempDir::new()?; | |
| std::fs::write( | |
| codex_home.path().join(CONFIG_TOML_FILE), | |
| r#"[features] | |
| plugins = true | |
| [marketplaces."bad/name"] | |
| source_type = "local" | |
| source = "/tmp/debug" | |
| "#, | |
| )?; | |
| Ok(codex_home) | |
| } | |
| fn assert_configured_marketplace_snapshot_failure( | |
| assert: assert_cmd::assert::Assert, | |
| source: &Path, | |
| detail: &str, | |
| ) { | |
| assert | |
| .failure() | |
| .stderr(contains( | |
| "failed to load configured marketplace snapshot(s):", | |
| )) | |
| .stderr(contains("`debug`")) | |
| .stderr(contains(source.display().to_string())) | |
| .stderr(contains(detail)); | |
| } | |
| fn assert_marketplace_failure( | |
| assert: assert_cmd::assert::Assert, | |
| marketplace_name: &str, | |
| source: &Path, | |
| detail: &str, | |
| ) { | |
| assert | |
| .failure() | |
| .stderr(contains("failed to load marketplace(s):")) | |
| .stderr(contains(format!("`{marketplace_name}`"))) | |
| .stderr(contains(source.display().to_string())) | |
| .stderr(contains(detail)); | |
| } | |
| async fn marketplace_list_shows_configured_marketplace_names() -> Result<()> { | |
| let (codex_home, source) = setup_local_marketplace()?; | |
| let expected_row = marketplace_list_row("debug", source.path()); | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "marketplace", "list"]) | |
| .assert() | |
| .success() | |
| .stdout(contains(MARKETPLACE_LIST_HEADER)) | |
| .stdout(contains(&expected_row)) | |
| .stdout(contains("\t").not()); | |
| Ok(()) | |
| } | |
| async fn marketplace_list_json_prints_configured_marketplaces() -> Result<()> { | |
| let (codex_home, source) = setup_local_marketplace()?; | |
| let source_path = source.path().display().to_string(); | |
| let assert = codex_command(codex_home.path())? | |
| .args(["plugin", "marketplace", "list", "--json"]) | |
| .assert() | |
| .success(); | |
| let stdout = assert.get_output().stdout.as_slice(); | |
| let actual: serde_json::Value = serde_json::from_slice(stdout)?; | |
| assert_eq!( | |
| actual, | |
| json!({ | |
| "marketplaces": [ | |
| { | |
| "name": "debug", | |
| "root": source_path, | |
| "marketplaceSource": { | |
| "sourceType": "local", | |
| "source": source_path, | |
| }, | |
| }, | |
| ], | |
| }) | |
| ); | |
| Ok(()) | |
| } | |
| async fn marketplace_list_json_includes_configured_git_marketplace_source() -> Result<()> { | |
| let codex_home = TempDir::new()?; | |
| let marketplace_root = codex_home | |
| .path() | |
| .join(".tmp") | |
| .join("marketplaces") | |
| .join("debug"); | |
| write_plugins_enabled_config(codex_home.path())?; | |
| write_marketplace_source(&marketplace_root)?; | |
| let update = MarketplaceConfigUpdate { | |
| source_type: "git", | |
| source: "https://example.com/acme/agent-skills.git", | |
| ref_name: None, | |
| sparse_paths: &[], | |
| }; | |
| record_user_marketplace(codex_home.path(), "debug", &update)?; | |
| let normalized_root = canonicalize_existing_preserving_symlinks(&marketplace_root)?; | |
| let assert = codex_command(codex_home.path())? | |
| .args(["plugin", "marketplace", "list", "--json"]) | |
| .assert() | |
| .success(); | |
| let stdout = assert.get_output().stdout.as_slice(); | |
| let actual: serde_json::Value = serde_json::from_slice(stdout)?; | |
| assert_eq!( | |
| actual, | |
| json!({ | |
| "marketplaces": [ | |
| { | |
| "name": "debug", | |
| "root": normalized_root.display().to_string(), | |
| "marketplaceSource": { | |
| "sourceType": "git", | |
| "source": "https://example.com/acme/agent-skills.git", | |
| }, | |
| }, | |
| ], | |
| }) | |
| ); | |
| Ok(()) | |
| } | |
| async fn marketplace_list_json_keys_configured_source_by_root() -> Result<()> { | |
| let codex_home = TempDir::new()?; | |
| let home = TempDir::new()?; | |
| let marketplace_root = codex_home | |
| .path() | |
| .join(".tmp") | |
| .join("marketplaces") | |
| .join("debug"); | |
| write_plugins_enabled_config(codex_home.path())?; | |
| write_marketplace_source(home.path())?; | |
| write_marketplace_source(&marketplace_root)?; | |
| let update = MarketplaceConfigUpdate { | |
| source_type: "git", | |
| source: "https://example.com/acme/agent-skills.git", | |
| ref_name: None, | |
| sparse_paths: &[], | |
| }; | |
| record_user_marketplace(codex_home.path(), "debug", &update)?; | |
| let normalized_root = canonicalize_existing_preserving_symlinks(&marketplace_root)?; | |
| let assert = codex_command(codex_home.path())? | |
| .env("HOME", home.path()) | |
| .args(["plugin", "marketplace", "list", "--json"]) | |
| .assert() | |
| .success(); | |
| let stdout = assert.get_output().stdout.as_slice(); | |
| let actual: serde_json::Value = serde_json::from_slice(stdout)?; | |
| assert_eq!( | |
| actual, | |
| json!({ | |
| "marketplaces": [ | |
| { | |
| "name": "debug", | |
| "root": home.path().display().to_string(), | |
| }, | |
| { | |
| "name": "debug", | |
| "root": normalized_root.display().to_string(), | |
| "marketplaceSource": { | |
| "sourceType": "git", | |
| "source": "https://example.com/acme/agent-skills.git", | |
| }, | |
| }, | |
| ], | |
| }) | |
| ); | |
| Ok(()) | |
| } | |
| async fn marketplace_list_includes_home_marketplace_when_present() -> Result<()> { | |
| let codex_home = TempDir::new()?; | |
| let home = TempDir::new()?; | |
| write_marketplace_source(home.path())?; | |
| write_plugins_enabled_config(codex_home.path())?; | |
| let expected_row = marketplace_list_row("debug", home.path()); | |
| codex_command(codex_home.path())? | |
| .env("HOME", home.path()) | |
| .args(["plugin", "marketplace", "list"]) | |
| .assert() | |
| .success() | |
| .stdout(contains(MARKETPLACE_LIST_HEADER)) | |
| .stdout(contains(&expected_row)) | |
| .stdout(contains("\t").not()); | |
| Ok(()) | |
| } | |
| async fn marketplace_list_includes_root_when_plugins_are_filtered_out() -> Result<()> { | |
| let (codex_home, source) = setup_local_marketplace_with_explicit_empty_products()?; | |
| let expected_row = marketplace_list_row("debug", source.path()); | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "marketplace", "list"]) | |
| .assert() | |
| .success() | |
| .stdout(contains(MARKETPLACE_LIST_HEADER)) | |
| .stdout(contains(&expected_row)); | |
| Ok(()) | |
| } | |
| async fn marketplace_list_fails_when_configured_marketplace_snapshot_is_missing() -> Result<()> { | |
| let (codex_home, source) = setup_configured_marketplace_without_manifest()?; | |
| assert_marketplace_failure( | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "marketplace", "list"]) | |
| .assert(), | |
| "debug", | |
| source.path(), | |
| "marketplace root does not contain a supported manifest", | |
| ); | |
| Ok(()) | |
| } | |
| async fn marketplace_list_fails_when_configured_marketplace_name_is_invalid() -> Result<()> { | |
| let codex_home = setup_configured_local_marketplace_with_invalid_name()?; | |
| assert_marketplace_failure( | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "marketplace", "list"]) | |
| .assert(), | |
| "bad/name", | |
| Path::new("<invalid config>"), | |
| "marketplace name", | |
| ); | |
| Ok(()) | |
| } | |
| async fn marketplace_list_fails_when_configured_local_marketplace_source_is_missing() -> Result<()> | |
| { | |
| let codex_home = setup_configured_local_marketplace_with_missing_source()?; | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "marketplace", "list"]) | |
| .assert() | |
| .failure() | |
| .stderr(contains("failed to load marketplace(s):")) | |
| .stderr(contains("`debug`")) | |
| .stderr(contains("<invalid source>")) | |
| .stderr(contains( | |
| "configured local marketplace source is missing or empty", | |
| )); | |
| Ok(()) | |
| } | |
| async fn marketplace_list_fails_when_home_marketplace_is_malformed() -> Result<()> { | |
| let codex_home = TempDir::new()?; | |
| let home = TempDir::new()?; | |
| write_plugins_enabled_config(codex_home.path())?; | |
| std::fs::create_dir_all(home.path().join(".agents/plugins"))?; | |
| let home_marketplace_path = home | |
| .path() | |
| .join(".agents") | |
| .join("plugins") | |
| .join("marketplace.json"); | |
| std::fs::write(&home_marketplace_path, "{not valid json")?; | |
| codex_command(codex_home.path())? | |
| .env("HOME", home.path()) | |
| .args(["plugin", "marketplace", "list"]) | |
| .assert() | |
| .failure() | |
| .stderr(contains("failed to load marketplace(s):")) | |
| .stderr(contains(home_marketplace_path.display().to_string())) | |
| .stderr(contains("key must be a string")); | |
| Ok(()) | |
| } | |
| async fn marketplace_list_fails_when_configured_marketplace_snapshot_is_malformed() -> Result<()> { | |
| let (codex_home, source) = setup_configured_marketplace_with_malformed_manifest()?; | |
| assert_marketplace_failure( | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "marketplace", "list"]) | |
| .assert(), | |
| "debug", | |
| source.path(), | |
| "key must be a string", | |
| ); | |
| Ok(()) | |
| } | |
| async fn plugin_list_prints_plugins_in_a_table() -> Result<()> { | |
| let (codex_home, source) = setup_local_marketplace()?; | |
| let marketplace_manifest = source | |
| .path() | |
| .join(".agents") | |
| .join("plugins") | |
| .join("marketplace.json"); | |
| let plugin_path = source.path().join("plugins").join("sample"); | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "list"]) | |
| .assert() | |
| .success() | |
| .stdout(contains("Marketplace `debug`")) | |
| .stdout(contains("PLUGIN")) | |
| .stdout(contains("STATUS")) | |
| .stdout(contains("VERSION")) | |
| .stdout(contains("SOURCE")) | |
| .stdout(contains(marketplace_manifest.display().to_string())) | |
| .stdout(contains("sample@debug")) | |
| .stdout(contains("not installed")) | |
| .stdout(contains(plugin_path.display().to_string())); | |
| Ok(()) | |
| } | |
| async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<()> { | |
| let (codex_home, source) = setup_local_marketplace()?; | |
| let plugin_path = source.path().join("plugins").join("sample"); | |
| let source_path = source.path().to_string_lossy().into_owned(); | |
| let assert = codex_command(codex_home.path())? | |
| .args(["plugin", "list", "--available", "--json"]) | |
| .assert() | |
| .success(); | |
| let stdout = assert.get_output().stdout.as_slice(); | |
| let actual: serde_json::Value = serde_json::from_slice(stdout)?; | |
| assert_eq!( | |
| actual, | |
| json!({ | |
| "installed": [], | |
| "available": [ | |
| { | |
| "pluginId": "sample@debug", | |
| "name": "sample", | |
| "marketplaceName": "debug", | |
| "version": "1.2.3", | |
| "installed": false, | |
| "enabled": false, | |
| "source": { | |
| "source": "local", | |
| "path": plugin_path.display().to_string(), | |
| }, | |
| "marketplaceSource": { | |
| "sourceType": "local", | |
| "source": source_path, | |
| }, | |
| "installPolicy": "AVAILABLE", | |
| "authPolicy": "ON_INSTALL", | |
| }, | |
| ], | |
| }) | |
| ); | |
| Ok(()) | |
| } | |
| async fn plugin_list_json_includes_configured_git_marketplace_source() -> Result<()> { | |
| let codex_home = TempDir::new()?; | |
| let marketplace_root = codex_home | |
| .path() | |
| .join(".tmp") | |
| .join("marketplaces") | |
| .join("debug"); | |
| write_plugins_enabled_config(codex_home.path())?; | |
| write_marketplace_source(&marketplace_root)?; | |
| let update = MarketplaceConfigUpdate { | |
| source_type: "git", | |
| source: "https://example.com/acme/agent-skills.git", | |
| ref_name: None, | |
| sparse_paths: &[], | |
| }; | |
| record_user_marketplace(codex_home.path(), "debug", &update)?; | |
| let plugin_path = marketplace_root.join("plugins").join("sample"); | |
| let normalized_plugin_path = canonicalize_existing_preserving_symlinks(&plugin_path)?; | |
| let assert = codex_command(codex_home.path())? | |
| .args(["plugin", "list", "--available", "--json"]) | |
| .assert() | |
| .success(); | |
| let stdout = assert.get_output().stdout.as_slice(); | |
| let actual: serde_json::Value = serde_json::from_slice(stdout)?; | |
| assert_eq!( | |
| actual, | |
| json!({ | |
| "installed": [], | |
| "available": [ | |
| { | |
| "pluginId": "sample@debug", | |
| "name": "sample", | |
| "marketplaceName": "debug", | |
| "version": "1.2.3", | |
| "installed": false, | |
| "enabled": false, | |
| "source": { | |
| "source": "local", | |
| "path": normalized_plugin_path.display().to_string(), | |
| }, | |
| "marketplaceSource": { | |
| "sourceType": "git", | |
| "source": "https://example.com/acme/agent-skills.git", | |
| }, | |
| "installPolicy": "AVAILABLE", | |
| "authPolicy": "ON_INSTALL", | |
| }, | |
| ], | |
| }) | |
| ); | |
| Ok(()) | |
| } | |
| async fn plugin_list_json_prints_installed_plugins() -> Result<()> { | |
| let (codex_home, source) = setup_local_marketplace()?; | |
| let plugin_path = source.path().join("plugins").join("sample"); | |
| let source_path = source.path().to_string_lossy().into_owned(); | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "add", "sample@debug"]) | |
| .assert() | |
| .success(); | |
| let assert = codex_command(codex_home.path())? | |
| .args(["plugin", "list", "--json"]) | |
| .assert() | |
| .success(); | |
| let stdout = assert.get_output().stdout.as_slice(); | |
| let actual: serde_json::Value = serde_json::from_slice(stdout)?; | |
| assert_eq!( | |
| actual, | |
| json!({ | |
| "installed": [ | |
| { | |
| "pluginId": "sample@debug", | |
| "name": "sample", | |
| "marketplaceName": "debug", | |
| "version": "1.2.3", | |
| "installed": true, | |
| "enabled": true, | |
| "source": { | |
| "source": "local", | |
| "path": plugin_path.display().to_string(), | |
| }, | |
| "marketplaceSource": { | |
| "sourceType": "local", | |
| "source": source_path, | |
| }, | |
| "installPolicy": "AVAILABLE", | |
| "authPolicy": "ON_INSTALL", | |
| }, | |
| ], | |
| "available": [], | |
| }) | |
| ); | |
| Ok(()) | |
| } | |
| async fn plugin_list_available_requires_json() -> Result<()> { | |
| let (codex_home, _source) = setup_local_marketplace()?; | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "list", "--available"]) | |
| .assert() | |
| .failure() | |
| .stderr(contains( | |
| "the following required arguments were not provided", | |
| )) | |
| .stderr(contains("--json")); | |
| Ok(()) | |
| } | |
| async fn plugin_list_shows_installed_version_when_plugin_is_installed() -> Result<()> { | |
| let (codex_home, _source) = setup_local_marketplace()?; | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "add", "sample@debug"]) | |
| .assert() | |
| .success(); | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "list"]) | |
| .assert() | |
| .success() | |
| .stdout(contains("sample@debug")) | |
| .stdout(contains("1.2.3")) | |
| .stdout(contains("installed, enabled")); | |
| Ok(()) | |
| } | |
| async fn plugin_list_excludes_unconfigured_repo_local_marketplaces() -> Result<()> { | |
| let (codex_home, source) = setup_unconfigured_local_marketplace()?; | |
| codex_command_in(codex_home.path(), source.path())? | |
| .args(["plugin", "list", "--marketplace", "debug"]) | |
| .assert() | |
| .success() | |
| .stdout(contains("No plugins found in marketplace `debug`.")) | |
| .stdout(predicates::str::is_match("sample@debug").unwrap().not()); | |
| Ok(()) | |
| } | |
| async fn plugin_list_fails_when_configured_marketplace_snapshot_is_missing() -> Result<()> { | |
| let (codex_home, source) = setup_configured_marketplace_without_manifest()?; | |
| assert_configured_marketplace_snapshot_failure( | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "list"]) | |
| .assert(), | |
| source.path(), | |
| "marketplace root does not contain a supported manifest", | |
| ); | |
| Ok(()) | |
| } | |
| async fn plugin_list_ignores_implicit_system_marketplace_roots_without_manifests() -> Result<()> { | |
| let (codex_home, source, cache_home) = setup_local_marketplace_with_implicit_system_roots()?; | |
| codex_command(codex_home.path())? | |
| .env("HOME", cache_home.path()) | |
| .env("USERPROFILE", cache_home.path()) | |
| .args(["plugin", "list"]) | |
| .assert() | |
| .success() | |
| .stdout(contains("Marketplace `debug`")) | |
| .stdout(contains( | |
| source | |
| .path() | |
| .join(".agents") | |
| .join("plugins") | |
| .join("marketplace.json") | |
| .display() | |
| .to_string(), | |
| )) | |
| .stderr( | |
| predicates::str::contains("failed to load configured marketplace snapshot(s):").not(), | |
| ); | |
| Ok(()) | |
| } | |
| async fn plugin_list_fails_for_custom_marketplace_under_system_root() -> Result<()> { | |
| let (codex_home, custom_root) = setup_custom_marketplace_under_implicit_system_root()?; | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "list"]) | |
| .assert() | |
| .failure() | |
| .stderr(contains( | |
| "failed to load configured marketplace snapshot(s):", | |
| )) | |
| .stderr(contains("`custom-marketplace`")) | |
| .stderr(contains(custom_root.display().to_string())) | |
| .stderr(contains( | |
| "marketplace root does not contain a supported manifest", | |
| )); | |
| Ok(()) | |
| } | |
| async fn plugin_list_hides_version_for_cached_but_unconfigured_plugin() -> Result<()> { | |
| let (codex_home, _source) = setup_local_marketplace()?; | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "add", "sample@debug"]) | |
| .assert() | |
| .success(); | |
| remove_installed_plugin_config(codex_home.path(), "sample@debug")?; | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "list"]) | |
| .assert() | |
| .success() | |
| .stdout(contains("sample@debug")) | |
| .stdout(contains("not installed")) | |
| .stdout(predicates::str::contains("1.2.3").not()); | |
| Ok(()) | |
| } | |
| async fn plugin_add_and_remove_updates_installed_plugin_config() -> Result<()> { | |
| let (codex_home, _source) = setup_local_marketplace()?; | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "add", "sample@debug"]) | |
| .assert() | |
| .success() | |
| .stdout(contains("Added plugin `sample` from marketplace `debug`.")); | |
| let config = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?; | |
| assert!(config.contains("[plugins.\"sample@debug\"]")); | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "remove", "sample", "--marketplace", "debug"]) | |
| .assert() | |
| .success() | |
| .stdout(contains( | |
| "Removed plugin `sample` from marketplace `debug`.", | |
| )); | |
| let config = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?; | |
| assert!(!config.contains("[plugins.\"sample@debug\"]")); | |
| Ok(()) | |
| } | |
| async fn plugin_add_json_prints_install_outcome() -> Result<()> { | |
| let (codex_home, _source) = setup_local_marketplace()?; | |
| let assert = codex_command(codex_home.path())? | |
| .args(["plugin", "add", "sample@debug", "--json"]) | |
| .assert() | |
| .success(); | |
| let stdout = assert.get_output().stdout.as_slice(); | |
| let actual: serde_json::Value = serde_json::from_slice(stdout)?; | |
| let installed_path = codex_home.path().join("plugins/cache/debug/sample/1.2.3"); | |
| let normalized_installed_path = canonicalize_existing_preserving_symlinks(&installed_path)?; | |
| assert_eq!( | |
| actual, | |
| json!({ | |
| "pluginId": "sample@debug", | |
| "name": "sample", | |
| "marketplaceName": "debug", | |
| "version": "1.2.3", | |
| "installedPath": normalized_installed_path.display().to_string(), | |
| "authPolicy": "ON_INSTALL", | |
| }) | |
| ); | |
| Ok(()) | |
| } | |
| async fn plugin_remove_json_prints_remove_outcome() -> Result<()> { | |
| let (codex_home, _source) = setup_local_marketplace()?; | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "add", "sample@debug"]) | |
| .assert() | |
| .success(); | |
| let assert = codex_command(codex_home.path())? | |
| .args([ | |
| "plugin", | |
| "remove", | |
| "sample", | |
| "--marketplace", | |
| "debug", | |
| "--json", | |
| ]) | |
| .assert() | |
| .success(); | |
| let stdout = assert.get_output().stdout.as_slice(); | |
| let actual: serde_json::Value = serde_json::from_slice(stdout)?; | |
| assert_eq!( | |
| actual, | |
| json!({ | |
| "pluginId": "sample@debug", | |
| "name": "sample", | |
| "marketplaceName": "debug", | |
| }) | |
| ); | |
| Ok(()) | |
| } | |
| async fn plugin_add_rejects_unconfigured_repo_local_marketplaces() -> Result<()> { | |
| let (codex_home, source) = setup_unconfigured_local_marketplace()?; | |
| codex_command_in(codex_home.path(), source.path())? | |
| .args(["plugin", "add", "sample@debug"]) | |
| .assert() | |
| .failure() | |
| .stderr(contains( | |
| "plugin `sample` was not found in marketplace `debug`", | |
| )); | |
| Ok(()) | |
| } | |
| async fn plugin_add_fails_when_configured_marketplace_snapshot_is_malformed() -> Result<()> { | |
| let (codex_home, source) = setup_configured_marketplace_with_malformed_manifest()?; | |
| assert_configured_marketplace_snapshot_failure( | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "add", "sample@debug"]) | |
| .assert(), | |
| source.path(), | |
| "key must be a string", | |
| ); | |
| Ok(()) | |
| } | |
| async fn plugin_add_reinstalls_from_configured_marketplace_snapshot() -> Result<()> { | |
| let (codex_home, _source) = setup_local_marketplace()?; | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "add", "sample@debug"]) | |
| .assert() | |
| .success(); | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "add", "sample@debug"]) | |
| .assert() | |
| .success() | |
| .stdout(contains("Added plugin `sample` from marketplace `debug`.")); | |
| assert!( | |
| codex_home | |
| .path() | |
| .join("plugins/cache/debug/sample/1.2.3/.codex-plugin/plugin.json") | |
| .is_file() | |
| ); | |
| Ok(()) | |
| } | |
| async fn plugin_remove_works_after_marketplace_is_removed() -> Result<()> { | |
| let (codex_home, _source) = setup_local_marketplace()?; | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "add", "sample", "--marketplace", "debug"]) | |
| .assert() | |
| .success(); | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "marketplace", "remove", "debug"]) | |
| .assert() | |
| .success(); | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "remove", "sample@debug"]) | |
| .assert() | |
| .success() | |
| .stdout(contains( | |
| "Removed plugin `sample` from marketplace `debug`.", | |
| )); | |
| let config = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?; | |
| assert!(!config.contains("[plugins.\"sample@debug\"]")); | |
| Ok(()) | |
| } | |
| async fn plugin_add_rejects_cached_plugins_without_authorizing_marketplace_snapshot() -> Result<()> | |
| { | |
| let (codex_home, _source) = setup_local_marketplace()?; | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "add", "sample@debug"]) | |
| .assert() | |
| .success(); | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "marketplace", "remove", "debug"]) | |
| .assert() | |
| .success(); | |
| assert!( | |
| codex_home | |
| .path() | |
| .join("plugins/cache/debug/sample/1.2.3/.codex-plugin/plugin.json") | |
| .is_file() | |
| ); | |
| codex_command(codex_home.path())? | |
| .args(["plugin", "add", "sample@debug"]) | |
| .assert() | |
| .failure() | |
| .stderr(contains( | |
| "plugin `sample` was not found in marketplace `debug`", | |
| )); | |
| Ok(()) | |
| } | |
| const REMOTE_ID: &str = "b1234567-89ab-4cde-8f01-234567890abc"; | |
| const MARKETPLACE: &str = "openai-curated-remote"; | |
| const PLUGIN_KEY: &str = "sample@openai-curated-remote"; | |
| fn sample_remote_plugin_bundle() -> Result<Vec<u8>> { | |
| let mut archive = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default())); | |
| for (path, contents) in [ | |
| ( | |
| ".codex-plugin/plugin.json", | |
| r#"{"name":"sample","version":"1.2.3"}"#, | |
| ), | |
| ( | |
| "skills/sample/SKILL.md", | |
| "---\nname: sample\ndescription: Sample remote skill\n---\nSample instructions.\n", | |
| ), | |
| ] { | |
| let mut header = tar::Header::new_gnu(); | |
| header.set_size(contents.len() as u64); | |
| header.set_mode(/*mode*/ 0o644); | |
| header.set_cksum(); | |
| archive.append_data(&mut header, path, contents.as_bytes())?; | |
| } | |
| Ok(archive.into_inner()?.finish()?) | |
| } | |
| struct RemoteMarketplaceFixture { | |
| home: TempDir, | |
| server: MockServer, | |
| plugin: Value, | |
| } | |
| impl RemoteMarketplaceFixture { | |
| async fn new() -> Result<Self> { | |
| let home = TempDir::new()?; | |
| let server = MockServer::start().await; | |
| std::fs::write( | |
| home.path().join("config.toml"), | |
| format!( | |
| "cli_auth_credentials_store = 'file'\nchatgpt_base_url = '{}/backend-api'\n[features]\nplugins = true\nremote_plugin = true\n", | |
| server.uri() | |
| ), | |
| )?; | |
| write_chatgpt_auth( | |
| home.path(), | |
| ChatGptAuthFixture::new("chatgpt-token") | |
| .account_id("account-123") | |
| .chatgpt_account_id("account-123"), | |
| AuthCredentialsStoreMode::File, | |
| )?; | |
| let plugin = json!({ | |
| "id": REMOTE_ID, "name": "sample", "scope": "GLOBAL", | |
| "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", | |
| "release": {"version": "1.2.3", "display_name": "Sample", "description": "Remote sample", | |
| "interface": {}, "bundle_download_url": format!("{}/bundle.tar.gz", server.uri())} | |
| }); | |
| Mock::given(method("GET")) | |
| .and(path("/backend-api/ps/plugins/list")) | |
| .and(query_param("scope", "GLOBAL")) | |
| .and(header("authorization", "Bearer chatgpt-token")) | |
| .and(header("chatgpt-account-id", "account-123")) | |
| .respond_with(ResponseTemplate::new(200).set_body_json( | |
| json!({"plugins": [plugin.clone()], "pagination": {"next_page_token": null}}), | |
| )) | |
| .mount(&server) | |
| .await; | |
| Ok(Self { | |
| home, | |
| server, | |
| plugin, | |
| }) | |
| } | |
| fn write_local_curated_marketplace(&self) -> Result<Value> { | |
| let source = canonicalize_existing_preserving_symlinks(self.home.path())? | |
| .join(".tmp") | |
| .join("plugins"); | |
| let mut manifest = json!({ | |
| "name": "openai-curated", | |
| "plugins": [{ | |
| "name": "sample", | |
| "source": {"source": "local", "path": "./plugins/sample"} | |
| }] | |
| }); | |
| write_marketplace_source_with_manifest(&source, &manifest.to_string())?; | |
| std::fs::write( | |
| self.home.path().join(".tmp").join("plugins.sha"), | |
| "local-curated", | |
| )?; | |
| manifest["name"] = json!("openai-api-curated"); | |
| std::fs::write( | |
| source | |
| .join(".agents") | |
| .join("plugins") | |
| .join("api_marketplace.json"), | |
| manifest.to_string(), | |
| )?; | |
| Ok(json!({ | |
| "pluginId": "sample@openai-curated", "name": "sample", | |
| "marketplaceName": "openai-curated", "version": "1.2.3", | |
| "installed": false, "enabled": false, | |
| "source": {"source": "local", "path": source.join("plugins").join("sample")}, | |
| "installPolicy": "AVAILABLE", "authPolicy": "ON_INSTALL" | |
| })) | |
| } | |
| async fn run(&self, args: &[&str]) -> Result<Output> { | |
| Ok(Command::new(codex_utils_cargo_bin::cargo_bin("codex")?) | |
| .current_dir(self.home.path()) | |
| .env("CODEX_HOME", self.home.path()) | |
| .env("HOME", self.home.path()) | |
| .env_remove("OPENAI_API_KEY") | |
| .env_remove("CODEX_API_KEY") | |
| .env_remove("CODEX_ACCESS_TOKEN") | |
| .env("CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS", "1") | |
| .args(args) | |
| .output() | |
| .await?) | |
| } | |
| async fn success(&self, args: &[&str]) -> Result<String> { | |
| let output = self.run(args).await?; | |
| ensure!( | |
| output.status.success(), | |
| "{}", | |
| String::from_utf8_lossy(&output.stderr) | |
| ); | |
| Ok(String::from_utf8(output.stdout)?) | |
| } | |
| } | |
| async fn remote_plugin_listing_replaces_local_curated_catalog() -> Result<()> { | |
| let fixture = RemoteMarketplaceFixture::new().await?; | |
| let mut local_plugin = fixture.write_local_curated_marketplace()?; | |
| fixture | |
| .success(&["plugin", "add", "sample@openai-curated"]) | |
| .await?; | |
| local_plugin["installed"] = json!(true); | |
| local_plugin["enabled"] = json!(true); | |
| local_plugin["version"] = json!("local-curated"); | |
| let mut installed_plugin = fixture.plugin.clone(); | |
| installed_plugin["enabled"] = json!(true); | |
| Mock::given(path("/backend-api/ps/plugins/installed")) | |
| .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| "plugins": [installed_plugin], "pagination": {"next_page_token": null} | |
| }))) | |
| .mount(&fixture.server) | |
| .await; | |
| let listed = fixture | |
| .success(&["plugin", "list", "--available", "--json"]) | |
| .await?; | |
| assert_eq!( | |
| serde_json::from_str::<Value>(&listed)?, | |
| json!({"installed": [{ | |
| "pluginId": PLUGIN_KEY, "name": "sample", "marketplaceName": MARKETPLACE, | |
| "version": "1.2.3", "installed": true, "enabled": true, | |
| "source": {"source": "remote", "id": REMOTE_ID}, | |
| "installPolicy": "AVAILABLE", "authPolicy": "ON_USE" | |
| }], "available": []}) | |
| ); | |
| let table = fixture.success(&["plugin", "list"]).await?; | |
| insta::assert_snapshot!(table, @r" | |
| Marketplace `openai-curated-remote` | |
| Remote catalog | |
| PLUGIN STATUS VERSION SOURCE | |
| sample@openai-curated-remote installed, enabled 1.2.3 b1234567-89ab-4cde-8f01-234567890abc | |
| "); | |
| fixture.server.reset().await; | |
| let listed = fixture | |
| .success(&["plugin", "list", "-m", "openai-curated", "--json"]) | |
| .await?; | |
| assert_eq!( | |
| serde_json::from_str::<Value>(&listed)?, | |
| json!({"installed": [local_plugin], "available": []}) | |
| ); | |
| assert!(fixture.server.received_requests().await.unwrap().is_empty()); | |
| Ok(()) | |
| } | |
| async fn remote_plugin_listing_preserves_local_curated_only_when_fetch_fails() -> Result<()> { | |
| for status in [200, 403] { | |
| let fixture = RemoteMarketplaceFixture::new().await?; | |
| fixture.server.reset().await; | |
| let local_plugin = fixture.write_local_curated_marketplace()?; | |
| let response = ResponseTemplate::new(status).set_body_json(json!({ | |
| "plugins": [], "pagination": {"next_page_token": null} | |
| })); | |
| let _catalog = Mock::given(path("/backend-api/ps/plugins/list")) | |
| .respond_with(response) | |
| .mount_as_scoped(&fixture.server) | |
| .await; | |
| Mock::given(path("/backend-api/ps/plugins/installed")) | |
| .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| "plugins": [], "pagination": {"next_page_token": null} | |
| }))) | |
| .mount(&fixture.server) | |
| .await; | |
| let listed = fixture | |
| .success(&["plugin", "list", "--available", "--json"]) | |
| .await?; | |
| let available = if status == 200 { | |
| Vec::new() | |
| } else { | |
| vec![local_plugin] | |
| }; | |
| assert_eq!( | |
| serde_json::from_str::<Value>(&listed)?, | |
| json!({"installed": [], "available": available}), | |
| "catalog response status: {status}" | |
| ); | |
| } | |
| Ok(()) | |
| } | |
| async fn remote_plugin_add_list_and_remove() -> Result<()> { | |
| let fixture = RemoteMarketplaceFixture::new().await?; | |
| let installed = Arc::new(AtomicBool::new(false)); | |
| let installed_for_list = Arc::clone(&installed); | |
| let mut installed_plugin = fixture.plugin.clone(); | |
| installed_plugin["enabled"] = json!(true); | |
| Mock::given(path("/backend-api/ps/plugins/installed")) | |
| .respond_with(move |_: &wiremock::Request| { | |
| let plugins = if installed_for_list.load(Ordering::SeqCst) { | |
| vec![installed_plugin.clone()] | |
| } else { | |
| Vec::new() | |
| }; | |
| ResponseTemplate::new(200) | |
| .set_body_json(json!({"plugins": plugins, "pagination": {"next_page_token": null}})) | |
| }) | |
| .mount(&fixture.server) | |
| .await; | |
| Mock::given(path(format!("/backend-api/ps/plugins/{REMOTE_ID}"))) | |
| .respond_with(ResponseTemplate::new(200).set_body_json(&fixture.plugin)) | |
| .mount(&fixture.server) | |
| .await; | |
| Mock::given(path("/bundle.tar.gz")) | |
| .respond_with(ResponseTemplate::new(200).set_body_bytes(sample_remote_plugin_bundle()?)) | |
| .expect(1) | |
| .mount(&fixture.server) | |
| .await; | |
| let installed_root = canonicalize_existing_preserving_symlinks(fixture.home.path())? | |
| .join("plugins") | |
| .join("cache") | |
| .join(MARKETPLACE) | |
| .join("sample") | |
| .join("1.2.3"); | |
| let manifest = installed_root.join(".codex-plugin/plugin.json"); | |
| let installed_for_add = Arc::clone(&installed); | |
| Mock::given(method("POST")) | |
| .and(path(format!("/backend-api/ps/plugins/{REMOTE_ID}/install"))) | |
| .and(query_param("includeAppsNeedingAuth", "true")) | |
| .and(header("authorization", "Bearer chatgpt-token")) | |
| .respond_with(move |_: &wiremock::Request| { | |
| assert!( | |
| manifest.is_file(), | |
| "cache must exist before backend install" | |
| ); | |
| installed_for_add.store(true, Ordering::SeqCst); | |
| ResponseTemplate::new(200).set_body_json(json!({"id": REMOTE_ID, "enabled": true})) | |
| }) | |
| .expect(1) | |
| .mount(&fixture.server) | |
| .await; | |
| Mock::given(method("POST")) | |
| .and(path(format!( | |
| "/backend-api/ps/plugins/{REMOTE_ID}/uninstall" | |
| ))) | |
| .and(header("authorization", "Bearer chatgpt-token")) | |
| .respond_with(move |_: &wiremock::Request| { | |
| installed.store(false, Ordering::SeqCst); | |
| ResponseTemplate::new(200).set_body_json(json!({"id": REMOTE_ID, "enabled": false})) | |
| }) | |
| .expect(1) | |
| .mount(&fixture.server) | |
| .await; | |
| let available = json!({ | |
| "pluginId": PLUGIN_KEY, "name": "sample", "marketplaceName": MARKETPLACE, | |
| "version": "1.2.3", "installed": false, "enabled": false, | |
| "source": {"source": "remote", "id": REMOTE_ID}, "installPolicy": "AVAILABLE", "authPolicy": "ON_USE" | |
| }); | |
| let listed = fixture | |
| .success(&["plugin", "list", "--available", "--json"]) | |
| .await?; | |
| assert_eq!( | |
| serde_json::from_str::<Value>(&listed)?, | |
| json!({"installed": [], "available": [available.clone()]}) | |
| ); | |
| let table = fixture | |
| .success(&["plugin", "list", "-m", MARKETPLACE]) | |
| .await?; | |
| insta::assert_snapshot!(table, @r" | |
| Marketplace `openai-curated-remote` | |
| Remote catalog | |
| PLUGIN STATUS VERSION SOURCE | |
| sample@openai-curated-remote not installed 1.2.3 b1234567-89ab-4cde-8f01-234567890abc | |
| "); | |
| let added = fixture | |
| .success(&["plugin", "add", PLUGIN_KEY, "--json"]) | |
| .await?; | |
| assert_eq!( | |
| serde_json::from_str::<Value>(&added)?, | |
| json!({ | |
| "pluginId": PLUGIN_KEY, "name": "sample", "marketplaceName": MARKETPLACE, | |
| "version": "1.2.3", "installedPath": installed_root, "authPolicy": "ON_USE" | |
| }) | |
| ); | |
| assert_eq!( | |
| fixture | |
| .server | |
| .received_requests() | |
| .await | |
| .unwrap() | |
| .iter() | |
| .filter(|request| request.url.path() == "/backend-api/ps/plugins/list") | |
| .count(), | |
| 1, | |
| "installing a cached name must not refetch the catalog" | |
| ); | |
| assert!(installed_root.join("skills/sample/SKILL.md").is_file()); | |
| let mut expected = available; | |
| expected["installed"] = json!(true); | |
| expected["enabled"] = json!(true); | |
| let listed = fixture | |
| .success(&["plugin", "list", "-m", MARKETPLACE, "--json"]) | |
| .await?; | |
| assert_eq!( | |
| serde_json::from_str::<Value>(&listed)?, | |
| json!({"installed": [expected], "available": []}) | |
| ); | |
| let _delisted = Mock::given(path("/backend-api/ps/plugins/list")) | |
| .respond_with( | |
| ResponseTemplate::new(200) | |
| .set_body_json(json!({"plugins": [], "pagination": {"next_page_token": null}})), | |
| ) | |
| .mount_as_scoped(&fixture.server) | |
| .await; | |
| let removed = fixture | |
| .success(&[ | |
| "-c", | |
| "features.remote_plugin=false", | |
| "plugin", | |
| "remove", | |
| "sample", | |
| "-m", | |
| MARKETPLACE, | |
| "--json", | |
| ]) | |
| .await?; | |
| assert_eq!( | |
| serde_json::from_str::<Value>(&removed)?, | |
| json!({"pluginId": PLUGIN_KEY, "name": "sample", "marketplaceName": MARKETPLACE}) | |
| ); | |
| assert!(!installed_root.exists()); | |
| let listed = fixture.success(&["plugin", "list", "--json"]).await?; | |
| assert_eq!( | |
| serde_json::from_str::<Value>(&listed)?, | |
| json!({"installed": [], "available": []}) | |
| ); | |
| Ok(()) | |
| } | |
| async fn remote_plugin_add_refreshes_cached_catalog_on_name_miss() -> Result<()> { | |
| for (remote_plugin_config, collection) in [ | |
| ("features.remote_plugin=true", None), | |
| ("features.remote_plugin=false", Some("vertical")), | |
| ] { | |
| let fixture = RemoteMarketplaceFixture::new().await?; | |
| fixture.server.reset().await; | |
| let published = Arc::new(AtomicBool::new(false)); | |
| let published_for_list = Arc::clone(&published); | |
| let plugin = fixture.plugin.clone(); | |
| Mock::given(method("GET")) | |
| .and(path("/backend-api/ps/plugins/list")) | |
| .and(query_param("scope", "GLOBAL")) | |
| .respond_with(move |_: &wiremock::Request| { | |
| let plugins = if published_for_list.load(Ordering::SeqCst) { | |
| vec![plugin.clone()] | |
| } else { | |
| Vec::new() | |
| }; | |
| ResponseTemplate::new(200).set_body_json(json!({ | |
| "plugins": plugins, "pagination": {"next_page_token": null} | |
| })) | |
| }) | |
| .expect(2) | |
| .mount(&fixture.server) | |
| .await; | |
| Mock::given(path("/backend-api/ps/plugins/installed")) | |
| .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| "plugins": [], "pagination": {"next_page_token": null} | |
| }))) | |
| .mount(&fixture.server) | |
| .await; | |
| Mock::given(path(format!("/backend-api/ps/plugins/{REMOTE_ID}"))) | |
| .respond_with(ResponseTemplate::new(200).set_body_json(&fixture.plugin)) | |
| .expect(1) | |
| .mount(&fixture.server) | |
| .await; | |
| Mock::given(path("/bundle.tar.gz")) | |
| .respond_with(ResponseTemplate::new(200).set_body_bytes(sample_remote_plugin_bundle()?)) | |
| .expect(1) | |
| .mount(&fixture.server) | |
| .await; | |
| Mock::given(method("POST")) | |
| .and(path(format!("/backend-api/ps/plugins/{REMOTE_ID}/install"))) | |
| .respond_with( | |
| ResponseTemplate::new(200).set_body_json(json!({"id": REMOTE_ID, "enabled": true})), | |
| ) | |
| .expect(1) | |
| .mount(&fixture.server) | |
| .await; | |
| for is_published in [false, true] { | |
| published.store(is_published, Ordering::SeqCst); | |
| let listed = fixture | |
| .success(&[ | |
| "-c", | |
| remote_plugin_config, | |
| "plugin", | |
| "list", | |
| "-m", | |
| MARKETPLACE, | |
| "--available", | |
| "--json", | |
| ]) | |
| .await?; | |
| assert_eq!( | |
| serde_json::from_str::<Value>(&listed)?, | |
| json!({"installed": [], "available": []}), | |
| "normal listing must keep using the fresh catalog cache" | |
| ); | |
| } | |
| let added = fixture | |
| .success(&[ | |
| "-c", | |
| remote_plugin_config, | |
| "plugin", | |
| "add", | |
| PLUGIN_KEY, | |
| "--json", | |
| ]) | |
| .await?; | |
| let installed_root = canonicalize_existing_preserving_symlinks(fixture.home.path())? | |
| .join("plugins") | |
| .join("cache") | |
| .join(MARKETPLACE) | |
| .join("sample") | |
| .join("1.2.3"); | |
| assert_eq!( | |
| serde_json::from_str::<Value>(&added)?, | |
| json!({ | |
| "pluginId": PLUGIN_KEY, "name": "sample", "marketplaceName": MARKETPLACE, | |
| "version": "1.2.3", "installedPath": installed_root, "authPolicy": "ON_USE" | |
| }) | |
| ); | |
| assert!( | |
| installed_root | |
| .join("skills") | |
| .join("sample") | |
| .join("SKILL.md") | |
| .is_file() | |
| ); | |
| let requests = fixture.server.received_requests().await.unwrap(); | |
| let catalog_collections = requests | |
| .iter() | |
| .filter(|request| request.url.path() == "/backend-api/ps/plugins/list") | |
| .map(|request| { | |
| request | |
| .url | |
| .query_pairs() | |
| .find_map(|(key, value)| (key == "collection").then(|| value.into_owned())) | |
| }) | |
| .collect::<Vec<_>>(); | |
| assert_eq!(catalog_collections, vec![collection.map(str::to_owned); 2]); | |
| } | |
| Ok(()) | |
| } | |
| async fn remote_plugin_add_limits_catalog_refresh_without_mutation() -> Result<()> { | |
| enum CatalogCache { | |
| Missing, | |
| Fresh, | |
| Expired, | |
| } | |
| for (remote_plugin_config, collection) in [ | |
| ("features.remote_plugin=true", None), | |
| ("features.remote_plugin=false", Some("vertical")), | |
| ] { | |
| for (cache, plugin_count, status, catalog_fetches, error) in [ | |
| ( | |
| CatalogCache::Missing, | |
| 0, | |
| 200, | |
| 1, | |
| "was not found in remote marketplace", | |
| ), | |
| ( | |
| CatalogCache::Fresh, | |
| 0, | |
| 200, | |
| 2, | |
| "was not found in remote marketplace", | |
| ), | |
| ( | |
| CatalogCache::Expired, | |
| 0, | |
| 200, | |
| 2, | |
| "was not found in remote marketplace", | |
| ), | |
| ( | |
| CatalogCache::Missing, | |
| 2, | |
| 200, | |
| 1, | |
| "matched multiple remote plugins", | |
| ), | |
| ( | |
| CatalogCache::Missing, | |
| 0, | |
| 403, | |
| 1, | |
| "failed to list remote marketplace plugins", | |
| ), | |
| ] { | |
| let fixture = RemoteMarketplaceFixture::new().await?; | |
| fixture.server.reset().await; | |
| let mut duplicate = fixture.plugin.clone(); | |
| duplicate["id"] = json!("c1234567-89ab-4cde-8f01-234567890abc"); | |
| let plugins = [fixture.plugin.clone(), duplicate] | |
| .into_iter() | |
| .take(plugin_count) | |
| .collect::<Vec<_>>(); | |
| Mock::given(path("/backend-api/ps/plugins/list")) | |
| .and(query_param("scope", "GLOBAL")) | |
| .respond_with(ResponseTemplate::new(status).set_body_json(json!({ | |
| "plugins": plugins, "pagination": {"next_page_token": null} | |
| }))) | |
| .expect(catalog_fetches) | |
| .mount(&fixture.server) | |
| .await; | |
| Mock::given(path("/backend-api/ps/plugins/installed")) | |
| .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| "plugins": [], "pagination": {"next_page_token": null} | |
| }))) | |
| .mount(&fixture.server) | |
| .await; | |
| if !matches!(cache, CatalogCache::Missing) { | |
| let listed = fixture | |
| .success(&[ | |
| "-c", | |
| remote_plugin_config, | |
| "plugin", | |
| "list", | |
| "-m", | |
| MARKETPLACE, | |
| "--available", | |
| "--json", | |
| ]) | |
| .await?; | |
| assert_eq!( | |
| serde_json::from_str::<Value>(&listed)?, | |
| json!({"installed": [], "available": []}) | |
| ); | |
| } | |
| if matches!(cache, CatalogCache::Expired) { | |
| let cache_paths = std::fs::read_dir( | |
| fixture | |
| .home | |
| .path() | |
| .join("cache") | |
| .join("remote_plugin_catalog"), | |
| )? | |
| .map(|entry| entry.map(|entry| entry.path())) | |
| .collect::<std::io::Result<Vec<_>>>()?; | |
| assert_eq!(cache_paths.len(), 1); | |
| let cache_path = &cache_paths[0]; | |
| let mut cached_catalog: Value = | |
| serde_json::from_slice(&std::fs::read(cache_path)?)?; | |
| cached_catalog["fetched_at"] = json!("2000-01-01T00:00:00Z"); | |
| std::fs::write(cache_path, serde_json::to_vec(&cached_catalog)?)?; | |
| } | |
| let output = fixture | |
| .run(&["-c", remote_plugin_config, "plugin", "add", PLUGIN_KEY]) | |
| .await?; | |
| assert!(!output.status.success()); | |
| let stderr = String::from_utf8(output.stderr)?; | |
| assert!(stderr.contains(error), "{stderr}"); | |
| let requests = fixture.server.received_requests().await.unwrap(); | |
| let catalog_collections = requests | |
| .iter() | |
| .filter(|request| request.url.path() == "/backend-api/ps/plugins/list") | |
| .map(|request| { | |
| request | |
| .url | |
| .query_pairs() | |
| .find_map(|(key, value)| (key == "collection").then(|| value.into_owned())) | |
| }) | |
| .collect::<Vec<_>>(); | |
| assert_eq!( | |
| catalog_collections, | |
| vec![collection.map(str::to_owned); catalog_fetches as usize] | |
| ); | |
| assert!(requests.iter().all(|request| { | |
| request.method == "GET" | |
| && matches!( | |
| request.url.path(), | |
| "/backend-api/ps/plugins/list" | "/backend-api/ps/plugins/installed" | |
| ) | |
| })); | |
| assert!( | |
| !fixture | |
| .home | |
| .path() | |
| .join("plugins") | |
| .join("cache") | |
| .join(MARKETPLACE) | |
| .exists() | |
| ); | |
| } | |
| } | |
| Ok(()) | |
| } | |
| async fn remote_plugin_install_checks_policy_and_bundle_before_mutation() -> Result<()> { | |
| let fixture = RemoteMarketplaceFixture::new().await?; | |
| Mock::given(path("/backend-api/ps/plugins/installed")) | |
| .respond_with( | |
| ResponseTemplate::new(200) | |
| .set_body_json(json!({"plugins": [], "pagination": {"next_page_token": null}})), | |
| ) | |
| .mount(&fixture.server) | |
| .await; | |
| for (field, value, error) in [ | |
| ("status", "DISABLED_BY_ADMIN", "disabled by admin"), | |
| ( | |
| "installation_policy", | |
| "NOT_AVAILABLE", | |
| "not available for install", | |
| ), | |
| ("status", "ENABLED", "failed to read plugin bundle tar"), | |
| ] { | |
| let mut plugin = fixture.plugin.clone(); | |
| plugin[field] = json!(value); | |
| let _detail = Mock::given(path(format!("/backend-api/ps/plugins/{REMOTE_ID}"))) | |
| .respond_with(ResponseTemplate::new(200).set_body_json(plugin)) | |
| .mount_as_scoped(&fixture.server) | |
| .await; | |
| let _bundle = Mock::given(path("/bundle.tar.gz")) | |
| .respond_with(ResponseTemplate::new(200).set_body_bytes(b"bad gzip")) | |
| .mount_as_scoped(&fixture.server) | |
| .await; | |
| let output = fixture | |
| .run(&["plugin", "add", "sample", "-m", MARKETPLACE]) | |
| .await?; | |
| assert!(!output.status.success()); | |
| let stderr = String::from_utf8(output.stderr)?; | |
| assert!(stderr.contains(error), "{stderr}"); | |
| } | |
| let requests = fixture.server.received_requests().await.unwrap(); | |
| assert!(requests.iter().all(|request| request.method != "POST")); | |
| assert_eq!( | |
| requests | |
| .iter() | |
| .filter(|request| request.url.path() == "/backend-api/ps/plugins/list") | |
| .count(), | |
| 1, | |
| "installation policy and bundle failures must not refetch the catalog" | |
| ); | |
| Ok(()) | |
| } | |
| async fn remote_plugin_listing_uses_collection_when_remote_catalog_is_disabled() -> Result<()> { | |
| let fixture = RemoteMarketplaceFixture::new().await?; | |
| let mut local_plugin = fixture.write_local_curated_marketplace()?; | |
| Mock::given(path("/backend-api/ps/plugins/installed")) | |
| .respond_with( | |
| ResponseTemplate::new(200) | |
| .set_body_json(json!({"plugins": [], "pagination": {"next_page_token": null}})), | |
| ) | |
| .mount(&fixture.server) | |
| .await; | |
| let listed = fixture | |
| .success(&[ | |
| "-c", | |
| "features.remote_plugin=false", | |
| "plugin", | |
| "list", | |
| "--available", | |
| "--json", | |
| ]) | |
| .await?; | |
| assert_eq!( | |
| serde_json::from_str::<Value>(&listed)?, | |
| json!({"installed": [], "available": [local_plugin.clone(), { | |
| "pluginId": PLUGIN_KEY, "name": "sample", "marketplaceName": MARKETPLACE, | |
| "version": "1.2.3", "installed": false, "enabled": false, | |
| "source": {"source": "remote", "id": REMOTE_ID}, | |
| "installPolicy": "AVAILABLE", "authPolicy": "ON_USE" | |
| }]}) | |
| ); | |
| let requests = fixture.server.received_requests().await.unwrap(); | |
| let listing = requests | |
| .iter() | |
| .find(|request| request.url.path().ends_with("/list")) | |
| .unwrap(); | |
| assert!( | |
| listing | |
| .url | |
| .query_pairs() | |
| .any(|(key, value)| key == "collection" && value == "vertical") | |
| ); | |
| fixture.server.reset().await; | |
| fixture | |
| .success(&["-c", "features.plugins=false", "plugin", "list"]) | |
| .await?; | |
| fixture | |
| .success(&["plugin", "list", "-m", "local-only"]) | |
| .await?; | |
| assert!(fixture.server.received_requests().await.unwrap().is_empty()); | |
| std::fs::remove_file(fixture.home.path().join("auth.json"))?; | |
| let output = fixture.run(&["plugin", "list", "-m", MARKETPLACE]).await?; | |
| assert!(!output.status.success()); | |
| assert!(String::from_utf8(output.stderr)?.contains("chatgpt authentication required")); | |
| let listed = fixture | |
| .success(&["plugin", "list", "--available", "--json"]) | |
| .await?; | |
| local_plugin["pluginId"] = json!("sample@openai-api-curated"); | |
| local_plugin["marketplaceName"] = json!("openai-api-curated"); | |
| assert_eq!( | |
| serde_json::from_str::<Value>(&listed)?, | |
| json!({"installed": [], "available": [local_plugin]}) | |
| ); | |
| Ok(()) | |
| } | |