| use super::*; |
| use crate::LoadedPlugin; |
| use crate::OPENAI_API_CURATED_MARKETPLACE_NAME; |
| use crate::OPENAI_CURATED_MARKETPLACE_NAME; |
| use crate::PluginLoadOutcome; |
| use crate::ToolSuggestDiscoverablePlugin; |
| use crate::ToolSuggestPluginDiscoveryInput; |
| use crate::installed_marketplaces::marketplace_install_root; |
| use crate::loader::load_plugin_skill_inventory; |
| use crate::loader::load_plugins_from_layer_stack; |
| use crate::loader::refresh_non_curated_plugin_cache; |
| use crate::loader::refresh_non_curated_plugin_cache_force_reinstall; |
| use crate::marketplace::MarketplacePluginInstallPolicy; |
| use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; |
| use crate::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME; |
| use crate::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME; |
| use crate::remote::RecommendedPlugin; |
| use crate::remote::RemoteInstalledPlugin; |
| use crate::startup_sync::curated_plugins_repo_path; |
| use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION; |
| use crate::test_support::TEST_CURATED_PLUGIN_SHA; |
| use crate::test_support::load_plugins_config as load_plugins_config_input; |
| use crate::test_support::set_test_auth; |
| use crate::test_support::set_test_auth_mode; |
| use crate::test_support::test_auth_manager; |
| use crate::test_support::test_http_client_factory; |
| use crate::test_support::test_plugins_manager; |
| use crate::test_support::test_plugins_manager_with_auth_manager; |
| use crate::test_support::test_plugins_manager_with_options; |
| use crate::test_support::test_skill_root_loader; |
| use crate::test_support::write_curated_plugin; |
| use crate::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha; |
| use crate::test_support::write_file; |
| use crate::test_support::write_openai_api_curated_marketplace; |
| use crate::test_support::write_openai_curated_marketplace; |
| use codex_config::AppToolApproval; |
| use codex_config::CONFIG_TOML_FILE; |
| use codex_config::ConfigLayerEntry; |
| use codex_config::ConfigLayerSource; |
| use codex_config::ConfigLayerStack; |
| use codex_config::ConfigRequirements; |
| use codex_config::ConfigRequirementsToml; |
| use codex_config::McpServerConfig; |
| use codex_config::McpServerOAuthConfig; |
| use codex_config::McpServerToolConfig; |
| use codex_config::RequirementSource; |
| use codex_config::RequirementsLayerEntry; |
| use codex_config::SkillConfigRules; |
| use codex_config::compose_requirements; |
| use codex_config::types::McpServerTransportConfig; |
| use codex_login::CodexAuth; |
| use codex_login::test_support::auth_manager_with_agent_identity; |
| use codex_model_provider::AMAZON_BEDROCK_PROVIDER_ID; |
| use codex_plugin::AppDeclaration; |
| use codex_plugin::PluginId; |
| use codex_protocol::auth::AuthMode; |
| use codex_protocol::protocol::HookEventName; |
| use codex_protocol::protocol::Product; |
| use codex_utils_absolute_path::AbsolutePathBuf; |
| use codex_utils_absolute_path::test_support::PathBufExt; |
| use codex_utils_plugins::SkillDiscoveryMode; |
| use pretty_assertions::assert_eq; |
| use std::fs; |
| use std::path::Path; |
| use std::time::Duration; |
| use tempfile::TempDir; |
| use toml::Value; |
| 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; |
| use wiremock::matchers::query_param_is_missing; |
|
|
| const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024; |
|
|
| #[path = "marketplace_policy/curated_loading_tests.rs"] |
| mod curated_marketplace_policy; |
|
|
| fn unrestricted_config_layer_stack() -> ConfigLayerStack { |
| ConfigLayerStack::default() |
| } |
|
|
| fn unrestricted_plugins_config_input() -> PluginsConfigInput { |
| PluginsConfigInput::new( |
| unrestricted_config_layer_stack(), |
| String::new(), |
| true, |
| false, |
| String::new(), |
| test_http_client_factory(), |
| ) |
| } |
|
|
| fn config_layer_stack_with_requirements( |
| codex_home: &Path, |
| user_config: &str, |
| requirements: &str, |
| ) -> ConfigLayerStack { |
| let with_sources = compose_requirements([RequirementsLayerEntry::from_toml( |
| RequirementSource::Unknown, |
| requirements, |
| )]) |
| .expect("compose requirements") |
| .expect("requirements should be present"); |
| let requirements_toml = with_sources.clone().into_toml(); |
| let requirements = ConfigRequirements::try_from(with_sources).expect("normalize requirements"); |
| let config_file = |
| AbsolutePathBuf::try_from(codex_home.join(CONFIG_TOML_FILE)).expect("absolute config path"); |
| ConfigLayerStack::new( |
| vec![ConfigLayerEntry::new( |
| ConfigLayerSource::User { |
| file: config_file, |
| profile: None, |
| }, |
| toml::from_str(user_config).expect("parse user config"), |
| )], |
| requirements, |
| requirements_toml, |
| ) |
| .expect("build config layer stack") |
| } |
|
|
| fn plugins_config_input_with_requirements( |
| codex_home: &Path, |
| user_config: &str, |
| requirements: &str, |
| ) -> PluginsConfigInput { |
| PluginsConfigInput::new( |
| config_layer_stack_with_requirements(codex_home, user_config, requirements), |
| String::new(), |
| true, |
| false, |
| String::new(), |
| test_http_client_factory(), |
| ) |
| } |
|
|
| #[tokio::test] |
| async fn plugins_manager_reads_auth_mode_from_auth_manager() { |
| let tmp = TempDir::new().unwrap(); |
| let auth_manager = test_auth_manager( None); |
| let manager = test_plugins_manager_with_auth_manager( |
| tmp.path().to_path_buf(), |
| Some(Product::Codex), |
| Arc::clone(&auth_manager), |
| ); |
|
|
| assert_eq!(manager.auth_mode(), None); |
| set_test_auth_mode(&auth_manager, Some(AuthMode::ApiKey)).await; |
| assert_eq!(manager.auth_mode(), Some(AuthMode::ApiKey)); |
| set_test_auth_mode(&auth_manager, Some(AuthMode::ChatgptAuthTokens)).await; |
| assert_eq!(manager.auth_mode(), Some(AuthMode::ChatgptAuthTokens)); |
| set_test_auth_mode(&auth_manager, None).await; |
| assert_eq!(manager.auth_mode(), None); |
|
|
| let manager_with_auth = test_plugins_manager_with_options( |
| tmp.path().join("auth"), |
| Some(Product::Codex), |
| Some(AuthMode::Chatgpt), |
| ); |
| assert_eq!(manager_with_auth.auth_mode(), Some(AuthMode::Chatgpt)); |
| } |
|
|
| #[test] |
| fn curated_repo_sync_stays_deferred_for_remote_chatgpt_catalog() { |
| CURATED_REPO_SYNC_STARTED.store(false, std::sync::atomic::Ordering::SeqCst); |
| let tmp = TempDir::new().unwrap(); |
| let config = PluginsConfigInput::new( |
| unrestricted_config_layer_stack(), |
| "openai".to_string(), |
| true, |
| true, |
| "https://chatgpt.com".to_string(), |
| test_http_client_factory(), |
| ); |
| let manager = Arc::new(test_plugins_manager_with_options( |
| tmp.path().to_path_buf(), |
| Some(Product::Codex), |
| Some(AuthMode::Chatgpt), |
| )); |
|
|
| manager.maybe_start_curated_repo_sync_for_config( |
| &config, None, |
| ); |
|
|
| assert!(!CURATED_REPO_SYNC_STARTED.load(std::sync::atomic::Ordering::SeqCst)); |
| } |
|
|
| #[test] |
| fn marketplace_source_refresh_notifies_only_after_installed_cache_changes() { |
| let tmp = TempDir::new().unwrap(); |
| let manager = test_plugins_manager(tmp.path().to_path_buf()); |
| let callback_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); |
| let callback_count_for_callback = Arc::clone(&callback_count); |
| let callback: EffectivePluginsChangedCallback = Arc::new(move |_change| { |
| callback_count_for_callback.fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
| }); |
|
|
| manager.clear_caches_after_marketplace_source_refresh( |
| false, |
| Some(&callback), |
| ); |
| assert_eq!(callback_count.load(std::sync::atomic::Ordering::Relaxed), 0); |
|
|
| manager.clear_caches_after_marketplace_source_refresh( |
| true, |
| Some(&callback), |
| ); |
| assert_eq!(callback_count.load(std::sync::atomic::Ordering::Relaxed), 1); |
| } |
|
|
| #[tokio::test] |
| async fn marketplace_policy_projection_disables_installed_plugin_and_invalidates_cache() { |
| let codex_home = TempDir::new().expect("create Codex home"); |
| write_plugin( |
| &codex_home.path().join("plugins/cache/company"), |
| "sample/local", |
| "sample", |
| ); |
| let user_config = r#" |
| [marketplaces.company] |
| source_type = "git" |
| source = "https://github.com/example/company.git" |
| |
| [plugins."sample@company"] |
| enabled = true |
| "#; |
| let allowed = plugins_config_input_with_requirements( |
| codex_home.path(), |
| user_config, |
| r#" |
| [marketplaces] |
| restrict_to_allowed_sources = true |
| |
| [marketplaces.allowed_sources.company] |
| source = "git" |
| url = "https://github.com/example/company.git" |
| "#, |
| ); |
| let blocked = plugins_config_input_with_requirements( |
| codex_home.path(), |
| user_config, |
| r#" |
| [marketplaces] |
| restrict_to_allowed_sources = true |
| |
| [marketplaces.allowed_sources.other] |
| source = "git" |
| url = "https://github.com/example/other.git" |
| "#, |
| ); |
| let manager = test_plugins_manager(codex_home.path().to_path_buf()); |
|
|
| let allowed_outcome = manager.plugins_for_config(&allowed).await; |
| assert_eq!(allowed_outcome.plugins().len(), 1); |
| assert_eq!(allowed_outcome.plugins()[0].config_name, "sample@company"); |
|
|
| let blocked_outcome = manager.plugins_for_config(&blocked).await; |
| assert_eq!(blocked_outcome, PluginLoadOutcome::default()); |
| } |
|
|
| #[tokio::test] |
| async fn plugin_read_rejects_marketplace_blocked_by_requirements() { |
| let codex_home = TempDir::new().expect("create Codex home"); |
| let marketplace_root = codex_home.path().join("marketplace"); |
| write_plugin(&marketplace_root, "sample", "sample"); |
| write_file( |
| &marketplace_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "company", |
| "plugins": [ |
| { |
| "name": "sample", |
| "source": {"source": "local", "path": "./sample"} |
| } |
| ] |
| }"#, |
| ); |
| let config = plugins_config_input_with_requirements( |
| codex_home.path(), |
| "", |
| r#" |
| [marketplaces] |
| restrict_to_allowed_sources = true |
| "#, |
| ); |
| let marketplace_path = |
| AbsolutePathBuf::try_from(marketplace_root.join(".agents/plugins/marketplace.json")) |
| .expect("absolute marketplace path"); |
|
|
| let err = test_plugins_manager(codex_home.path().to_path_buf()) |
| .read_plugin_for_config( |
| &config, |
| &PluginReadRequest { |
| plugin_name: "sample".to_string(), |
| marketplace_path, |
| }, |
| ) |
| .await |
| .expect_err("blocked marketplace should not be readable"); |
| assert!(matches!( |
| err, |
| MarketplaceError::InvalidMarketplaceFile { .. } |
| )); |
| } |
|
|
| #[test] |
| fn marketplace_policy_filters_discovered_marketplaces_by_configured_name() { |
| let codex_home = TempDir::new().expect("create Codex home"); |
| let repo_root = codex_home.path().join("repo"); |
| let subdirectory = repo_root.join("worktree/subdirectory"); |
| fs::create_dir_all(&subdirectory).expect("create input subdirectory"); |
| write_plugin(&repo_root, "sample", "sample"); |
| write_file( |
| &repo_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "company", |
| "plugins": [ |
| { |
| "name": "sample", |
| "source": {"source": "local", "path": "./sample"} |
| } |
| ] |
| }"#, |
| ); |
| init_git_repo(&repo_root); |
| let repo_root = AbsolutePathBuf::try_from(repo_root).expect("absolute repository root"); |
| let subdirectory = |
| AbsolutePathBuf::try_from(subdirectory).expect("absolute input subdirectory"); |
| let manager = test_plugins_manager(codex_home.path().to_path_buf()); |
| let user_config = format!( |
| r#" |
| [marketplaces.company] |
| source_type = "local" |
| source = {:?} |
| "#, |
| repo_root.as_path() |
| ); |
| let allowed = plugins_config_input_with_requirements( |
| codex_home.path(), |
| &user_config, |
| &format!( |
| r#" |
| [marketplaces] |
| restrict_to_allowed_sources = true |
| |
| [marketplaces.allowed_sources.company] |
| source = "local" |
| path = {:?} |
| "#, |
| repo_root.as_path() |
| ), |
| ); |
| let blocked = plugins_config_input_with_requirements( |
| codex_home.path(), |
| &user_config, |
| &format!( |
| r#" |
| [marketplaces] |
| restrict_to_allowed_sources = true |
| |
| [marketplaces.allowed_sources.subdirectory] |
| source = "local" |
| path = {:?} |
| "#, |
| subdirectory.as_path() |
| ), |
| ); |
|
|
| let allowed_outcome = manager |
| .list_marketplaces_for_config( |
| &allowed, |
| std::slice::from_ref(&subdirectory), |
| false, |
| ) |
| .expect("list allowed marketplace"); |
| assert_eq!(allowed_outcome.marketplaces.len(), 1); |
| assert_eq!(allowed_outcome.marketplaces[0].name, "company"); |
|
|
| let blocked_outcome = manager |
| .list_marketplaces_for_config( |
| &blocked, |
| std::slice::from_ref(&subdirectory), |
| false, |
| ) |
| .expect("list blocked marketplace"); |
| assert_eq!(blocked_outcome.marketplaces, Vec::new()); |
| } |
|
|
| fn write_auth_projection_plugin(codex_home: &Path, name: &str, include_app: bool) { |
| let plugin_root = codex_home |
| .join("plugins/cache") |
| .join("test") |
| .join(name) |
| .join("local"); |
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| &format!(r#"{{"name":"{name}"}}"#), |
| ); |
| write_file( |
| &plugin_root.join(".mcp.json"), |
| &format!( |
| r#"{{ |
| "mcpServers": {{ |
| "{name}": {{ |
| "type": "stdio", |
| "command": "{name}-mcp" |
| }} |
| }} |
| }}"# |
| ), |
| ); |
| if include_app { |
| write_auth_projection_app(codex_home, name, name); |
| } |
| } |
|
|
| fn write_auth_projection_app(codex_home: &Path, plugin_name: &str, app_name: &str) { |
| let plugin_root = codex_home |
| .join("plugins/cache") |
| .join("test") |
| .join(plugin_name) |
| .join("local"); |
| write_file( |
| &plugin_root.join(".app.json"), |
| &format!(r#"{{"apps":{{"{app_name}":{{"id":"connector_{plugin_name}"}}}}}}"#), |
| ); |
| } |
|
|
| fn app_declaration(name: &str, connector_id: &str) -> AppDeclaration { |
| AppDeclaration { |
| name: name.to_string(), |
| connector_id: AppConnectorId(connector_id.to_string()), |
| category: None, |
| } |
| } |
|
|
| async fn auth_projection_config(codex_home: &Path) -> PluginsConfigInput { |
| let config_toml = r#"[features] |
| plugins = true |
| |
| [plugins."sample@test"] |
| enabled = true |
| |
| [plugins."docs@test"] |
| enabled = true |
| "# |
| .to_string(); |
| write_file(&codex_home.join(CONFIG_TOML_FILE), &config_toml); |
| load_config(codex_home, codex_home).await |
| } |
|
|
| fn sorted_effective_mcp_server_names(outcome: &PluginLoadOutcome) -> Vec<String> { |
| let mut names = outcome |
| .effective_mcp_servers() |
| .keys() |
| .cloned() |
| .collect::<Vec<_>>(); |
| names.sort(); |
| names |
| } |
|
|
| #[tokio::test] |
| async fn plugin_auth_projection_hides_apps_without_chatgpt_auth() { |
| let codex_home = TempDir::new().unwrap(); |
| write_auth_projection_plugin(codex_home.path(), "sample", true); |
| write_auth_projection_plugin(codex_home.path(), "docs", false); |
| let config = auth_projection_config(codex_home.path()).await; |
| let manager = test_plugins_manager_with_options( |
| codex_home.path().to_path_buf(), |
| Some(Product::Codex), |
| Some(AuthMode::ApiKey), |
| ); |
|
|
| let outcome = manager.plugins_for_config(&config).await; |
|
|
| assert!(outcome.effective_apps().is_empty()); |
| assert_eq!( |
| sorted_effective_mcp_server_names(&outcome), |
| vec!["docs".to_string(), "sample".to_string()] |
| ); |
| let sample = outcome |
| .capability_summaries() |
| .iter() |
| .find(|plugin| plugin.config_name == "sample@test") |
| .expect("sample plugin summary should exist"); |
| assert_eq!(sample.mcp_server_names, vec!["sample".to_string()]); |
| assert!(sample.app_connector_ids.is_empty()); |
| } |
|
|
| #[tokio::test] |
| async fn plugin_auth_projection_hides_matching_mcp_with_chatgpt_apps_route() { |
| let codex_home = TempDir::new().unwrap(); |
| write_auth_projection_plugin(codex_home.path(), "sample", true); |
| write_auth_projection_plugin(codex_home.path(), "docs", false); |
| let config = auth_projection_config(codex_home.path()).await; |
| let manager = test_plugins_manager_with_options( |
| codex_home.path().to_path_buf(), |
| Some(Product::Codex), |
| Some(AuthMode::Chatgpt), |
| ); |
|
|
| let outcome = manager.plugins_for_config(&config).await; |
|
|
| assert_eq!( |
| outcome.effective_apps(), |
| vec![AppConnectorId("connector_sample".to_string())] |
| ); |
| assert_eq!( |
| sorted_effective_mcp_server_names(&outcome), |
| vec!["docs".to_string()] |
| ); |
| let sample = outcome |
| .capability_summaries() |
| .iter() |
| .find(|plugin| plugin.config_name == "sample@test") |
| .expect("sample plugin summary should exist"); |
| assert!(sample.mcp_server_names.is_empty()); |
| assert_eq!( |
| sample.app_connector_ids, |
| vec![AppConnectorId("connector_sample".to_string())] |
| ); |
| let docs = outcome |
| .capability_summaries() |
| .iter() |
| .find(|plugin| plugin.config_name == "docs@test") |
| .expect("docs plugin summary should exist"); |
| assert_eq!(docs.mcp_server_names, vec!["docs".to_string()]); |
| assert!(docs.app_connector_ids.is_empty()); |
| } |
|
|
| #[tokio::test] |
| async fn plugin_auth_projection_hides_dual_surface_mcp_with_agent_identity_apps_route() { |
| let codex_home = TempDir::new().unwrap(); |
| write_auth_projection_plugin(codex_home.path(), "sample", true); |
| write_auth_projection_plugin(codex_home.path(), "docs", false); |
| let config = auth_projection_config(codex_home.path()).await; |
| let manager = test_plugins_manager_with_auth_manager( |
| codex_home.path().to_path_buf(), |
| Some(Product::Codex), |
| auth_manager_with_agent_identity() |
| .await |
| .expect("create test Agent Identity auth manager"), |
| ); |
|
|
| let outcome = manager.plugins_for_config(&config).await; |
|
|
| assert_eq!( |
| outcome.effective_apps(), |
| vec![AppConnectorId("connector_sample".to_string())] |
| ); |
| assert_eq!( |
| sorted_effective_mcp_server_names(&outcome), |
| vec!["docs".to_string()] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn plugin_auth_projection_keeps_non_conflicting_mcp_with_chatgpt_apps_route() { |
| let codex_home = TempDir::new().unwrap(); |
| write_auth_projection_plugin(codex_home.path(), "sample", false); |
| write_auth_projection_app(codex_home.path(), "sample", "sample_app"); |
| write_auth_projection_plugin(codex_home.path(), "docs", false); |
| let config = auth_projection_config(codex_home.path()).await; |
| let manager = test_plugins_manager_with_options( |
| codex_home.path().to_path_buf(), |
| Some(Product::Codex), |
| Some(AuthMode::Chatgpt), |
| ); |
|
|
| let outcome = manager.plugins_for_config(&config).await; |
|
|
| assert_eq!( |
| outcome.effective_apps(), |
| vec![AppConnectorId("connector_sample".to_string())] |
| ); |
| assert_eq!( |
| sorted_effective_mcp_server_names(&outcome), |
| vec!["docs".to_string(), "sample".to_string()] |
| ); |
| let sample = outcome |
| .capability_summaries() |
| .iter() |
| .find(|plugin| plugin.config_name == "sample@test") |
| .expect("sample plugin summary should exist"); |
| assert_eq!(sample.mcp_server_names, vec!["sample".to_string()]); |
| assert_eq!( |
| sample.app_connector_ids, |
| vec![AppConnectorId("connector_sample".to_string())] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn plugin_auth_projection_preserves_duplicate_connector_declaration_names() { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test") |
| .join("sample") |
| .join("local"); |
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| r#"{"name":"sample"}"#, |
| ); |
| write_file( |
| &plugin_root.join(".mcp.json"), |
| r#"{ |
| "mcpServers": { |
| "foo": { |
| "type": "stdio", |
| "command": "foo-mcp" |
| }, |
| "foo2": { |
| "type": "stdio", |
| "command": "foo2-mcp" |
| }, |
| "other": { |
| "type": "stdio", |
| "command": "other-mcp" |
| } |
| } |
| }"#, |
| ); |
| write_file( |
| &plugin_root.join(".app.json"), |
| r#"{ |
| "apps": { |
| "foo": { |
| "id": "connector_shared" |
| }, |
| "foo2": { |
| "id": "connector_shared" |
| } |
| } |
| }"#, |
| ); |
| write_file( |
| &codex_home.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| |
| [plugins."sample@test"] |
| enabled = true |
| "#, |
| ); |
| let config = load_config(codex_home.path(), codex_home.path()).await; |
| let manager = test_plugins_manager_with_options( |
| codex_home.path().to_path_buf(), |
| Some(Product::Codex), |
| Some(AuthMode::Chatgpt), |
| ); |
|
|
| let outcome = manager.plugins_for_config(&config).await; |
|
|
| assert_eq!( |
| outcome.effective_apps(), |
| vec![AppConnectorId("connector_shared".to_string())] |
| ); |
| assert_eq!( |
| sorted_effective_mcp_server_names(&outcome), |
| vec!["other".to_string()] |
| ); |
| let sample = outcome |
| .capability_summaries() |
| .iter() |
| .find(|plugin| plugin.config_name == "sample@test") |
| .expect("sample plugin summary should exist"); |
| assert_eq!(sample.mcp_server_names, vec!["other".to_string()]); |
| assert_eq!( |
| sample.app_connector_ids, |
| vec![AppConnectorId("connector_shared".to_string())] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn plugin_auth_projection_reprojects_cached_plugins_when_auth_changes() { |
| let codex_home = TempDir::new().unwrap(); |
| write_auth_projection_plugin(codex_home.path(), "sample", true); |
| write_auth_projection_plugin(codex_home.path(), "docs", false); |
| let config = auth_projection_config(codex_home.path()).await; |
| let auth_manager = test_auth_manager( None); |
| set_test_auth_mode(&auth_manager, Some(AuthMode::Chatgpt)).await; |
| let manager = test_plugins_manager_with_auth_manager( |
| codex_home.path().to_path_buf(), |
| Some(Product::Codex), |
| Arc::clone(&auth_manager), |
| ); |
|
|
| let chatgpt_outcome = manager.plugins_for_config(&config).await; |
| assert_eq!( |
| sorted_effective_mcp_server_names(&chatgpt_outcome), |
| vec!["docs".to_string()] |
| ); |
| assert_eq!( |
| chatgpt_outcome.effective_apps(), |
| vec![AppConnectorId("connector_sample".to_string())] |
| ); |
| assert_eq!( |
| chatgpt_outcome.capability_summaries(), |
| &[ |
| PluginCapabilitySummary { |
| config_name: "docs@test".to_string(), |
| display_name: "docs".to_string(), |
| plugin_namespace: Some("docs".to_string()), |
| description: None, |
| has_skills: false, |
| mcp_server_names: vec!["docs".to_string()], |
| app_connector_ids: Vec::new(), |
| }, |
| PluginCapabilitySummary { |
| config_name: "sample@test".to_string(), |
| display_name: "sample".to_string(), |
| plugin_namespace: Some("sample".to_string()), |
| description: None, |
| has_skills: false, |
| mcp_server_names: Vec::new(), |
| app_connector_ids: vec![AppConnectorId("connector_sample".to_string())], |
| }, |
| ] |
| ); |
|
|
| set_test_auth_mode(&auth_manager, Some(AuthMode::ApiKey)).await; |
| let api_key_outcome = manager.plugins_for_config(&config).await; |
|
|
| assert_eq!( |
| sorted_effective_mcp_server_names(&api_key_outcome), |
| vec!["docs".to_string(), "sample".to_string()] |
| ); |
| assert!(api_key_outcome.effective_apps().is_empty()); |
| assert_eq!( |
| api_key_outcome.capability_summaries(), |
| &[ |
| PluginCapabilitySummary { |
| config_name: "docs@test".to_string(), |
| display_name: "docs".to_string(), |
| plugin_namespace: Some("docs".to_string()), |
| description: None, |
| has_skills: false, |
| mcp_server_names: vec!["docs".to_string()], |
| app_connector_ids: Vec::new(), |
| }, |
| PluginCapabilitySummary { |
| config_name: "sample@test".to_string(), |
| display_name: "sample".to_string(), |
| plugin_namespace: Some("sample".to_string()), |
| description: None, |
| has_skills: false, |
| mcp_server_names: vec!["sample".to_string()], |
| app_connector_ids: Vec::new(), |
| }, |
| ] |
| ); |
| } |
|
|
| fn write_plugin_with_version( |
| root: &Path, |
| dir_name: &str, |
| manifest_name: &str, |
| manifest_version: Option<&str>, |
| ) { |
| let plugin_root = root.join(dir_name); |
| fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); |
| fs::create_dir_all(plugin_root.join("skills")).unwrap(); |
| let version = manifest_version |
| .map(|manifest_version| format!(r#","version":"{manifest_version}""#)) |
| .unwrap_or_default(); |
| fs::write( |
| plugin_root.join(".codex-plugin/plugin.json"), |
| format!(r#"{{"name":"{manifest_name}"{version}}}"#), |
| ) |
| .unwrap(); |
| fs::write( |
| plugin_root.join("skills/SKILL.md"), |
| format!("---\nname: {manifest_name}-skill\ndescription: test skill\n---\n\n# Test skill\n"), |
| ) |
| .unwrap(); |
| fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap(); |
| } |
|
|
| fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) { |
| write_plugin_with_version( |
| root, |
| dir_name, |
| manifest_name, |
| None, |
| ); |
| } |
|
|
| fn init_git_repo(repo: &Path) { |
| run_git(repo, &["init"]); |
| run_git(repo, &["config", "user.email", "codex-test@example.com"]); |
| run_git(repo, &["config", "user.name", "Codex Test"]); |
| run_git(repo, &["add", "."]); |
| run_git(repo, &["commit", "-m", "initial"]); |
| } |
|
|
| fn run_git(repo: &Path, args: &[&str]) { |
| let output = std::process::Command::new("git") |
| .arg("-C") |
| .arg(repo) |
| .args(args) |
| .output() |
| .unwrap_or_else(|err| panic!("git should run: {err}")); |
| assert!( |
| output.status.success(), |
| "git -C {} {} failed\nstdout:\n{}\nstderr:\n{}", |
| repo.display(), |
| args.join(" "), |
| String::from_utf8_lossy(&output.stdout), |
| String::from_utf8_lossy(&output.stderr) |
| ); |
| } |
|
|
| fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String { |
| let mut root = toml::map::Map::new(); |
|
|
| let mut features = toml::map::Map::new(); |
| features.insert( |
| "plugins".to_string(), |
| Value::Boolean(plugins_feature_enabled), |
| ); |
| root.insert("features".to_string(), Value::Table(features)); |
|
|
| let mut plugin = toml::map::Map::new(); |
| plugin.insert("enabled".to_string(), Value::Boolean(enabled)); |
|
|
| let mut plugins = toml::map::Map::new(); |
| plugins.insert("sample@test".to_string(), Value::Table(plugin)); |
| root.insert("plugins".to_string(), Value::Table(plugins)); |
|
|
| toml::to_string(&Value::Table(root)).expect("plugin test config should serialize") |
| } |
|
|
| async fn load_plugins_from_config( |
| config_toml: &str, |
| codex_home: &Path, |
| auth_mode: Option<AuthMode>, |
| ) -> PluginLoadOutcome { |
| write_file(&codex_home.join(CONFIG_TOML_FILE), config_toml); |
| let config = load_config(codex_home, codex_home).await; |
| test_plugins_manager_with_options(codex_home.to_path_buf(), Some(Product::Codex), auth_mode) |
| .plugins_for_config(&config) |
| .await |
| } |
|
|
| async fn load_config(codex_home: &Path, cwd: &Path) -> PluginsConfigInput { |
| load_plugins_config_input(codex_home, cwd).await |
| } |
|
|
| fn remote_installed_linear_plugin() -> RemoteInstalledPlugin { |
| remote_installed_plugin("linear") |
| } |
|
|
| fn remote_installed_plugin(name: &str) -> RemoteInstalledPlugin { |
| remote_installed_plugin_in_marketplace(name, REMOTE_GLOBAL_MARKETPLACE_NAME) |
| } |
|
|
| fn remote_installed_plugin_in_marketplace( |
| name: &str, |
| marketplace_name: &str, |
| ) -> RemoteInstalledPlugin { |
| RemoteInstalledPlugin { |
| canonical_app_id: None, |
| marketplace_name: marketplace_name.to_string(), |
| id: format!("plugins~Plugin_{name}"), |
| version: None, |
| name: name.to_string(), |
| installed_at: None, |
| enabled: true, |
| install_policy: codex_app_server_protocol::PluginInstallPolicy::Available, |
| install_policy_source: None, |
| must_show_installation_interstitial: None, |
| auth_policy: codex_app_server_protocol::PluginAuthPolicy::OnUse, |
| availability: codex_app_server_protocol::PluginAvailability::Available, |
| disabled_reason: None, |
| eligible_plan_types: None, |
| interface: None, |
| keywords: Vec::new(), |
| } |
| } |
|
|
| fn write_cached_plugin(codex_home: &Path, marketplace_name: &str, plugin_name: &str) { |
| write_plugin_with_version( |
| &codex_home |
| .join("plugins/cache") |
| .join(marketplace_name) |
| .join(plugin_name), |
| "local", |
| plugin_name, |
| Some("local"), |
| ); |
| } |
|
|
| async fn loaded_plugin_names(manager: &PluginsManager, config: &PluginsConfigInput) -> Vec<String> { |
| manager |
| .plugins_for_config(config) |
| .await |
| .plugins() |
| .iter() |
| .map(|plugin| plugin.config_name.clone()) |
| .collect() |
| } |
|
|
| #[tokio::test] |
| async fn load_plugins_loads_default_skills_and_mcp_servers() { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/sample/local"); |
|
|
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| r#"{ |
| "name": "sample", |
| "description": "Plugin that includes the sample MCP server and Skills" |
| }"#, |
| ); |
| write_file( |
| &plugin_root.join("skills/sample-search/SKILL.md"), |
| "---\nname: sample-search\ndescription: search sample data\n---\n", |
| ); |
| write_file( |
| &plugin_root.join(".mcp.json"), |
| r#"{ |
| "mcpServers": { |
| "sample": { |
| "type": "http", |
| "url": "https://sample.example/mcp", |
| "oauth": { |
| "clientId": "client-id", |
| "callbackPort": 3118 |
| } |
| } |
| } |
| }"#, |
| ); |
| write_file( |
| &plugin_root.join(".app.json"), |
| r#"{ |
| "apps": { |
| "example": { |
| "id": "connector_example" |
| } |
| } |
| }"#, |
| ); |
|
|
| let outcome = load_plugins_from_config( |
| &plugin_config_toml( true, true), |
| codex_home.path(), |
| Some(AuthMode::Chatgpt), |
| ) |
| .await; |
|
|
| assert_eq!( |
| outcome.plugins(), |
| vec![LoadedPlugin { |
| config_name: "sample@test".to_string(), |
| remote_plugin_id: None, |
| manifest_name: Some("sample".to_string()), |
| plugin_namespace: Some("sample".to_string()), |
| manifest_description: Some( |
| "Plugin that includes the sample MCP server and Skills".to_string(), |
| ), |
| root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(), |
| enabled: true, |
| skill_roots: vec![plugin_root.join("skills").abs()], |
| skill_discovery_mode: SkillDiscoveryMode::Recursive, |
| disabled_skill_paths: HashSet::new(), |
| has_enabled_skills: true, |
| mcp_servers: HashMap::from([( |
| "sample".to_string(), |
| McpServerConfig { |
| auth: Default::default(), |
| transport: McpServerTransportConfig::StreamableHttp { |
| url: "https://sample.example/mcp".to_string(), |
| bearer_token_env_var: None, |
| http_headers: None, |
| env_http_headers: None, |
| http_headers_helper: None, |
| }, |
| environment_id: "local".to_string(), |
| enabled: true, |
| required: false, |
| supports_parallel_tool_calls: false, |
| omit_tools_from: None, |
| disabled_reason: None, |
| startup_timeout_sec: None, |
| tool_timeout_sec: None, |
| default_tools_approval_mode: None, |
| enabled_tools: None, |
| disabled_tools: None, |
| scopes: None, |
| oauth: Some(McpServerOAuthConfig { |
| client_id: Some("client-id".to_string()), |
| callback_url: None, |
| callback_port: Some(3118), |
| ..Default::default() |
| }), |
| oauth_resource: None, |
| tools: HashMap::new(), |
| }, |
| )]), |
| apps: vec![app_declaration("example", "connector_example")], |
| hook_sources: Vec::new(), |
| hook_load_warnings: Vec::new(), |
| error: None, |
| }] |
| ); |
| assert_eq!( |
| outcome.capability_summaries(), |
| &[PluginCapabilitySummary { |
| config_name: "sample@test".to_string(), |
| display_name: "sample".to_string(), |
| plugin_namespace: Some("sample".to_string()), |
| description: Some("Plugin that includes the sample MCP server and Skills".to_string(),), |
| has_skills: true, |
| mcp_server_names: vec!["sample".to_string()], |
| app_connector_ids: vec![AppConnectorId("connector_example".to_string())], |
| }] |
| ); |
| assert_eq!( |
| outcome |
| .effective_plugin_skill_roots() |
| .into_iter() |
| .map(|root| root.path) |
| .collect::<Vec<_>>(), |
| vec![plugin_root.join("skills").abs()] |
| ); |
| assert_eq!(outcome.effective_mcp_servers().len(), 1); |
| assert_eq!( |
| outcome.effective_apps(), |
| vec![AppConnectorId("connector_example".to_string())] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn load_plugins_loads_manifest_mcp_server_objects() { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/counter-sample/local"); |
|
|
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| r#"{ |
| "name": "counter-sample", |
| "version": "1.1.1", |
| "description": "Plugin that declares MCP servers in the manifest", |
| "mcpServers": { |
| "counter": { |
| "type": "http", |
| "url": "https://sample.example/counter/mcp" |
| } |
| } |
| }"#, |
| ); |
|
|
| let config_toml = r#" |
| [features] |
| plugins = true |
| |
| [plugins."counter-sample@test"] |
| enabled = true |
| "#; |
| let outcome = |
| load_plugins_from_config(config_toml, codex_home.path(), None).await; |
|
|
| assert_eq!(outcome.plugins()[0].error, None); |
| assert_eq!( |
| outcome.plugins()[0].mcp_servers, |
| HashMap::from([( |
| "counter".to_string(), |
| McpServerConfig { |
| auth: Default::default(), |
| transport: McpServerTransportConfig::StreamableHttp { |
| url: "https://sample.example/counter/mcp".to_string(), |
| bearer_token_env_var: None, |
| http_headers: None, |
| env_http_headers: None, |
| http_headers_helper: None, |
| }, |
| environment_id: "local".to_string(), |
| enabled: true, |
| required: false, |
| supports_parallel_tool_calls: false, |
| omit_tools_from: None, |
| disabled_reason: None, |
| startup_timeout_sec: None, |
| tool_timeout_sec: None, |
| default_tools_approval_mode: None, |
| enabled_tools: None, |
| disabled_tools: None, |
| scopes: None, |
| oauth: None, |
| oauth_resource: None, |
| tools: HashMap::new(), |
| }, |
| )]) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn load_plugins_applies_plugin_mcp_server_policy() { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/sample/local"); |
|
|
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| r#"{ |
| "name": "sample" |
| }"#, |
| ); |
| write_file( |
| &plugin_root.join(".mcp.json"), |
| r#"{ |
| "mcpServers": { |
| "sample": { |
| "type": "http", |
| "url": "https://sample.example/mcp", |
| "default_tools_approval_mode": "prompt", |
| "enabled_tools": ["read", "search"], |
| "tools": { |
| "search": { "approval_mode": "prompt", "output_token_limit": 8000 } |
| } |
| } |
| } |
| }"#, |
| ); |
| let config_toml = r#" |
| [features] |
| plugins = true |
| |
| [plugins."sample@test"] |
| enabled = true |
| |
| [plugins."sample@test".mcp_servers.sample] |
| enabled = false |
| default_tools_approval_mode = "approve" |
| enabled_tools = ["search"] |
| disabled_tools = ["delete"] |
| |
| [plugins."sample@test".mcp_servers.sample.tools.search] |
| approval_mode = "approve" |
| output_token_limit = 12000 |
| "#; |
|
|
| let outcome = |
| load_plugins_from_config(config_toml, codex_home.path(), None).await; |
| let server = outcome.plugins()[0] |
| .mcp_servers |
| .get("sample") |
| .expect("sample server"); |
|
|
| assert!(!server.enabled); |
| assert_eq!( |
| server.default_tools_approval_mode, |
| Some(AppToolApproval::Approve) |
| ); |
| assert_eq!(server.enabled_tools, Some(vec!["search".to_string()])); |
| assert_eq!(server.disabled_tools, Some(vec!["delete".to_string()])); |
| assert_eq!( |
| server.tools.get("search"), |
| Some(&McpServerToolConfig { |
| approval_mode: Some(AppToolApproval::Approve), |
| output_token_limit: std::num::NonZeroUsize::new(8_000), |
| }) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn remote_installed_plugin_preserves_configured_mcp_server_policy() { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache/openai-curated-remote/linear/local"); |
| write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); |
| write_file( |
| &plugin_root.join(".mcp.json"), |
| r#"{ |
| "mcpServers": { |
| "linear": { |
| "type": "http", |
| "url": "https://linear.example/mcp" |
| } |
| } |
| }"#, |
| ); |
| write_file( |
| &codex_home.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| |
| [plugins."linear@openai-curated-remote"] |
| enabled = false |
| |
| [plugins."linear@openai-curated-remote".mcp_servers.linear] |
| enabled = false |
| default_tools_approval_mode = "approve" |
| enabled_tools = ["search"] |
| disabled_tools = ["delete"] |
| |
| [plugins."linear@openai-curated-remote".mcp_servers.linear.tools.search] |
| approval_mode = "approve" |
| "#, |
| ); |
|
|
| let config = load_config(codex_home.path(), codex_home.path()).await; |
| let manager = test_plugins_manager_with_options( |
| codex_home.path().to_path_buf(), |
| Some(Product::Codex), |
| Some(AuthMode::Chatgpt), |
| ); |
| manager.write_remote_installed_plugins_cache(vec![remote_installed_linear_plugin()]); |
|
|
| let outcome = manager.plugins_for_config(&config).await; |
| let plugin = outcome |
| .plugins() |
| .iter() |
| .find(|plugin| plugin.config_name == "linear@openai-curated-remote") |
| .expect("remote plugin should be loaded"); |
| let expected_server = serde_json::from_value::<McpServerConfig>(serde_json::json!({ |
| "url": "https://linear.example/mcp", |
| "enabled": false, |
| "default_tools_approval_mode": "approve", |
| "enabled_tools": ["search"], |
| "disabled_tools": ["delete"], |
| "tools": { |
| "search": { "approval_mode": "approve" } |
| } |
| })) |
| .expect("valid expected MCP server"); |
|
|
| assert!(plugin.enabled); |
| assert_eq!( |
| plugin.mcp_servers, |
| HashMap::from([("linear".to_string(), expected_server)]) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn remote_installed_cache_ignores_plugins_missing_local_cache() { |
| let codex_home = TempDir::new().unwrap(); |
| write_file( |
| &codex_home.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| "#, |
| ); |
|
|
| let config = load_config(codex_home.path(), codex_home.path()).await; |
| let manager = test_plugins_manager(codex_home.path().to_path_buf()); |
| manager.write_remote_installed_plugins_cache(vec![remote_installed_linear_plugin()]); |
|
|
| let outcome = manager.plugins_for_config(&config).await; |
| assert_eq!(outcome, PluginLoadOutcome::default()); |
| } |
|
|
| #[tokio::test] |
| async fn installed_plugin_telemetry_metadata_collects_capabilities() { |
| let codex_home = TempDir::new().unwrap(); |
| write_cached_plugin(codex_home.path(), "test", "sample"); |
| let manager = test_plugins_manager(codex_home.path().to_path_buf()); |
| let plugin_id = PluginId::parse("sample@test").expect("plugin id should parse"); |
|
|
| let metadata = manager |
| .telemetry_metadata_for_installed_plugin(&plugin_id) |
| .await; |
|
|
| assert_eq!( |
| metadata, |
| PluginTelemetryMetadata { |
| plugin_id: Some(plugin_id), |
| remote_plugin_id: None, |
| capability_summary: Some(PluginCapabilitySummary { |
| config_name: "sample@test".to_string(), |
| display_name: "sample".to_string(), |
| plugin_namespace: Some("sample".to_string()), |
| description: None, |
| has_skills: true, |
| mcp_server_names: Vec::new(), |
| app_connector_ids: Vec::new(), |
| }), |
| } |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn installed_agent_plugin_telemetry_metadata_uses_portable_capabilities() { |
| for (skill_path, has_skills) in [ |
| ("skills/direct/SKILL.md", true), |
| ("skills/group/nested/SKILL.md", false), |
| ] { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache/test/agent-plugin/local"); |
| write_file( |
| &plugin_root.join("plugin.json"), |
| r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"agent.tools"}"#, |
| ); |
| write_file( |
| &plugin_root.join(skill_path), |
| "---\nname: portable\ndescription: Portable skill\n---\n", |
| ); |
| write_file( |
| &plugin_root.join("mcp.json"), |
| r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"portable":{"type":"stdio","command":"echo"}}}"#, |
| ); |
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| r#"{"apps":"./.app.json"}"#, |
| ); |
| write_file( |
| &plugin_root.join(".app.json"), |
| r#"{"apps":{"legacy":{"id":"connector_legacy"}}}"#, |
| ); |
| let manager = test_plugins_manager(codex_home.path().to_path_buf()); |
| let plugin_id = PluginId::parse("agent-plugin@test").expect("plugin id should parse"); |
|
|
| let metadata = manager |
| .telemetry_metadata_for_installed_plugin(&plugin_id) |
| .await; |
|
|
| assert_eq!( |
| metadata, |
| PluginTelemetryMetadata { |
| plugin_id: Some(plugin_id), |
| remote_plugin_id: None, |
| capability_summary: Some(PluginCapabilitySummary { |
| config_name: "agent-plugin@test".to_string(), |
| display_name: "agent-plugin".to_string(), |
| plugin_namespace: Some("agent.tools".to_string()), |
| description: None, |
| has_skills, |
| mcp_server_names: vec!["portable".to_string()], |
| app_connector_ids: Vec::new(), |
| }), |
| } |
| ); |
| } |
| } |
|
|
| #[tokio::test] |
| async fn installed_plugin_telemetry_metadata_resolves_persisted_remote_identity() { |
| let codex_home = TempDir::new().unwrap(); |
| write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); |
| let plugin_id = |
| PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse"); |
| PluginStore::new(codex_home.path().to_path_buf()) |
| .write_remote_plugin_id(&plugin_id, "plugins~Plugin_linear") |
| .expect("persist remote plugin id"); |
| let manager = test_plugins_manager(codex_home.path().to_path_buf()); |
|
|
| let metadata = manager |
| .telemetry_metadata_for_installed_plugin(&plugin_id) |
| .await; |
|
|
| assert_eq!( |
| metadata, |
| PluginTelemetryMetadata { |
| plugin_id: Some(plugin_id), |
| remote_plugin_id: Some("plugins~Plugin_linear".to_string()), |
| capability_summary: Some(PluginCapabilitySummary { |
| config_name: "linear@openai-curated-remote".to_string(), |
| display_name: "linear".to_string(), |
| plugin_namespace: Some("linear".to_string()), |
| description: None, |
| has_skills: true, |
| mcp_server_names: Vec::new(), |
| app_connector_ids: Vec::new(), |
| }), |
| } |
| ); |
| } |
|
|
| #[test] |
| fn plugin_telemetry_ignores_local_marketplace_sidecars() { |
| let codex_home = TempDir::new().unwrap(); |
| write_cached_plugin(codex_home.path(), "test", "sample"); |
| let plugin_id = PluginId::parse("sample@test").expect("plugin id should parse"); |
| PluginStore::new(codex_home.path().to_path_buf()) |
| .write_remote_plugin_id(&plugin_id, "plugins~Plugin_sample") |
| .expect("persist remote plugin id"); |
| let manager = test_plugins_manager(codex_home.path().to_path_buf()); |
|
|
| assert_eq!( |
| manager.telemetry_metadata_for_plugin_id(&plugin_id), |
| PluginTelemetryMetadata { |
| plugin_id: Some(plugin_id), |
| remote_plugin_id: None, |
| capability_summary: None, |
| } |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn installed_plugin_telemetry_metadata_prefers_remote_snapshot_identity() { |
| let codex_home = TempDir::new().unwrap(); |
| write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); |
| let plugin_id = |
| PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse"); |
| PluginStore::new(codex_home.path().to_path_buf()) |
| .write_remote_plugin_id(&plugin_id, "plugins~Plugin_stale") |
| .expect("persist remote plugin id"); |
| let manager = test_plugins_manager(codex_home.path().to_path_buf()); |
| manager.write_remote_installed_plugins_cache(vec![remote_installed_linear_plugin()]); |
|
|
| let metadata = manager |
| .telemetry_metadata_for_installed_plugin(&plugin_id) |
| .await; |
|
|
| assert_eq!( |
| metadata, |
| PluginTelemetryMetadata { |
| plugin_id: Some(plugin_id), |
| remote_plugin_id: Some("plugins~Plugin_linear".to_string()), |
| capability_summary: Some(PluginCapabilitySummary { |
| config_name: "linear@openai-curated-remote".to_string(), |
| display_name: "linear".to_string(), |
| plugin_namespace: Some("linear".to_string()), |
| description: None, |
| has_skills: true, |
| mcp_server_names: Vec::new(), |
| app_connector_ids: Vec::new(), |
| }), |
| } |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn installed_plugin_telemetry_metadata_accepts_authoritative_remote_identity() { |
| let codex_home = TempDir::new().unwrap(); |
| let manager = test_plugins_manager(codex_home.path().to_path_buf()); |
| let plugin_id = |
| PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse"); |
|
|
| let metadata = manager |
| .telemetry_metadata_for_installed_plugin_with_remote_id(&plugin_id, "plugins~Plugin_linear") |
| .await; |
|
|
| assert_eq!( |
| metadata, |
| PluginTelemetryMetadata { |
| plugin_id: Some(plugin_id), |
| remote_plugin_id: Some("plugins~Plugin_linear".to_string()), |
| capability_summary: None, |
| } |
| ); |
| } |
|
|
| #[test] |
| fn capability_summary_telemetry_metadata_uses_local_identity() { |
| let codex_home = TempDir::new().unwrap(); |
| let manager = test_plugins_manager(codex_home.path().to_path_buf()); |
| let summary = PluginCapabilitySummary { |
| config_name: "linear@openai-curated-remote".to_string(), |
| display_name: "Linear".to_string(), |
| plugin_namespace: Some("linear".to_string()), |
| description: Some("Track work".to_string()), |
| has_skills: true, |
| mcp_server_names: vec!["linear".to_string()], |
| app_connector_ids: vec![AppConnectorId("linear-app".to_string())], |
| }; |
|
|
| let metadata = manager.telemetry_metadata_for_capability_summary(&summary); |
|
|
| assert_eq!( |
| metadata, |
| Some(PluginTelemetryMetadata { |
| plugin_id: Some( |
| PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse"), |
| ), |
| remote_plugin_id: None, |
| capability_summary: Some(summary), |
| }) |
| ); |
| } |
|
|
| #[test] |
| fn capability_summary_telemetry_metadata_resolves_persisted_remote_identity() { |
| let codex_home = TempDir::new().unwrap(); |
| write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); |
| let plugin_id = |
| PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse"); |
| PluginStore::new(codex_home.path().to_path_buf()) |
| .write_remote_plugin_id(&plugin_id, "plugins~Plugin_linear") |
| .expect("persist remote plugin id"); |
| let manager = test_plugins_manager(codex_home.path().to_path_buf()); |
| let summary = PluginCapabilitySummary { |
| config_name: "linear@openai-curated-remote".to_string(), |
| display_name: "Linear".to_string(), |
| plugin_namespace: Some("linear".to_string()), |
| description: Some("Track work".to_string()), |
| has_skills: true, |
| mcp_server_names: vec!["linear".to_string()], |
| app_connector_ids: vec![AppConnectorId("linear-app".to_string())], |
| }; |
|
|
| let metadata = manager.telemetry_metadata_for_capability_summary(&summary); |
|
|
| assert_eq!( |
| metadata, |
| Some(PluginTelemetryMetadata { |
| plugin_id: Some(plugin_id), |
| remote_plugin_id: Some("plugins~Plugin_linear".to_string()), |
| capability_summary: Some(summary), |
| }) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn remote_installed_cache_prefers_local_curated_conflicts_when_remote_plugin_disabled() { |
| let codex_home = TempDir::new().unwrap(); |
| write_file( |
| &codex_home.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| remote_plugin = false |
| |
| [plugins."linear@openai-curated"] |
| enabled = true |
| |
| [plugins."calendar@openai-curated"] |
| enabled = true |
| |
| [plugins."linear@openai-api-curated"] |
| enabled = true |
| "#, |
| ); |
| write_cached_plugin(codex_home.path(), "openai-curated", "linear"); |
| write_cached_plugin(codex_home.path(), "openai-curated", "calendar"); |
| write_cached_plugin(codex_home.path(), "openai-api-curated", "linear"); |
| write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); |
| write_cached_plugin(codex_home.path(), "openai-curated-remote", "remote-only"); |
|
|
| let config = load_config(codex_home.path(), codex_home.path()).await; |
| let manager = test_plugins_manager_with_options( |
| codex_home.path().to_path_buf(), |
| Some(Product::Codex), |
| Some(AuthMode::Chatgpt), |
| ); |
| manager.write_remote_installed_plugins_cache(vec![ |
| remote_installed_plugin("linear"), |
| remote_installed_plugin("remote-only"), |
| ]); |
|
|
| assert_eq!( |
| loaded_plugin_names(&manager, &config).await, |
| vec![ |
| "calendar@openai-curated".to_string(), |
| "linear@openai-curated".to_string(), |
| "remote-only@openai-curated-remote".to_string(), |
| ] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn api_curated_plugin_does_not_suppress_remote_curated_conflict_for_chatgpt() { |
| let codex_home = TempDir::new().unwrap(); |
| write_file( |
| &codex_home.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| remote_plugin = false |
| |
| [plugins."linear@openai-api-curated"] |
| enabled = true |
| "#, |
| ); |
| write_cached_plugin(codex_home.path(), "openai-api-curated", "linear"); |
| write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); |
|
|
| let config = load_config(codex_home.path(), codex_home.path()).await; |
| let manager = test_plugins_manager_with_options( |
| codex_home.path().to_path_buf(), |
| Some(Product::Codex), |
| Some(AuthMode::Chatgpt), |
| ); |
| manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("linear")]); |
|
|
| assert_eq!( |
| loaded_plugin_names(&manager, &config).await, |
| vec!["linear@openai-curated-remote".to_string()] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn remote_global_catalog_ignores_local_curated_plugins() { |
| let codex_home = TempDir::new().unwrap(); |
| write_file( |
| &codex_home.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| |
| [plugins."linear@openai-curated"] |
| enabled = true |
| |
| [plugins."linear@openai-api-curated"] |
| enabled = true |
| |
| [plugins."calendar@openai-curated"] |
| enabled = true |
| "#, |
| ); |
| write_cached_plugin(codex_home.path(), "openai-curated", "linear"); |
| write_cached_plugin(codex_home.path(), "openai-api-curated", "linear"); |
| write_cached_plugin(codex_home.path(), "openai-curated", "calendar"); |
| write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); |
| write_cached_plugin(codex_home.path(), "openai-curated-remote", "remote-only"); |
|
|
| let config = load_config(codex_home.path(), codex_home.path()).await; |
| let manager = test_plugins_manager_with_options( |
| codex_home.path().to_path_buf(), |
| Some(Product::Codex), |
| Some(AuthMode::Chatgpt), |
| ); |
| manager.write_remote_installed_plugins_cache(vec![ |
| remote_installed_plugin("linear"), |
| remote_installed_plugin("remote-only"), |
| ]); |
|
|
| assert_eq!( |
| loaded_plugin_names(&manager, &config).await, |
| vec![ |
| "linear@openai-curated-remote".to_string(), |
| "remote-only@openai-curated-remote".to_string(), |
| ] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn non_chatgpt_auth_rejects_cached_remote_curated_plugins_after_auth_switch() { |
| let codex_home = TempDir::new().unwrap(); |
| write_file( |
| &codex_home.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| |
| [plugins."linear@openai-curated"] |
| enabled = true |
| |
| [plugins."linear@openai-api-curated"] |
| enabled = true |
| "#, |
| ); |
| write_cached_plugin(codex_home.path(), "openai-curated", "linear"); |
| write_cached_plugin(codex_home.path(), "openai-api-curated", "linear"); |
| write_cached_plugin(codex_home.path(), "openai-curated-remote", "remote-only"); |
|
|
| let mut config = load_config(codex_home.path(), codex_home.path()).await; |
| let auth_manager = test_auth_manager( None); |
| set_test_auth_mode(&auth_manager, Some(AuthMode::Chatgpt)).await; |
| let manager = test_plugins_manager_with_auth_manager( |
| codex_home.path().to_path_buf(), |
| Some(Product::Codex), |
| Arc::clone(&auth_manager), |
| ); |
| manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("remote-only")]); |
| assert_eq!( |
| loaded_plugin_names(&manager, &config).await, |
| vec!["remote-only@openai-curated-remote".to_string()] |
| ); |
|
|
| set_test_auth_mode(&auth_manager, None).await; |
| assert_eq!( |
| loaded_plugin_names(&manager, &config).await, |
| vec!["linear@openai-api-curated".to_string()] |
| ); |
|
|
| for auth_mode in [AuthMode::ApiKey, AuthMode::BedrockApiKey] { |
| set_test_auth_mode(&auth_manager, Some(auth_mode)).await; |
|
|
| assert_eq!( |
| loaded_plugin_names(&manager, &config).await, |
| vec!["linear@openai-api-curated".to_string()] |
| ); |
| } |
|
|
| set_test_auth_mode(&auth_manager, None).await; |
| for model_provider_id in ["openai", AMAZON_BEDROCK_PROVIDER_ID, "ollama"] { |
| config.model_provider_id = model_provider_id.to_string(); |
| assert_eq!( |
| loaded_plugin_names(&manager, &config).await, |
| vec!["linear@openai-api-curated".to_string()] |
| ); |
| } |
|
|
| set_test_auth_mode(&auth_manager, Some(AuthMode::Chatgpt)).await; |
| assert_eq!( |
| loaded_plugin_names(&manager, &config).await, |
| vec!["remote-only@openai-curated-remote".to_string()] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn build_remote_installed_plugin_marketplaces_from_cache_uses_remote_metadata() { |
| let codex_home = TempDir::new().unwrap(); |
| let manager = test_plugins_manager(codex_home.path().to_path_buf()); |
| let mut plugin = remote_installed_linear_plugin(); |
| plugin.install_policy = codex_app_server_protocol::PluginInstallPolicy::InstalledByDefault; |
| plugin.auth_policy = codex_app_server_protocol::PluginAuthPolicy::OnInstall; |
| plugin.interface = Some(codex_app_server_protocol::PluginInterface { |
| display_name: Some("Linear".to_string()), |
| short_description: Some("Track remote work".to_string()), |
| long_description: None, |
| developer_name: None, |
| category: None, |
| capabilities: Vec::new(), |
| website_url: None, |
| privacy_policy_url: None, |
| terms_of_service_url: None, |
| default_prompt: None, |
| brand_color: Some("#111111".to_string()), |
| composer_icon: None, |
| composer_icon_url: None, |
| logo: None, |
| logo_dark: None, |
| logo_url: None, |
| logo_url_dark: None, |
| screenshots: Vec::new(), |
| screenshot_urls: Vec::new(), |
| }); |
| plugin.keywords = vec!["issues".to_string()]; |
| manager.write_remote_installed_plugins_cache(vec![plugin]); |
|
|
| let marketplaces = manager |
| .build_remote_installed_plugin_marketplaces_from_cache(&[REMOTE_GLOBAL_MARKETPLACE_NAME]) |
| .expect("remote installed cache should be present"); |
| assert_eq!(marketplaces.len(), 1); |
| assert_eq!(marketplaces[0].name, "openai-curated-remote"); |
| assert_eq!(marketplaces[0].display_name, "OpenAI Curated Remote"); |
| assert_eq!(marketplaces[0].plugins.len(), 1); |
| let plugin = &marketplaces[0].plugins[0]; |
| assert_eq!(plugin.id, "linear@openai-curated-remote"); |
| assert_eq!(plugin.remote_plugin_id, "plugins~Plugin_linear"); |
| assert_eq!(plugin.name, "linear"); |
| assert_eq!(plugin.installed, true); |
| assert_eq!(plugin.enabled, true); |
| assert_eq!( |
| plugin.install_policy, |
| codex_app_server_protocol::PluginInstallPolicy::InstalledByDefault |
| ); |
| assert_eq!( |
| plugin.auth_policy, |
| codex_app_server_protocol::PluginAuthPolicy::OnInstall |
| ); |
| assert_eq!(plugin.keywords, vec!["issues".to_string()]); |
| assert_eq!( |
| plugin |
| .interface |
| .as_ref() |
| .and_then(|interface| interface.display_name.as_deref()), |
| Some("Linear") |
| ); |
| assert_eq!( |
| plugin |
| .interface |
| .as_ref() |
| .and_then(|interface| interface.short_description.as_deref()), |
| Some("Track remote work") |
| ); |
| assert_eq!( |
| manager |
| .build_remote_installed_plugin_marketplaces_from_cache(&[ |
| REMOTE_WORKSPACE_MARKETPLACE_NAME |
| ]) |
| .expect("remote installed cache should be present"), |
| Vec::new() |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn build_remote_installed_plugin_marketplaces_from_cache_filters_by_marketplace_name() { |
| let codex_home = TempDir::new().unwrap(); |
| let manager = test_plugins_manager(codex_home.path().to_path_buf()); |
| manager.write_remote_installed_plugins_cache(vec![ |
| remote_installed_plugin_in_marketplace( |
| "workspace-linear", |
| REMOTE_WORKSPACE_MARKETPLACE_NAME, |
| ), |
| remote_installed_plugin_in_marketplace( |
| "shared-linear", |
| REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME, |
| ), |
| ]); |
|
|
| let marketplaces = manager |
| .build_remote_installed_plugin_marketplaces_from_cache(&[REMOTE_WORKSPACE_MARKETPLACE_NAME]) |
| .expect("remote installed cache should be present"); |
|
|
| assert_eq!(marketplaces.len(), 1); |
| assert_eq!(marketplaces[0].name, REMOTE_WORKSPACE_MARKETPLACE_NAME); |
| assert_eq!( |
| marketplaces[0] |
| .plugins |
| .iter() |
| .map(|plugin| plugin.id.as_str()) |
| .collect::<Vec<_>>(), |
| vec!["workspace-linear@workspace-directory"] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn load_plugins_resolves_disabled_skill_names_against_loaded_plugin_skills() { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/sample/local"); |
| let skill_path = plugin_root.join("skills/sample-search/SKILL.md"); |
|
|
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| r#"{"name":"sample"}"#, |
| ); |
| write_file( |
| &skill_path, |
| "---\nname: sample-search\ndescription: search sample data\n---\n", |
| ); |
|
|
| let config_toml = r#"[features] |
| plugins = true |
| |
| [[skills.config]] |
| name = "sample:sample-search" |
| enabled = false |
| |
| [plugins."sample@test"] |
| enabled = true |
| "#; |
| let outcome = |
| load_plugins_from_config(config_toml, codex_home.path(), None).await; |
| let skill_path = std::fs::canonicalize(skill_path) |
| .expect("skill path should canonicalize") |
| .abs(); |
|
|
| assert_eq!( |
| outcome.plugins()[0].disabled_skill_paths, |
| HashSet::from([skill_path]) |
| ); |
| assert!(!outcome.plugins()[0].has_enabled_skills); |
| assert!(outcome.capability_summaries().is_empty()); |
| } |
|
|
| #[tokio::test] |
| async fn load_plugins_ignores_unknown_disabled_skill_names() { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/sample/local"); |
|
|
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| r#"{"name":"sample"}"#, |
| ); |
| write_file( |
| &plugin_root.join("skills/sample-search/SKILL.md"), |
| "---\nname: sample-search\ndescription: search sample data\n---\n", |
| ); |
|
|
| let config_toml = r#"[features] |
| plugins = true |
| |
| [[skills.config]] |
| name = "sample:missing-skill" |
| enabled = false |
| |
| [plugins."sample@test"] |
| enabled = true |
| "#; |
| let outcome = |
| load_plugins_from_config(config_toml, codex_home.path(), None).await; |
|
|
| assert!(outcome.plugins()[0].disabled_skill_paths.is_empty()); |
| assert!(outcome.plugins()[0].has_enabled_skills); |
| assert_eq!( |
| outcome.capability_summaries(), |
| &[PluginCapabilitySummary { |
| config_name: "sample@test".to_string(), |
| display_name: "sample".to_string(), |
| plugin_namespace: Some("sample".to_string()), |
| description: None, |
| has_skills: true, |
| mcp_server_names: Vec::new(), |
| app_connector_ids: Vec::new(), |
| }] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn plugin_telemetry_metadata_uses_default_mcp_config_path() { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/sample/local"); |
|
|
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| r#"{ |
| "name": "sample" |
| }"#, |
| ); |
| write_file( |
| &plugin_root.join(".mcp.json"), |
| r#"{ |
| "mcpServers": { |
| "sample": { |
| "type": "http", |
| "url": "https://sample.example/mcp" |
| } |
| } |
| }"#, |
| ); |
|
|
| let summary = plugin_capability_summary_from_root( |
| &PluginId::parse("sample@test").expect("plugin id should parse"), |
| &plugin_root.abs(), |
| test_skill_root_loader().as_ref(), |
| ) |
| .await; |
|
|
| assert_eq!( |
| summary, |
| Some(PluginCapabilitySummary { |
| config_name: "sample@test".to_string(), |
| display_name: "sample".to_string(), |
| plugin_namespace: Some("sample".to_string()), |
| description: None, |
| has_skills: false, |
| mcp_server_names: vec!["sample".to_string()], |
| app_connector_ids: Vec::new(), |
| }) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn plugin_capability_summary_uses_manifest_mcp_server_objects() { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/counter-sample/local"); |
|
|
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| r#"{ |
| "name": "counter-sample", |
| "version": "1.1.1", |
| "mcpServers": { |
| "counter": { |
| "type": "http", |
| "url": "https://sample.example/counter/mcp" |
| } |
| } |
| }"#, |
| ); |
|
|
| let summary = plugin_capability_summary_from_root( |
| &PluginId::parse("counter-sample@test").expect("plugin id should parse"), |
| &plugin_root.abs(), |
| test_skill_root_loader().as_ref(), |
| ) |
| .await; |
|
|
| assert_eq!( |
| summary, |
| Some(PluginCapabilitySummary { |
| config_name: "counter-sample@test".to_string(), |
| display_name: "counter-sample".to_string(), |
| plugin_namespace: Some("counter-sample".to_string()), |
| description: None, |
| has_skills: false, |
| mcp_server_names: vec!["counter".to_string()], |
| app_connector_ids: Vec::new(), |
| }) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn capability_summary_sanitizes_plugin_descriptions_to_one_line() { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/sample/local"); |
|
|
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| r#"{ |
| "name": "sample", |
| "description": "Plugin that\n includes the sample\tserver" |
| }"#, |
| ); |
| write_file( |
| &plugin_root.join("skills/sample-search/SKILL.md"), |
| "---\nname: sample-search\ndescription: search sample data\n---\n", |
| ); |
|
|
| let outcome = load_plugins_from_config( |
| &plugin_config_toml( true, true), |
| codex_home.path(), |
| None, |
| ) |
| .await; |
|
|
| assert_eq!( |
| outcome.plugins()[0].manifest_description.as_deref(), |
| Some("Plugin that\n includes the sample\tserver") |
| ); |
| assert_eq!( |
| outcome.capability_summaries()[0].description.as_deref(), |
| Some("Plugin that includes the sample server") |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn capability_summary_truncates_overlong_plugin_descriptions() { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/sample/local"); |
| let too_long = "x".repeat(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN + 1); |
|
|
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| &format!( |
| r#"{{ |
| "name": "sample", |
| "description": "{too_long}" |
| }}"# |
| ), |
| ); |
| write_file( |
| &plugin_root.join("skills/sample-search/SKILL.md"), |
| "---\nname: sample-search\ndescription: search sample data\n---\n", |
| ); |
|
|
| let outcome = load_plugins_from_config( |
| &plugin_config_toml( true, true), |
| codex_home.path(), |
| None, |
| ) |
| .await; |
|
|
| assert_eq!( |
| outcome.plugins()[0].manifest_description.as_deref(), |
| Some(too_long.as_str()) |
| ); |
| assert_eq!( |
| outcome.capability_summaries()[0].description, |
| Some("x".repeat(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN)) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn load_plugins_uses_manifest_configured_component_paths() { |
| for (skills_json, expected_skill_dirs) in [ |
| (r#""./custom-skills/""#, &["custom-skills"][..]), |
| ( |
| r#"["./custom-skills/", "./extra-skills/"]"#, |
| &["custom-skills", "extra-skills"][..], |
| ), |
| ( |
| r#"["./custom-skills/", "./custom-skills/"]"#, |
| &["custom-skills"][..], |
| ), |
| (r#""./skills/""#, &["skills"][..]), |
| ( |
| r#"["./skills/abc/", "./skills/edk/"]"#, |
| &["skills/abc", "skills/edk"][..], |
| ), |
| ] { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/sample/local"); |
|
|
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| &format!( |
| r#"{{ |
| "name": "sample", |
| "skills": {skills_json}, |
| "mcpServers": "./config/custom.mcp.json", |
| "apps": "./config/custom.app.json" |
| }}"# |
| ), |
| ); |
| write_file( |
| &plugin_root.join("skills/default-skill/SKILL.md"), |
| "---\nname: default-skill\ndescription: default skill\n---\n", |
| ); |
| write_file( |
| &plugin_root.join("skills/abc/SKILL.md"), |
| "---\nname: abc\ndescription: abc skill\n---\n", |
| ); |
| write_file( |
| &plugin_root.join("skills/edk/SKILL.md"), |
| "---\nname: edk\ndescription: edk skill\n---\n", |
| ); |
| write_file( |
| &plugin_root.join("custom-skills/custom-skill/SKILL.md"), |
| "---\nname: custom-skill\ndescription: custom skill\n---\n", |
| ); |
| write_file( |
| &plugin_root.join("extra-skills/extra-skill/SKILL.md"), |
| "---\nname: extra-skill\ndescription: extra skill\n---\n", |
| ); |
| write_file( |
| &plugin_root.join(".mcp.json"), |
| r#"{ |
| "mcpServers": { |
| "default": { |
| "type": "http", |
| "url": "https://default.example/mcp" |
| } |
| } |
| }"#, |
| ); |
| write_file( |
| &plugin_root.join("config/custom.mcp.json"), |
| r#"{ |
| "mcpServers": { |
| "custom": { |
| "type": "http", |
| "url": "https://custom.example/mcp" |
| } |
| } |
| }"#, |
| ); |
| write_file( |
| &plugin_root.join(".app.json"), |
| r#"{ |
| "apps": { |
| "default-app": { |
| "id": "connector_default" |
| } |
| } |
| }"#, |
| ); |
| write_file( |
| &plugin_root.join("config/custom.app.json"), |
| r#"{ |
| "apps": { |
| "custom-app": { |
| "id": "connector_custom" |
| } |
| } |
| }"#, |
| ); |
| let outcome = load_plugins_from_config( |
| &plugin_config_toml( true, true), |
| codex_home.path(), |
| Some(AuthMode::Chatgpt), |
| ) |
| .await; |
| let mut expected_skill_roots = expected_skill_dirs |
| .iter() |
| .map(|dir| plugin_root.join(dir).abs()) |
| .collect::<Vec<_>>(); |
| expected_skill_roots.sort_unstable(); |
| expected_skill_roots.dedup(); |
|
|
| assert_eq!(outcome.plugins()[0].skill_roots, expected_skill_roots); |
| assert_eq!( |
| outcome.plugins()[0].mcp_servers, |
| HashMap::from([( |
| "custom".to_string(), |
| McpServerConfig { |
| auth: Default::default(), |
| transport: McpServerTransportConfig::StreamableHttp { |
| url: "https://custom.example/mcp".to_string(), |
| bearer_token_env_var: None, |
| http_headers: None, |
| env_http_headers: None, |
| http_headers_helper: None, |
| }, |
| environment_id: "local".to_string(), |
| enabled: true, |
| required: false, |
| supports_parallel_tool_calls: false, |
| omit_tools_from: None, |
| disabled_reason: None, |
| startup_timeout_sec: None, |
| tool_timeout_sec: None, |
| default_tools_approval_mode: None, |
| enabled_tools: None, |
| disabled_tools: None, |
| scopes: None, |
| oauth: None, |
| oauth_resource: None, |
| tools: HashMap::new(), |
| }, |
| )]) |
| ); |
| assert_eq!( |
| outcome.plugins()[0].apps, |
| vec![app_declaration("custom-app", "connector_custom")] |
| ); |
| } |
| } |
|
|
| #[tokio::test] |
| async fn install_plugin_materializes_default_command_skills() { |
| let codex_home = TempDir::new().unwrap(); |
| let source_root = codex_home.path().join("source/sample"); |
|
|
| write_file( |
| &source_root.join(".codex-plugin/plugin.json"), |
| r#"{ |
| "name": "sample", |
| "skills": "./custom-skills/" |
| }"#, |
| ); |
| fs::create_dir_all(source_root.join("custom-skills")).unwrap(); |
| write_file( |
| &source_root.join("custom-skills/source-command-pr-review/SKILL.md"), |
| "---\nname: source-command-pr-review\ndescription: Native review skill\n---\n", |
| ); |
| write_file( |
| &source_root.join("commands/pr/review.md"), |
| "---\ndescription: Review a pull request\n---\nInspect the proposed changes.\n", |
| ); |
| write_file( |
| &source_root.join("commands/summarize.md"), |
| "---\ndescription: Summarize a change\n---\nSummarize the proposed changes.\n", |
| ); |
| write_file( |
| &source_root.join("commands/oversized.md"), |
| &format!("---\ndescription: Oversized\n---\n{}", "x".repeat(4_000)), |
| ); |
| write_file( |
| &source_root.join(".codex-plugin/migrated-command-skills/undeclared-command/SKILL.md"), |
| "---\nname: undeclared-command\ndescription: undeclared command\n---\n", |
| ); |
| let result = PluginStore::new(codex_home.path().to_path_buf()) |
| .install( |
| source_root.abs(), |
| PluginId::parse("sample@test").expect("plugin id should parse"), |
| ) |
| .unwrap(); |
| let migrated_skill = result |
| .installed_path |
| .join(".codex-plugin/migrated-command-skills/source-command-pr-review/SKILL.md"); |
| let expected_migrated_skill = "---\nname: \"source-command-pr-review\"\ndescription: \"Review a pull request\"\n---\n\n# source-command-pr-review\n\nUse this skill when the user asks to run the migrated source command `pr-review`.\n\n## Command Template\n\nInspect the proposed changes.\n"; |
| assert_eq!( |
| fs::read_to_string(&migrated_skill).unwrap(), |
| expected_migrated_skill |
| ); |
| assert!( |
| !result |
| .installed_path |
| .join(".codex-plugin/migrated-command-skills/undeclared-command") |
| .exists() |
| ); |
| assert!( |
| !result |
| .installed_path |
| .join(".codex-plugin/migrated-command-skills/source-command-oversized") |
| .exists() |
| ); |
|
|
| let manifest = crate::manifest::load_plugin_manifest(&result.installed_path).unwrap(); |
| let resolved = load_plugin_skill_inventory( |
| &result.installed_path, |
| &PluginIdentity { |
| plugin_id: result.plugin_id.as_key(), |
| remote_plugin_id: None, |
| }, |
| &manifest, |
| PluginManifestFormat::Legacy, |
| None, |
| None, |
| test_skill_root_loader().as_ref(), |
| ) |
| .await |
| .resolve(&SkillConfigRules::default()); |
| assert_eq!( |
| resolved |
| .skills |
| .iter() |
| .map(|skill| skill.path_to_skills_md.clone()) |
| .collect::<Vec<_>>(), |
| vec![ |
| AbsolutePathBuf::from_absolute_path_checked( |
| fs::canonicalize( |
| result |
| .installed_path |
| .join("custom-skills/source-command-pr-review/SKILL.md") |
| ) |
| .unwrap() |
| ) |
| .unwrap(), |
| AbsolutePathBuf::from_absolute_path_checked( |
| fs::canonicalize(result.installed_path.join( |
| ".codex-plugin/migrated-command-skills/source-command-summarize/SKILL.md" |
| )) |
| .unwrap() |
| ) |
| .unwrap() |
| ] |
| ); |
| } |
|
|
| #[test] |
| fn install_plugin_ignores_invalid_commands_manifest_field() { |
| let codex_home = TempDir::new().unwrap(); |
| let source_root = codex_home.path().join("source/sample"); |
| write_file( |
| &source_root.join(".codex-plugin/plugin.json"), |
| r#"{"name":"sample","commands":{}}"#, |
| ); |
| write_file( |
| &source_root.join("commands/review.md"), |
| "---\ndescription: Review\n---\nReview the current change.\n", |
| ); |
|
|
| let result = PluginStore::new(codex_home.path().to_path_buf()) |
| .install( |
| source_root.abs(), |
| PluginId::parse("sample@test").expect("plugin id should parse"), |
| ) |
| .unwrap(); |
|
|
| assert!( |
| !result |
| .installed_path |
| .join(".codex-plugin/migrated-command-skills") |
| .exists() |
| ); |
| } |
|
|
| #[test] |
| fn install_plugin_ignores_command_migration_errors() { |
| let codex_home = TempDir::new().unwrap(); |
| let source_root = codex_home.path().join("source/sample"); |
| write_file( |
| &source_root.join(".codex-plugin/plugin.json"), |
| r#"{"name":"sample","commands":"./commands/review.md"}"#, |
| ); |
| fs::create_dir_all(source_root.join("commands")).unwrap(); |
| fs::write(source_root.join("commands/review.md"), [0xff]).unwrap(); |
|
|
| let result = PluginStore::new(codex_home.path().to_path_buf()) |
| .install( |
| source_root.abs(), |
| PluginId::parse("sample@test").expect("plugin id should parse"), |
| ) |
| .unwrap(); |
|
|
| assert!(result.installed_path.join("commands/review.md").is_file()); |
| } |
|
|
| #[tokio::test] |
| async fn load_plugin_skills_dedupes_overlapping_manifest_roots() { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/sample/local") |
| .abs(); |
| write_file( |
| &plugin_root.join("skills/abc/SKILL.md"), |
| "---\nname: abc\ndescription: abc skill\n---\n", |
| ); |
| write_file( |
| &plugin_root.join("skills/edk/SKILL.md"), |
| "---\nname: edk\ndescription: edk skill\n---\n", |
| ); |
| let manifest = crate::manifest::PluginManifest { |
| name: "sample".to_string(), |
| version: None, |
| description: None, |
| keywords: Vec::new(), |
| paths: crate::manifest::PluginManifestPaths { |
| skills: vec![ |
| plugin_root.join("skills"), |
| plugin_root.join("skills/abc"), |
| plugin_root.join("skills/edk"), |
| plugin_root.join("skills/abc"), |
| ], |
| mcp_servers: None, |
| apps: None, |
| hooks: None, |
| }, |
| interface: None, |
| }; |
| let plugin_id = PluginId::parse("sample@test").expect("plugin id should parse"); |
| let resolved = load_plugin_skill_inventory( |
| &plugin_root, |
| &PluginIdentity { |
| plugin_id: plugin_id.as_key(), |
| remote_plugin_id: None, |
| }, |
| &manifest, |
| PluginManifestFormat::Legacy, |
| None, |
| None, |
| test_skill_root_loader().as_ref(), |
| ) |
| .await |
| .resolve(&SkillConfigRules::default()); |
|
|
| let skill_paths = resolved |
| .skills |
| .iter() |
| .map(|skill| skill.path_to_skills_md.clone()) |
| .collect::<Vec<_>>(); |
| let canonical_skill_path = |path| { |
| AbsolutePathBuf::from_absolute_path_checked( |
| fs::canonicalize(plugin_root.join(path)).expect("canonical skill path"), |
| ) |
| .expect("absolute skill path") |
| }; |
| assert_eq!( |
| skill_paths, |
| vec![ |
| canonical_skill_path("skills/abc/SKILL.md"), |
| canonical_skill_path("skills/edk/SKILL.md") |
| ] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn load_plugins_ignores_manifest_component_paths_without_dot_slash() { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/sample/local"); |
|
|
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| r#"{ |
| "name": "sample", |
| "skills": "custom-skills", |
| "mcpServers": "config/custom.mcp.json", |
| "apps": "config/custom.app.json" |
| }"#, |
| ); |
| write_file( |
| &plugin_root.join("skills/default-skill/SKILL.md"), |
| "---\nname: default-skill\ndescription: default skill\n---\n", |
| ); |
| write_file( |
| &plugin_root.join("custom-skills/custom-skill/SKILL.md"), |
| "---\nname: custom-skill\ndescription: custom skill\n---\n", |
| ); |
| write_file( |
| &plugin_root.join(".mcp.json"), |
| r#"{ |
| "mcpServers": { |
| "default": { |
| "type": "http", |
| "url": "https://default.example/mcp" |
| } |
| } |
| }"#, |
| ); |
| write_file( |
| &plugin_root.join("config/custom.mcp.json"), |
| r#"{ |
| "mcpServers": { |
| "custom": { |
| "type": "http", |
| "url": "https://custom.example/mcp" |
| } |
| } |
| }"#, |
| ); |
| write_file( |
| &plugin_root.join(".app.json"), |
| r#"{ |
| "apps": { |
| "default-app": { |
| "id": "connector_default" |
| } |
| } |
| }"#, |
| ); |
| write_file( |
| &plugin_root.join("config/custom.app.json"), |
| r#"{ |
| "apps": { |
| "custom-app": { |
| "id": "connector_custom" |
| } |
| } |
| }"#, |
| ); |
|
|
| let outcome = load_plugins_from_config( |
| &plugin_config_toml( true, true), |
| codex_home.path(), |
| Some(AuthMode::Chatgpt), |
| ) |
| .await; |
|
|
| assert_eq!( |
| outcome.plugins()[0].skill_roots, |
| vec![plugin_root.join("skills").abs()] |
| ); |
| assert_eq!( |
| outcome.plugins()[0].mcp_servers, |
| HashMap::from([( |
| "default".to_string(), |
| McpServerConfig { |
| auth: Default::default(), |
| transport: McpServerTransportConfig::StreamableHttp { |
| url: "https://default.example/mcp".to_string(), |
| bearer_token_env_var: None, |
| http_headers: None, |
| env_http_headers: None, |
| http_headers_helper: None, |
| }, |
| environment_id: "local".to_string(), |
| enabled: true, |
| required: false, |
| supports_parallel_tool_calls: false, |
| omit_tools_from: None, |
| disabled_reason: None, |
| startup_timeout_sec: None, |
| tool_timeout_sec: None, |
| default_tools_approval_mode: None, |
| enabled_tools: None, |
| disabled_tools: None, |
| scopes: None, |
| oauth: None, |
| oauth_resource: None, |
| tools: HashMap::new(), |
| }, |
| )]) |
| ); |
| assert_eq!( |
| outcome.plugins()[0].apps, |
| vec![app_declaration("default-app", "connector_default")] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn load_plugins_ignores_invalid_manifest_skills_shape() { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/sample/local"); |
|
|
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| r#"{ |
| "name": "sample", |
| "skills": { "path": "./custom-skills/" } |
| }"#, |
| ); |
| write_file( |
| &plugin_root.join("skills/default-skill/SKILL.md"), |
| "---\nname: default-skill\ndescription: default skill\n---\n", |
| ); |
| write_file( |
| &plugin_root.join("custom-skills/custom-skill/SKILL.md"), |
| "---\nname: custom-skill\ndescription: custom skill\n---\n", |
| ); |
|
|
| let outcome = load_plugins_from_config( |
| &plugin_config_toml( true, true), |
| codex_home.path(), |
| None, |
| ) |
| .await; |
|
|
| assert_eq!(outcome.plugins()[0].error, None); |
| assert_eq!( |
| outcome.plugins()[0].skill_roots, |
| vec![plugin_root.join("skills").abs()] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn load_plugins_preserves_disabled_plugins_without_effective_contributions() { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/sample/local"); |
|
|
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| r#"{"name":"sample"}"#, |
| ); |
| write_file( |
| &plugin_root.join(".mcp.json"), |
| r#"{ |
| "mcpServers": { |
| "sample": { |
| "type": "http", |
| "url": "https://sample.example/mcp" |
| } |
| } |
| }"#, |
| ); |
|
|
| let outcome = load_plugins_from_config( |
| &plugin_config_toml( |
| false, true, |
| ), |
| codex_home.path(), |
| None, |
| ) |
| .await; |
|
|
| assert_eq!( |
| outcome.plugins(), |
| vec![LoadedPlugin { |
| config_name: "sample@test".to_string(), |
| remote_plugin_id: None, |
| manifest_name: None, |
| plugin_namespace: None, |
| manifest_description: None, |
| root: AbsolutePathBuf::try_from(plugin_root).unwrap(), |
| enabled: false, |
| skill_roots: Vec::new(), |
| skill_discovery_mode: SkillDiscoveryMode::Recursive, |
| disabled_skill_paths: HashSet::new(), |
| has_enabled_skills: false, |
| mcp_servers: HashMap::new(), |
| apps: Vec::new(), |
| hook_sources: Vec::new(), |
| hook_load_warnings: Vec::new(), |
| error: None, |
| }] |
| ); |
| assert!(outcome.effective_plugin_skill_roots().is_empty()); |
| assert!(outcome.effective_mcp_servers().is_empty()); |
| } |
|
|
| #[tokio::test] |
| async fn effective_apps_dedupes_connector_ids_across_plugins() { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_a_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/plugin-a/local"); |
| let plugin_b_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/plugin-b/local"); |
|
|
| write_file( |
| &plugin_a_root.join(".codex-plugin/plugin.json"), |
| r#"{"name":"plugin-a"}"#, |
| ); |
| write_file( |
| &plugin_a_root.join(".app.json"), |
| r#"{ |
| "apps": { |
| "example": { |
| "id": "connector_example" |
| } |
| } |
| }"#, |
| ); |
| write_file( |
| &plugin_b_root.join(".codex-plugin/plugin.json"), |
| r#"{"name":"plugin-b"}"#, |
| ); |
| write_file( |
| &plugin_b_root.join(".app.json"), |
| r#"{ |
| "apps": { |
| "chat": { |
| "id": "connector_example" |
| }, |
| "gmail": { |
| "id": "connector_gmail" |
| } |
| } |
| }"#, |
| ); |
|
|
| let mut root = toml::map::Map::new(); |
| let mut features = toml::map::Map::new(); |
| features.insert("plugins".to_string(), Value::Boolean(true)); |
| features.insert("apps".to_string(), Value::Boolean(true)); |
| root.insert("features".to_string(), Value::Table(features)); |
|
|
| let mut plugins = toml::map::Map::new(); |
|
|
| let mut plugin_a = toml::map::Map::new(); |
| plugin_a.insert("enabled".to_string(), Value::Boolean(true)); |
| plugins.insert("plugin-a@test".to_string(), Value::Table(plugin_a)); |
|
|
| let mut plugin_b = toml::map::Map::new(); |
| plugin_b.insert("enabled".to_string(), Value::Boolean(true)); |
| plugins.insert("plugin-b@test".to_string(), Value::Table(plugin_b)); |
|
|
| root.insert("plugins".to_string(), Value::Table(plugins)); |
| let config_toml = |
| toml::to_string(&Value::Table(root)).expect("plugin test config should serialize"); |
|
|
| let outcome = |
| load_plugins_from_config(&config_toml, codex_home.path(), Some(AuthMode::Chatgpt)).await; |
|
|
| assert_eq!( |
| outcome.effective_apps(), |
| vec![ |
| AppConnectorId("connector_example".to_string()), |
| AppConnectorId("connector_gmail".to_string()), |
| ] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn effective_apps_preserves_app_config_order() { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/sample/local"); |
|
|
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| r#"{"name":"sample"}"#, |
| ); |
| write_file( |
| &plugin_root.join(".app.json"), |
| r#"{ |
| "apps": { |
| "slack": { |
| "id": "connector_slack" |
| }, |
| "github": { |
| "id": "connector_github" |
| }, |
| "slack-copy": { |
| "id": "connector_slack" |
| } |
| } |
| }"#, |
| ); |
|
|
| let outcome = load_plugins_from_config( |
| &plugin_config_toml( true, true), |
| codex_home.path(), |
| Some(AuthMode::Chatgpt), |
| ) |
| .await; |
|
|
| assert_eq!( |
| outcome.effective_apps(), |
| vec![ |
| AppConnectorId("connector_slack".to_string()), |
| AppConnectorId("connector_github".to_string()), |
| ] |
| ); |
| } |
|
|
| #[test] |
| fn capability_index_filters_inactive_and_zero_capability_plugins() { |
| let codex_home = TempDir::new().unwrap(); |
| let connector = |id: &str| AppConnectorId(id.to_string()); |
| let app = |name: &str, connector_id: &str| app_declaration(name, connector_id); |
| let http_server = |url: &str| McpServerConfig { |
| auth: Default::default(), |
| transport: McpServerTransportConfig::StreamableHttp { |
| url: url.to_string(), |
| bearer_token_env_var: None, |
| http_headers: None, |
| env_http_headers: None, |
| http_headers_helper: None, |
| }, |
| environment_id: "local".to_string(), |
| enabled: true, |
| required: false, |
| supports_parallel_tool_calls: false, |
| omit_tools_from: None, |
| disabled_reason: None, |
| startup_timeout_sec: None, |
| tool_timeout_sec: None, |
| default_tools_approval_mode: None, |
| enabled_tools: None, |
| disabled_tools: None, |
| scopes: None, |
| oauth: None, |
| oauth_resource: None, |
| tools: HashMap::new(), |
| }; |
| let plugin = |config_name: &str, dir_name: &str, manifest_name: &str| LoadedPlugin { |
| config_name: config_name.to_string(), |
| remote_plugin_id: None, |
| manifest_name: Some(manifest_name.to_string()), |
| plugin_namespace: Some( |
| config_name |
| .split_once('@') |
| .map_or(config_name, |(name, _)| name) |
| .to_string(), |
| ), |
| manifest_description: None, |
| root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(), |
| enabled: true, |
| skill_roots: Vec::new(), |
| skill_discovery_mode: SkillDiscoveryMode::Recursive, |
| disabled_skill_paths: HashSet::new(), |
| has_enabled_skills: false, |
| mcp_servers: HashMap::new(), |
| apps: Vec::new(), |
| hook_sources: Vec::new(), |
| hook_load_warnings: Vec::new(), |
| error: None, |
| }; |
| let summary = |config_name: &str, display_name: &str| PluginCapabilitySummary { |
| config_name: config_name.to_string(), |
| display_name: display_name.to_string(), |
| plugin_namespace: Some( |
| config_name |
| .split_once('@') |
| .map_or(config_name, |(name, _)| name) |
| .to_string(), |
| ), |
| description: None, |
| ..PluginCapabilitySummary::default() |
| }; |
| let outcome = PluginLoadOutcome::from_plugins(vec![ |
| LoadedPlugin { |
| skill_roots: vec![codex_home.path().join("skills-plugin/skills").abs()], |
| has_enabled_skills: true, |
| ..plugin("skills@test", "skills-plugin", "skills-plugin") |
| }, |
| LoadedPlugin { |
| mcp_servers: HashMap::from([("alpha".to_string(), http_server("https://alpha"))]), |
| apps: vec![app("example", "connector_example")], |
| ..plugin("alpha@test", "alpha-plugin", "alpha-plugin") |
| }, |
| LoadedPlugin { |
| mcp_servers: HashMap::from([("beta".to_string(), http_server("https://beta"))]), |
| apps: vec![ |
| app("example", "connector_example"), |
| app("gmail", "connector_gmail"), |
| ], |
| ..plugin("beta@test", "beta-plugin", "beta-plugin") |
| }, |
| plugin("empty@test", "empty-plugin", "empty-plugin"), |
| LoadedPlugin { |
| enabled: false, |
| skill_roots: vec![codex_home.path().join("disabled-plugin/skills").abs()], |
| apps: vec![app("hidden", "connector_hidden")], |
| ..plugin("disabled@test", "disabled-plugin", "disabled-plugin") |
| }, |
| LoadedPlugin { |
| apps: vec![app("broken", "connector_broken")], |
| error: Some("failed to load".to_string()), |
| ..plugin("broken@test", "broken-plugin", "broken-plugin") |
| }, |
| ]); |
|
|
| assert_eq!( |
| outcome.capability_summaries(), |
| &[ |
| PluginCapabilitySummary { |
| has_skills: true, |
| ..summary("skills@test", "skills-plugin") |
| }, |
| PluginCapabilitySummary { |
| mcp_server_names: vec!["alpha".to_string()], |
| app_connector_ids: vec![connector("connector_example")], |
| ..summary("alpha@test", "alpha-plugin") |
| }, |
| PluginCapabilitySummary { |
| mcp_server_names: vec!["beta".to_string()], |
| app_connector_ids: vec![ |
| connector("connector_example"), |
| connector("connector_gmail"), |
| ], |
| ..summary("beta@test", "beta-plugin") |
| }, |
| ] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn load_plugins_returns_empty_when_feature_disabled() { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/sample/local"); |
|
|
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| r#"{"name":"sample"}"#, |
| ); |
| write_file( |
| &plugin_root.join("skills/sample-search/SKILL.md"), |
| "---\nname: sample-search\ndescription: search sample data\n---\n", |
| ); |
| write_file( |
| &codex_home.path().join(CONFIG_TOML_FILE), |
| &plugin_config_toml( |
| true, false, |
| ), |
| ); |
|
|
| let config = load_config(codex_home.path(), codex_home.path()).await; |
| let outcome = test_plugins_manager(codex_home.path().to_path_buf()) |
| .plugins_for_config(&config) |
| .await; |
|
|
| assert_eq!(outcome, PluginLoadOutcome::default()); |
| } |
|
|
| #[tokio::test] |
| async fn plugin_cache_reuses_effective_configurations() { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/sample/local"); |
| write_plugin( |
| codex_home.path().join("plugins/cache/test").as_path(), |
| "sample/local", |
| "sample", |
| ); |
| write_file( |
| &plugin_root.join(".mcp.json"), |
| r#"{ |
| "mcpServers": { |
| "sample": { |
| "url": "https://sample.example/mcp" |
| } |
| } |
| }"#, |
| ); |
|
|
| let user_file = codex_home.path().join(CONFIG_TOML_FILE).abs(); |
| let user_config: toml::Value = toml::from_str(&plugin_config_toml( |
| true, true, |
| )) |
| .expect("user config should parse"); |
| let stack = |session_config: &str| { |
| ConfigLayerStack::new( |
| vec![ |
| ConfigLayerEntry::new( |
| ConfigLayerSource::User { |
| file: user_file.clone(), |
| profile: None, |
| }, |
| user_config.clone(), |
| ), |
| ConfigLayerEntry::new( |
| ConfigLayerSource::SessionFlags, |
| toml::from_str(session_config).expect("session config should parse"), |
| ), |
| ], |
| ConfigRequirements::default(), |
| ConfigRequirementsToml::default(), |
| ) |
| .expect("config layer stack should build") |
| }; |
| let config = |session_config| { |
| PluginsConfigInput::new( |
| stack(session_config), |
| String::new(), |
| true, |
| false, |
| "https://chatgpt.com".to_string(), |
| test_http_client_factory(), |
| ) |
| }; |
| let manager = test_plugins_manager(codex_home.path().to_path_buf()); |
|
|
| let first_config = config(r#"model = "first""#); |
| let second_config = config( |
| r#"[plugins."sample@test".mcp_servers.sample] |
| enabled = false"#, |
| ); |
| let first = manager.plugins_for_config(&first_config).await; |
| let first_snapshots = manager |
| .plugin_skill_snapshots_for_config(&first_config) |
| .expect("first configuration snapshots"); |
| let second = manager.plugins_for_config(&second_config).await; |
| let second_snapshots = manager |
| .plugin_skill_snapshots_for_config(&second_config) |
| .expect("second configuration snapshots"); |
| std::fs::remove_file(plugin_root.join(".mcp.json")).unwrap(); |
|
|
| assert_eq!( |
| manager.plugin_skill_snapshots_for_config(&first_config), |
| Some(first_snapshots), |
| ); |
| assert_eq!( |
| manager |
| .plugins_for_config(&config(r#"model = "second""#)) |
| .await, |
| first, |
| ); |
| assert_eq!( |
| manager.plugin_skill_snapshots_for_config(&second_config), |
| Some(second_snapshots), |
| ); |
| assert_eq!(manager.plugins_for_config(&second_config).await, second); |
| manager.clear_cache(); |
| assert_eq!( |
| [first_config, second_config] |
| .iter() |
| .map(|config| manager.plugin_skill_snapshots_for_config(config)) |
| .collect::<Vec<_>>(), |
| vec![None, None], |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn skill_roots_resolve_remote_plugin_identity_from_authoritative_source() { |
| let mut duplicate_plugin = remote_installed_plugin("sample"); |
| duplicate_plugin.id = "plugins~Plugin_duplicate".to_string(); |
|
|
| for (installed_plugins, expected_remote_plugin_id) in [ |
| (None, Some("plugins~Plugin_persisted")), |
| (Some(Vec::new()), None), |
| ( |
| Some(vec![remote_installed_plugin("sample"), duplicate_plugin]), |
| Some("plugins~Plugin_sample"), |
| ), |
| ] { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache/openai-curated-remote/sample/local"); |
| write_plugin( |
| codex_home |
| .path() |
| .join("plugins/cache/openai-curated-remote") |
| .as_path(), |
| "sample/local", |
| "sample", |
| ); |
| write_file( |
| &plugin_root.join("skills/SKILL.md"), |
| "---\nname: search\ndescription: first\n---\n", |
| ); |
| write_file( |
| &codex_home.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| remote_plugin = true |
| |
| [plugins."sample@openai-curated-remote"] |
| enabled = true |
| "#, |
| ); |
|
|
| let plugin_id = |
| PluginId::parse("sample@openai-curated-remote").expect("remote plugin id should parse"); |
| PluginStore::new(codex_home.path().to_path_buf()) |
| .write_remote_plugin_id(&plugin_id, "plugins~Plugin_persisted") |
| .expect("persist remote plugin id"); |
| let config = load_config(codex_home.path(), codex_home.path()).await; |
| let manager = test_plugins_manager_with_options( |
| codex_home.path().to_path_buf(), |
| Some(Product::Codex), |
| Some(AuthMode::Chatgpt), |
| ); |
| if let Some(installed_plugins) = installed_plugins { |
| manager.write_remote_installed_plugins_cache(installed_plugins); |
| } |
|
|
| let plugin_outcome = manager.plugins_for_config(&config).await; |
| assert_eq!( |
| manager |
| .telemetry_metadata_for_plugin_id(&plugin_id) |
| .remote_plugin_id |
| .as_deref(), |
| expected_remote_plugin_id |
| ); |
| assert_eq!( |
| plugin_outcome |
| .effective_plugin_skill_roots() |
| .into_iter() |
| .map(|root| { |
| ( |
| root.plugin_identity.plugin_id, |
| root.plugin_identity.remote_plugin_id, |
| ) |
| }) |
| .collect::<Vec<_>>(), |
| vec![( |
| "sample@openai-curated-remote".to_string(), |
| expected_remote_plugin_id.map(str::to_string), |
| )], |
| ); |
| } |
| } |
|
|
| #[tokio::test] |
| async fn connector_snapshot_combines_plugin_exclusions_with_current_account_ownership() { |
| let codex_home = TempDir::new().unwrap(); |
| let auth_manager = test_auth_manager(Some(AuthMode::Chatgpt)); |
| let manager = test_plugins_manager_with_auth_manager( |
| codex_home.path().to_path_buf(), |
| Some(Product::Codex), |
| Arc::clone(&auth_manager), |
| ); |
| let sources = [PluginConnectorSource::from_connector_ids( |
| "local@test", |
| "Local", |
| [AppConnectorId("local-connector".to_string())], |
| )]; |
| let disabled = vec![ |
| "linear@openai-curated-remote".to_string(), |
| "local@test".to_string(), |
| ]; |
| let local_exclusion = HashSet::from(["local-connector".to_string()]); |
| assert_eq!( |
| manager |
| .connector_snapshot(sources.clone(), &disabled) |
| .disabled_connector_ids(), |
| &local_exclusion, |
| ); |
| let mut plugin = remote_installed_linear_plugin(); |
| plugin.canonical_app_id = Some("linear".to_string()); |
| manager.write_remote_installed_plugins_cache(vec![plugin]); |
| assert_eq!( |
| manager |
| .connector_snapshot(sources.clone(), &disabled) |
| .disabled_connector_ids(), |
| &HashSet::from(["linear".to_string(), "local-connector".to_string()]), |
| ); |
| assert!( |
| manager |
| .connector_snapshot(sources.clone(), &["linear@another-marketplace".to_string()]) |
| .disabled_connector_ids() |
| .is_empty() |
| ); |
| set_test_auth_mode(&auth_manager, Some(AuthMode::ApiKey)).await; |
| assert_eq!( |
| manager |
| .connector_snapshot(sources, &disabled) |
| .disabled_connector_ids(), |
| &local_exclusion, |
| ); |
| } |
|
|
| #[test] |
| fn loaded_plugins_cache_evicts_least_recently_used_configuration() { |
| let codex_home = TempDir::new().unwrap(); |
| let manager = test_plugins_manager(codex_home.path().to_path_buf()); |
| let keys = (0..=LOADED_PLUGINS_CACHE_CAPACITY) |
| .map(|index| PluginLoadCacheKey { |
| configured_plugins: HashMap::from([( |
| format!("plugin-{index}@test"), |
| PluginConfig { |
| enabled: true, |
| mcp_servers: HashMap::new(), |
| }, |
| )]), |
| skill_config_rules: SkillConfigRules::default(), |
| remote_global_catalog_active: false, |
| auth_identity: None, |
| excluded_plugin_ids: BTreeSet::new(), |
| }) |
| .collect::<Vec<_>>(); |
| let generation = manager.loaded_plugins_cache_generation(); |
| for key in keys.iter().take(LOADED_PLUGINS_CACHE_CAPACITY) { |
| manager.cache_loaded_plugins_if_current( |
| generation, |
| key.clone(), |
| Vec::new(), |
| crate::skill_snapshots::new_plugin_skill_snapshots(), |
| ); |
| } |
| manager.cache_loaded_plugins_if_current( |
| generation, |
| keys[LOADED_PLUGINS_CACHE_CAPACITY - 1].clone(), |
| Vec::new(), |
| crate::skill_snapshots::new_plugin_skill_snapshots(), |
| ); |
| assert_eq!(manager.cached_loaded_plugins(&keys[0]), Some(Vec::new())); |
| manager.cache_loaded_plugins_if_current( |
| generation, |
| keys[LOADED_PLUGINS_CACHE_CAPACITY].clone(), |
| Vec::new(), |
| crate::skill_snapshots::new_plugin_skill_snapshots(), |
| ); |
|
|
| assert_eq!( |
| keys.iter() |
| .map(|key| manager.cached_loaded_plugins(key).is_some()) |
| .collect::<Vec<_>>(), |
| (0..=LOADED_PLUGINS_CACHE_CAPACITY) |
| .map(|index| index != 1) |
| .collect::<Vec<_>>(), |
| ); |
| } |
|
|
| #[test] |
| fn loaded_plugins_cache_invalidation_rejects_stale_load_completion() { |
| let codex_home = TempDir::new().unwrap(); |
| let manager = test_plugins_manager(codex_home.path().to_path_buf()); |
| let cache_key = PluginLoadCacheKey { |
| configured_plugins: HashMap::new(), |
| skill_config_rules: SkillConfigRules::default(), |
| remote_global_catalog_active: false, |
| auth_identity: None, |
| excluded_plugin_ids: BTreeSet::new(), |
| }; |
| let stale_generation = manager.loaded_plugins_cache_generation(); |
|
|
| manager.clear_loaded_plugins_cache(); |
| manager.cache_loaded_plugins_if_current( |
| stale_generation, |
| cache_key.clone(), |
| Vec::new(), |
| crate::skill_snapshots::new_plugin_skill_snapshots(), |
| ); |
|
|
| assert_eq!(manager.cached_loaded_plugins(&cache_key), None); |
| } |
|
|
| #[tokio::test] |
| async fn plugins_for_config_discards_in_flight_load_after_account_change() { |
| let codex_home = TempDir::new().unwrap(); |
| write_cached_plugin( |
| codex_home.path(), |
| REMOTE_GLOBAL_MARKETPLACE_NAME, |
| "remote-only", |
| ); |
| let config = PluginsConfigInput::new( |
| unrestricted_config_layer_stack(), |
| String::new(), |
| true, |
| true, |
| String::new(), |
| test_http_client_factory(), |
| ); |
| let auth_manager = test_auth_manager(Some(AuthMode::ChatgptAuthTokens)); |
| let manager = Arc::new(test_plugins_manager_with_auth_manager( |
| codex_home.path().to_path_buf(), |
| Some(Product::Codex), |
| Arc::clone(&auth_manager), |
| )); |
| manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("remote-only")]); |
| let account_a = auth_manager |
| .auth_cached() |
| .expect("capture first ChatGPT auth"); |
|
|
| let load_permit = manager |
| .loaded_plugins_load_semaphore |
| .try_acquire() |
| .expect("hold plugin load semaphore"); |
| let load = manager.plugins_for_config(&config); |
| tokio::pin!(load); |
| assert!(matches!( |
| futures::poll!(load.as_mut()), |
| std::task::Poll::Pending |
| )); |
| let account_b = CodexAuth::from_external_chatgpt_tokens( |
| "header.e30.other", |
| "other-account", |
| None, |
| ) |
| .expect("build second ChatGPT auth"); |
| set_test_auth(&auth_manager, Some(account_b)).await; |
| drop(load_permit); |
| assert_eq!(load.await, PluginLoadOutcome::default()); |
|
|
| set_test_auth(&auth_manager, Some(account_a)).await; |
| assert_eq!( |
| loaded_plugin_names(&manager, &config).await, |
| vec!["remote-only@openai-curated-remote".to_string()] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn load_plugins_rejects_invalid_plugin_keys() { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/sample/local"); |
|
|
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| r#"{"name":"sample"}"#, |
| ); |
|
|
| let mut root = toml::map::Map::new(); |
| let mut features = toml::map::Map::new(); |
| features.insert("plugins".to_string(), Value::Boolean(true)); |
| root.insert("features".to_string(), Value::Table(features)); |
|
|
| let mut plugin = toml::map::Map::new(); |
| plugin.insert("enabled".to_string(), Value::Boolean(true)); |
|
|
| let mut plugins = toml::map::Map::new(); |
| plugins.insert("sample".to_string(), Value::Table(plugin)); |
| root.insert("plugins".to_string(), Value::Table(plugins)); |
|
|
| let outcome = load_plugins_from_config( |
| &toml::to_string(&Value::Table(root)).expect("plugin test config should serialize"), |
| codex_home.path(), |
| None, |
| ) |
| .await; |
|
|
| assert_eq!(outcome.plugins().len(), 1); |
| assert_eq!( |
| outcome.plugins()[0].error.as_deref(), |
| Some("invalid plugin key `sample`; expected <plugin>@<marketplace>") |
| ); |
| assert!(outcome.effective_plugin_skill_roots().is_empty()); |
| assert!(outcome.effective_mcp_servers().is_empty()); |
| } |
|
|
| #[tokio::test] |
| async fn install_plugin_updates_config_with_relative_path_and_plugin_key() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); |
| write_plugin(&repo_root, "sample-plugin", "sample-plugin"); |
| fs::write( |
| repo_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "sample-plugin", |
| "source": { |
| "source": "local", |
| "path": "./sample-plugin" |
| }, |
| "policy": { |
| "authentication": "ON_USE" |
| } |
| } |
| ] |
| }"#, |
| ) |
| .unwrap(); |
|
|
| let result = test_plugins_manager(tmp.path().to_path_buf()) |
| .install_plugin( |
| &unrestricted_plugins_config_input(), |
| PluginInstallRequest { |
| plugin_name: "sample-plugin".to_string(), |
| marketplace_path: AbsolutePathBuf::try_from( |
| repo_root.join(".agents/plugins/marketplace.json"), |
| ) |
| .unwrap(), |
| }, |
| ) |
| .await |
| .unwrap(); |
|
|
| let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local"); |
| assert_eq!( |
| result, |
| PluginInstallOutcome { |
| plugin_id: PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(), |
| plugin_version: "local".to_string(), |
| installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(), |
| auth_policy: MarketplacePluginAuthPolicy::OnUse, |
| } |
| ); |
|
|
| let config = fs::read_to_string(tmp.path().join("config.toml")).unwrap(); |
| assert!(config.contains(r#"[plugins."sample-plugin@debug"]"#)); |
| assert!(config.contains("enabled = true")); |
| } |
|
|
| #[tokio::test] |
| async fn strict_install_requires_allowed_local_marketplace_to_be_added_first() { |
| let codex_home = TempDir::new().expect("create Codex home"); |
| let marketplace_root = codex_home.path().join("company-marketplace"); |
| write_plugin(&marketplace_root, "sample", "sample"); |
| write_file( |
| &marketplace_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "company", |
| "plugins": [ |
| { |
| "name": "sample", |
| "source": {"source": "local", "path": "./sample"} |
| } |
| ] |
| }"#, |
| ); |
| let marketplace_root = marketplace_root |
| .canonicalize() |
| .expect("canonical marketplace root"); |
| let requirements = format!( |
| r#" |
| [marketplaces] |
| restrict_to_allowed_sources = true |
| |
| [marketplaces.allowed_sources.company] |
| source = "local" |
| path = {marketplace_root:?} |
| "# |
| ); |
| let config = plugins_config_input_with_requirements(codex_home.path(), "", &requirements); |
| let marketplace_path = |
| AbsolutePathBuf::try_from(marketplace_root.join(".agents/plugins/marketplace.json")) |
| .expect("absolute marketplace path"); |
| let manager = test_plugins_manager(codex_home.path().to_path_buf()); |
|
|
| let err = manager |
| .install_plugin( |
| &config, |
| PluginInstallRequest { |
| plugin_name: "sample".to_string(), |
| marketplace_path: marketplace_path.clone(), |
| }, |
| ) |
| .await |
| .expect_err("unconfigured local marketplace should not be installable in strict mode"); |
| assert!(matches!( |
| err, |
| PluginInstallError::Marketplace(MarketplaceError::InvalidMarketplaceFile { .. }) |
| )); |
| assert!(err.to_string().contains("must be added to config")); |
| assert!(!codex_home.path().join(CONFIG_TOML_FILE).exists()); |
|
|
| let user_config = format!( |
| r#" |
| [marketplaces.company] |
| source_type = "local" |
| source = {marketplace_root:?} |
| "# |
| ); |
| write_file(&codex_home.path().join(CONFIG_TOML_FILE), &user_config); |
| let config = |
| plugins_config_input_with_requirements(codex_home.path(), &user_config, &requirements); |
| let outcome = manager |
| .install_plugin( |
| &config, |
| PluginInstallRequest { |
| plugin_name: "sample".to_string(), |
| marketplace_path, |
| }, |
| ) |
| .await |
| .expect("configured allowlisted marketplace should be installable"); |
| assert_eq!( |
| outcome.plugin_id, |
| PluginId::new("sample".to_string(), "company".to_string()).expect("plugin id") |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn install_openai_curated_plugin_uses_short_sha_cache_version() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let curated_root = curated_plugins_repo_path(tmp.path()); |
| write_openai_curated_marketplace(&curated_root, &["slack"]); |
| write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); |
|
|
| let result = test_plugins_manager(tmp.path().to_path_buf()) |
| .install_plugin( |
| &unrestricted_plugins_config_input(), |
| PluginInstallRequest { |
| plugin_name: "slack".to_string(), |
| marketplace_path: AbsolutePathBuf::try_from( |
| curated_root.join(".agents/plugins/marketplace.json"), |
| ) |
| .unwrap(), |
| }, |
| ) |
| .await |
| .unwrap(); |
|
|
| let installed_path = tmp.path().join(format!( |
| "plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}" |
| )); |
| assert_eq!( |
| result, |
| PluginInstallOutcome { |
| plugin_id: PluginId::new( |
| "slack".to_string(), |
| OPENAI_CURATED_MARKETPLACE_NAME.to_string() |
| ) |
| .unwrap(), |
| plugin_version: TEST_CURATED_PLUGIN_CACHE_VERSION.to_string(), |
| installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(), |
| auth_policy: MarketplacePluginAuthPolicy::OnInstall, |
| } |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn install_plugin_uses_manifest_version_for_non_curated_plugins() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); |
| write_plugin_with_version( |
| &repo_root, |
| "sample-plugin", |
| "sample-plugin", |
| Some("1.2.3-beta+7"), |
| ); |
| fs::write( |
| repo_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "sample-plugin", |
| "source": { |
| "source": "local", |
| "path": "./sample-plugin" |
| } |
| } |
| ] |
| }"#, |
| ) |
| .unwrap(); |
|
|
| let result = test_plugins_manager(tmp.path().to_path_buf()) |
| .install_plugin( |
| &unrestricted_plugins_config_input(), |
| PluginInstallRequest { |
| plugin_name: "sample-plugin".to_string(), |
| marketplace_path: AbsolutePathBuf::try_from( |
| repo_root.join(".agents/plugins/marketplace.json"), |
| ) |
| .unwrap(), |
| }, |
| ) |
| .await |
| .unwrap(); |
|
|
| let installed_path = tmp |
| .path() |
| .join("plugins/cache/debug/sample-plugin/1.2.3-beta+7"); |
| assert_eq!( |
| result, |
| PluginInstallOutcome { |
| plugin_id: PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(), |
| plugin_version: "1.2.3-beta+7".to_string(), |
| installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(), |
| auth_policy: MarketplacePluginAuthPolicy::OnInstall, |
| } |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn install_plugin_writes_marketplace_manifest_fallback_when_missing_plugin_json() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| let plugin_root = repo_root.join("plugins/quality-review"); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); |
| fs::create_dir_all(plugin_root.join("skills/thermo-nuclear-code-quality-review")).unwrap(); |
| fs::write( |
| plugin_root.join("skills/thermo-nuclear-code-quality-review/SKILL.md"), |
| "review skill", |
| ) |
| .unwrap(); |
| write_file( |
| &plugin_root.join("commands/review.md"), |
| "---\ndescription: Review code\n---\nReview the current change.\n", |
| ); |
| fs::write( |
| repo_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "quality-review", |
| "description": "Strict code quality review focused on maintainability.", |
| "source": "./plugins/quality-review", |
| "author": { |
| "name": "Byron Grogan" |
| }, |
| "skills": [ |
| "./skills/thermo-nuclear-code-quality-review" |
| ], |
| "commands": ["./commands/review.md"], |
| "category": "code-review" |
| } |
| ] |
| }"#, |
| ) |
| .unwrap(); |
|
|
| let result = test_plugins_manager(tmp.path().to_path_buf()) |
| .install_plugin( |
| &unrestricted_plugins_config_input(), |
| PluginInstallRequest { |
| plugin_name: "quality-review".to_string(), |
| marketplace_path: AbsolutePathBuf::try_from( |
| repo_root.join(".agents/plugins/marketplace.json"), |
| ) |
| .unwrap(), |
| }, |
| ) |
| .await |
| .unwrap(); |
|
|
| let installed_path = tmp.path().join("plugins/cache/debug/quality-review/local"); |
| assert_eq!( |
| result, |
| PluginInstallOutcome { |
| plugin_id: PluginId::new("quality-review".to_string(), "debug".to_string()).unwrap(), |
| plugin_version: "local".to_string(), |
| installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(), |
| auth_policy: MarketplacePluginAuthPolicy::OnInstall, |
| } |
| ); |
| assert!(!plugin_root.join(".codex-plugin/plugin.json").exists()); |
| assert!( |
| !tmp.path() |
| .join("plugins/.marketplace-plugin-source-staging") |
| .exists() |
| ); |
|
|
| let manifest = crate::manifest::load_plugin_manifest(&installed_path).unwrap(); |
| assert_eq!(manifest.name, "quality-review"); |
| assert_eq!( |
| manifest.description.as_deref(), |
| Some("Strict code quality review focused on maintainability.") |
| ); |
| assert_eq!( |
| manifest.paths.skills, |
| vec![ |
| AbsolutePathBuf::try_from( |
| installed_path.join("skills/thermo-nuclear-code-quality-review") |
| ) |
| .unwrap() |
| ] |
| ); |
| let interface = manifest.interface.expect("fallback interface"); |
| assert_eq!(interface.developer_name.as_deref(), Some("Byron Grogan")); |
| assert_eq!(interface.category.as_deref(), Some("code-review")); |
| let fallback_json: serde_json::Value = serde_json::from_str( |
| &fs::read_to_string(installed_path.join(".codex-plugin/plugin.json")).unwrap(), |
| ) |
| .unwrap(); |
| assert_eq!( |
| fallback_json["author"], |
| serde_json::json!({ "name": "Byron Grogan" }) |
| ); |
| assert_eq!(fallback_json["category"], "code-review"); |
| assert_eq!( |
| fs::read_to_string( |
| installed_path |
| .join(".codex-plugin/migrated-command-skills/source-command-review/SKILL.md") |
| ) |
| .unwrap(), |
| "---\nname: \"source-command-review\"\ndescription: \"Review code\"\n---\n\n# source-command-review\n\nUse this skill when the user asks to run the migrated source command `review`.\n\n## Command Template\n\nReview the current change.\n" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn install_plugin_supports_git_subdir_marketplace_sources() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("marketplace"); |
| let remote_repo = tmp.path().join("remote-plugin-repo"); |
| let remote_repo_url = url::Url::from_directory_path(&remote_repo) |
| .unwrap() |
| .to_string(); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); |
| write_plugin(&remote_repo, "plugins/toolkit", "toolkit"); |
| init_git_repo(&remote_repo); |
| fs::write( |
| repo_root.join(".agents/plugins/marketplace.json"), |
| format!( |
| r#"{{ |
| "name": "debug", |
| "plugins": [ |
| {{ |
| "name": "toolkit", |
| "source": {{ |
| "source": "git-subdir", |
| "url": "{remote_repo_url}", |
| "path": "plugins/toolkit" |
| }} |
| }} |
| ] |
| }}"# |
| ), |
| ) |
| .unwrap(); |
|
|
| let result = test_plugins_manager(tmp.path().to_path_buf()) |
| .install_plugin( |
| &unrestricted_plugins_config_input(), |
| PluginInstallRequest { |
| plugin_name: "toolkit".to_string(), |
| marketplace_path: AbsolutePathBuf::try_from( |
| repo_root.join(".agents/plugins/marketplace.json"), |
| ) |
| .unwrap(), |
| }, |
| ) |
| .await |
| .unwrap(); |
|
|
| let installed_path = tmp.path().join("plugins/cache/debug/toolkit/local"); |
| assert_eq!( |
| result, |
| PluginInstallOutcome { |
| plugin_id: PluginId::new("toolkit".to_string(), "debug".to_string()).unwrap(), |
| plugin_version: "local".to_string(), |
| installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(), |
| auth_policy: MarketplacePluginAuthPolicy::OnInstall, |
| } |
| ); |
| assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); |
| } |
|
|
| #[tokio::test] |
| async fn install_plugin_supports_relative_git_subdir_marketplace_sources() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("marketplace"); |
| let remote_repo = repo_root.join("remote-plugin-repo"); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); |
| write_plugin(&remote_repo, "plugins/toolkit", "toolkit"); |
| init_git_repo(&remote_repo); |
| fs::write( |
| repo_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "toolkit", |
| "source": { |
| "source": "git-subdir", |
| "url": "./remote-plugin-repo", |
| "path": "plugins/toolkit" |
| } |
| } |
| ] |
| }"#, |
| ) |
| .unwrap(); |
|
|
| let result = test_plugins_manager(tmp.path().to_path_buf()) |
| .install_plugin( |
| &unrestricted_plugins_config_input(), |
| PluginInstallRequest { |
| plugin_name: "toolkit".to_string(), |
| marketplace_path: AbsolutePathBuf::try_from( |
| repo_root.join(".agents/plugins/marketplace.json"), |
| ) |
| .unwrap(), |
| }, |
| ) |
| .await |
| .unwrap(); |
|
|
| let installed_path = tmp.path().join("plugins/cache/debug/toolkit/local"); |
| assert_eq!( |
| result, |
| PluginInstallOutcome { |
| plugin_id: PluginId::new("toolkit".to_string(), "debug".to_string()).unwrap(), |
| plugin_version: "local".to_string(), |
| installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(), |
| auth_policy: MarketplacePluginAuthPolicy::OnInstall, |
| } |
| ); |
| assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); |
| } |
|
|
| #[tokio::test] |
| async fn uninstall_plugin_removes_cache_and_config_entry() { |
| let tmp = tempfile::tempdir().unwrap(); |
| write_plugin( |
| &tmp.path().join("plugins/cache/debug"), |
| "sample-plugin/local", |
| "sample-plugin", |
| ); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| |
| [plugins."sample-plugin@debug"] |
| enabled = true |
| "#, |
| ); |
|
|
| let manager = test_plugins_manager(tmp.path().to_path_buf()); |
| manager |
| .uninstall_plugin("sample-plugin@debug".to_string()) |
| .await |
| .unwrap(); |
| manager |
| .uninstall_plugin("sample-plugin@debug".to_string()) |
| .await |
| .unwrap(); |
|
|
| assert!( |
| !tmp.path() |
| .join("plugins/cache/debug/sample-plugin") |
| .exists() |
| ); |
| let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); |
| assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#)); |
| } |
|
|
| #[tokio::test] |
| async fn list_marketplaces_includes_enabled_state() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); |
| write_plugin( |
| &tmp.path().join("plugins/cache/debug"), |
| "enabled-plugin/local", |
| "enabled-plugin", |
| ); |
| write_plugin( |
| &tmp.path().join("plugins/cache/debug"), |
| "disabled-plugin/local", |
| "disabled-plugin", |
| ); |
| fs::write( |
| repo_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "enabled-plugin", |
| "source": { |
| "source": "local", |
| "path": "./enabled-plugin" |
| } |
| }, |
| { |
| "name": "disabled-plugin", |
| "source": { |
| "source": "local", |
| "path": "./disabled-plugin" |
| } |
| } |
| ] |
| }"#, |
| ) |
| .unwrap(); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| |
| [plugins."enabled-plugin@debug"] |
| enabled = true |
| |
| [plugins."disabled-plugin@debug"] |
| enabled = false |
| "#, |
| ); |
|
|
| let config = load_config(tmp.path(), &repo_root).await; |
| let marketplaces = test_plugins_manager(tmp.path().to_path_buf()) |
| .list_marketplaces_for_config( |
| &config, |
| &[AbsolutePathBuf::try_from(repo_root).unwrap()], |
| true, |
| ) |
| .unwrap() |
| .marketplaces; |
|
|
| let marketplace = marketplaces |
| .into_iter() |
| .find(|marketplace| { |
| marketplace.path |
| == AbsolutePathBuf::try_from( |
| tmp.path().join("repo/.agents/plugins/marketplace.json"), |
| ) |
| .unwrap() |
| }) |
| .expect("expected repo marketplace entry"); |
|
|
| assert_eq!( |
| marketplace, |
| ConfiguredMarketplace { |
| name: "debug".to_string(), |
| path: AbsolutePathBuf::try_from( |
| tmp.path().join("repo/.agents/plugins/marketplace.json"), |
| ) |
| .unwrap(), |
| interface: None, |
| plugins: vec![ |
| ConfiguredMarketplacePlugin { |
| id: "enabled-plugin@debug".to_string(), |
| name: "enabled-plugin".to_string(), |
| local_version: None, |
| installed_version: Some("local".to_string()), |
| source: MarketplacePluginSource::Local { |
| path: AbsolutePathBuf::try_from(tmp.path().join("repo/enabled-plugin")) |
| .unwrap(), |
| }, |
| policy: MarketplacePluginPolicy { |
| installation: MarketplacePluginInstallPolicy::Available, |
| authentication: MarketplacePluginAuthPolicy::OnInstall, |
| products: None, |
| }, |
| interface: None, |
| keywords: Vec::new(), |
| manifest_fallback: None, |
| installed: true, |
| enabled: true, |
| }, |
| ConfiguredMarketplacePlugin { |
| id: "disabled-plugin@debug".to_string(), |
| name: "disabled-plugin".to_string(), |
| local_version: None, |
| installed_version: Some("local".to_string()), |
| source: MarketplacePluginSource::Local { |
| path: AbsolutePathBuf::try_from(tmp.path().join("repo/disabled-plugin"),) |
| .unwrap(), |
| }, |
| policy: MarketplacePluginPolicy { |
| installation: MarketplacePluginInstallPolicy::Available, |
| authentication: MarketplacePluginAuthPolicy::OnInstall, |
| products: None, |
| }, |
| interface: None, |
| keywords: Vec::new(), |
| manifest_fallback: None, |
| installed: true, |
| enabled: false, |
| }, |
| ], |
| } |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn list_marketplaces_returns_empty_when_feature_disabled() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); |
| fs::write( |
| repo_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "enabled-plugin", |
| "source": { |
| "source": "local", |
| "path": "./enabled-plugin" |
| } |
| } |
| ] |
| }"#, |
| ) |
| .unwrap(); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = false |
| |
| [plugins."enabled-plugin@debug"] |
| enabled = true |
| "#, |
| ); |
|
|
| let config = load_config(tmp.path(), &repo_root).await; |
| let marketplaces = test_plugins_manager(tmp.path().to_path_buf()) |
| .list_marketplaces_for_config( |
| &config, |
| &[AbsolutePathBuf::try_from(repo_root).unwrap()], |
| true, |
| ) |
| .unwrap() |
| .marketplaces; |
|
|
| assert_eq!(marketplaces, Vec::new()); |
| } |
|
|
| #[tokio::test] |
| async fn list_marketplaces_excludes_plugins_with_explicit_empty_products() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); |
| fs::write( |
| repo_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "disabled-plugin", |
| "source": { |
| "source": "local", |
| "path": "./disabled-plugin" |
| }, |
| "policy": { |
| "products": [] |
| } |
| }, |
| { |
| "name": "default-plugin", |
| "source": { |
| "source": "local", |
| "path": "./default-plugin" |
| } |
| } |
| ] |
| }"#, |
| ) |
| .unwrap(); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| "#, |
| ); |
|
|
| let config = load_config(tmp.path(), &repo_root).await; |
| let marketplaces = test_plugins_manager(tmp.path().to_path_buf()) |
| .list_marketplaces_for_config( |
| &config, |
| &[AbsolutePathBuf::try_from(repo_root).unwrap()], |
| true, |
| ) |
| .unwrap() |
| .marketplaces; |
|
|
| let marketplace = marketplaces |
| .into_iter() |
| .find(|marketplace| { |
| marketplace.path |
| == AbsolutePathBuf::try_from( |
| tmp.path().join("repo/.agents/plugins/marketplace.json"), |
| ) |
| .unwrap() |
| }) |
| .expect("expected repo marketplace entry"); |
| assert_eq!( |
| marketplace.plugins, |
| vec![ConfiguredMarketplacePlugin { |
| id: "default-plugin@debug".to_string(), |
| name: "default-plugin".to_string(), |
| local_version: None, |
| installed_version: None, |
| source: MarketplacePluginSource::Local { |
| path: AbsolutePathBuf::try_from(tmp.path().join("repo/default-plugin")).unwrap(), |
| }, |
| policy: MarketplacePluginPolicy { |
| installation: MarketplacePluginInstallPolicy::Available, |
| authentication: MarketplacePluginAuthPolicy::OnInstall, |
| products: None, |
| }, |
| interface: None, |
| keywords: Vec::new(), |
| manifest_fallback: None, |
| installed: false, |
| enabled: false, |
| }] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn read_plugin_for_config_returns_plugins_disabled_when_feature_disabled() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); |
| let marketplace_path = |
| AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(); |
| fs::write( |
| marketplace_path.as_path(), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "enabled-plugin", |
| "source": { |
| "source": "local", |
| "path": "./enabled-plugin" |
| } |
| } |
| ] |
| }"#, |
| ) |
| .unwrap(); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = false |
| |
| [plugins."enabled-plugin@debug"] |
| enabled = true |
| "#, |
| ); |
|
|
| let config = load_config(tmp.path(), &repo_root).await; |
| let err = test_plugins_manager(tmp.path().to_path_buf()) |
| .read_plugin_for_config( |
| &config, |
| &PluginReadRequest { |
| plugin_name: "enabled-plugin".to_string(), |
| marketplace_path, |
| }, |
| ) |
| .await |
| .unwrap_err(); |
|
|
| assert!(matches!(err, MarketplaceError::PluginsDisabled)); |
| } |
|
|
| #[tokio::test] |
| async fn read_plugin_for_config_filters_mcp_servers_for_codex_backend_auth() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); |
| write_file( |
| &repo_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "sample-plugin", |
| "source": { |
| "source": "local", |
| "path": "./sample-plugin" |
| } |
| } |
| ] |
| }"#, |
| ); |
| write_file( |
| &repo_root.join("sample-plugin/.codex-plugin/plugin.json"), |
| r#"{"name":"sample-plugin"}"#, |
| ); |
| write_file( |
| &repo_root.join("sample-plugin/.app.json"), |
| r#"{"apps":{"sample-mcp":{"id":"connector_sample"}}}"#, |
| ); |
| write_file( |
| &repo_root.join("sample-plugin/.mcp.json"), |
| r#"{"mcpServers":{"other-mcp":{"command":"other-mcp"},"sample-mcp":{"command":"sample-mcp"}}}"#, |
| ); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| "#, |
| ); |
|
|
| let config = load_config(tmp.path(), &repo_root).await; |
| let request = PluginReadRequest { |
| plugin_name: "sample-plugin".to_string(), |
| marketplace_path: AbsolutePathBuf::try_from( |
| repo_root.join(".agents/plugins/marketplace.json"), |
| ) |
| .unwrap(), |
| }; |
|
|
| let chatgpt_outcome = test_plugins_manager_with_options( |
| tmp.path().to_path_buf(), |
| Some(Product::Codex), |
| Some(AuthMode::Chatgpt), |
| ) |
| .read_plugin_for_config(&config, &request) |
| .await |
| .unwrap(); |
| assert_eq!( |
| chatgpt_outcome.plugin.mcp_server_names, |
| vec!["other-mcp".to_string()] |
| ); |
| assert_eq!( |
| chatgpt_outcome.plugin.apps, |
| vec![AppConnectorId("connector_sample".to_string())] |
| ); |
|
|
| let api_key_outcome = test_plugins_manager_with_options( |
| tmp.path().to_path_buf(), |
| Some(Product::Codex), |
| Some(AuthMode::ApiKey), |
| ) |
| .read_plugin_for_config(&config, &request) |
| .await |
| .unwrap(); |
| assert_eq!( |
| api_key_outcome.plugin.mcp_server_names, |
| vec!["other-mcp".to_string(), "sample-mcp".to_string()] |
| ); |
| assert!(api_key_outcome.plugin.apps.is_empty()); |
|
|
| let no_auth_outcome = test_plugins_manager_with_options( |
| tmp.path().to_path_buf(), |
| Some(Product::Codex), |
| None, |
| ) |
| .read_plugin_for_config(&config, &request) |
| .await |
| .unwrap(); |
| assert_eq!( |
| no_auth_outcome.plugin.mcp_server_names, |
| vec!["other-mcp".to_string(), "sample-mcp".to_string()] |
| ); |
| assert!(no_auth_outcome.plugin.apps.is_empty()); |
| } |
|
|
| #[tokio::test] |
| async fn read_plugin_for_config_uses_marketplace_manifest_fallback_paths_for_local_source() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| let plugin_root = repo_root.join("sample-plugin"); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); |
| write_file( |
| &repo_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "sample-plugin", |
| "source": "./sample-plugin", |
| "apps": "./config/custom.app.json", |
| "mcpServers": { |
| "sample-mcp": { |
| "command": "sample-mcp" |
| } |
| } |
| } |
| ] |
| }"#, |
| ); |
| write_file( |
| &plugin_root.join("config/custom.app.json"), |
| r#"{"apps":{"sample-app":{"id":"connector_sample"}}}"#, |
| ); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| "#, |
| ); |
|
|
| let config = load_config(tmp.path(), &repo_root).await; |
| let manager = test_plugins_manager_with_options( |
| tmp.path().to_path_buf(), |
| Some(Product::Codex), |
| Some(AuthMode::Chatgpt), |
| ); |
| let outcome = manager |
| .read_plugin_for_config( |
| &config, |
| &PluginReadRequest { |
| plugin_name: "sample-plugin".to_string(), |
| marketplace_path: AbsolutePathBuf::try_from( |
| repo_root.join(".agents/plugins/marketplace.json"), |
| ) |
| .unwrap(), |
| }, |
| ) |
| .await |
| .unwrap(); |
|
|
| assert_eq!( |
| outcome.plugin.apps, |
| vec![AppConnectorId("connector_sample".to_string())] |
| ); |
| assert_eq!( |
| outcome.plugin.mcp_server_names, |
| vec!["sample-mcp".to_string()] |
| ); |
|
|
| let listed_plugin = manager |
| .list_marketplaces_for_config( |
| &config, |
| &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], |
| false, |
| ) |
| .unwrap() |
| .marketplaces |
| .into_iter() |
| .find(|marketplace| { |
| marketplace.path |
| == AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")) |
| .unwrap() |
| }) |
| .unwrap() |
| .plugins |
| .into_iter() |
| .find(|plugin| plugin.name == "sample-plugin") |
| .unwrap(); |
| let listed_detail = manager |
| .read_plugin_detail_for_marketplace_plugin(&config, "debug", listed_plugin) |
| .await |
| .unwrap(); |
| assert_eq!( |
| listed_detail.apps, |
| vec![AppConnectorId("connector_sample".to_string())] |
| ); |
| assert_eq!( |
| listed_detail.mcp_server_names, |
| vec!["sample-mcp".to_string()] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn agent_plugin_read_and_tool_suggestions_use_portable_capabilities_only() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| let plugin_root = repo_root.join("agent-plugin"); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); |
| write_file( |
| &repo_root.join(".agents/plugins/marketplace.json"), |
| r#"{"name":"debug","plugins":[{"name":"agent-plugin","source":"./agent-plugin"}]}"#, |
| ); |
| write_file( |
| &plugin_root.join("plugin.json"), |
| r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"agent.tools"}"#, |
| ); |
| write_file( |
| &plugin_root.join("skills/direct/SKILL.md"), |
| "---\nname: direct\ndescription: Direct skill\n---\n", |
| ); |
| write_file( |
| &plugin_root.join("skills/group/nested/SKILL.md"), |
| "---\nname: nested\ndescription: Nested skill\n---\n", |
| ); |
| write_file( |
| &plugin_root.join("mcp.json"), |
| r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"portable":{"type":"stdio","command":"echo"}}}"#, |
| ); |
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| r#"{"apps":"./.app.json","hooks":"./hooks/hooks.json"}"#, |
| ); |
| write_file( |
| &plugin_root.join(".app.json"), |
| r#"{"apps":{"legacy":{"id":"connector_legacy"}}}"#, |
| ); |
| write_file( |
| &plugin_root.join("hooks/hooks.json"), |
| r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo legacy"}]}]}}"#, |
| ); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| "[features]\nplugins = true\n", |
| ); |
|
|
| let config = load_config(tmp.path(), &repo_root).await; |
| let manager = test_plugins_manager(tmp.path().to_path_buf()); |
| let plugin = manager |
| .list_marketplaces_for_config( |
| &config, |
| &[AbsolutePathBuf::try_from(repo_root).unwrap()], |
| false, |
| ) |
| .unwrap() |
| .marketplaces |
| .into_iter() |
| .find(|marketplace| marketplace.name == "debug") |
| .unwrap() |
| .plugins |
| .into_iter() |
| .find(|plugin| plugin.name == "agent-plugin") |
| .unwrap(); |
| let detail = manager |
| .read_plugin_detail_for_marketplace_plugin(&config, "debug", plugin.clone()) |
| .await |
| .unwrap(); |
| let suggestion = manager |
| .tool_suggest_metadata_for_marketplace_plugin( |
| "debug", |
| &plugin, |
| &SkillConfigRules::default(), |
| ) |
| .await |
| .unwrap(); |
|
|
| assert_eq!( |
| detail |
| .skills |
| .iter() |
| .map(|skill| skill.name.as_str()) |
| .collect::<Vec<_>>(), |
| vec!["agent.tools:direct"] |
| ); |
| assert_eq!(detail.mcp_server_names, vec!["portable"]); |
| assert!(detail.apps.is_empty()); |
| assert!(detail.hooks.is_empty()); |
| assert!(suggestion.has_skills); |
| assert_eq!(suggestion.mcp_server_names, vec!["portable"]); |
| assert!(suggestion.app_connector_ids.is_empty()); |
| } |
|
|
| #[tokio::test] |
| async fn read_plugin_for_config_does_not_fallback_from_invalid_plugin_manifest() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| let plugin_root = repo_root.join("sample-plugin"); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); |
| write_file( |
| &repo_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "sample-plugin", |
| "source": "./sample-plugin", |
| "description": "Fallback metadata" |
| } |
| ] |
| }"#, |
| ); |
| write_file(&plugin_root.join(".codex-plugin/plugin.json"), "{"); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| "#, |
| ); |
|
|
| let config = load_config(tmp.path(), &repo_root).await; |
| let err = test_plugins_manager(tmp.path().to_path_buf()) |
| .read_plugin_for_config( |
| &config, |
| &PluginReadRequest { |
| plugin_name: "sample-plugin".to_string(), |
| marketplace_path: AbsolutePathBuf::try_from( |
| repo_root.join(".agents/plugins/marketplace.json"), |
| ) |
| .unwrap(), |
| }, |
| ) |
| .await |
| .unwrap_err(); |
|
|
| assert_eq!(err.to_string(), "missing or invalid plugin.json"); |
| } |
|
|
| #[tokio::test] |
| async fn read_plugin_for_config_uses_user_layer_skill_settings_only() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| let plugin_root = repo_root.join("enabled-plugin"); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); |
| write_file( |
| &repo_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "enabled-plugin", |
| "source": { |
| "source": "local", |
| "path": "./enabled-plugin" |
| } |
| } |
| ] |
| }"#, |
| ); |
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| r#"{"name":"enabled-plugin"}"#, |
| ); |
| write_file( |
| &plugin_root.join("skills/sample-search/SKILL.md"), |
| "---\nname: sample-search\ndescription: search sample data\n---\n", |
| ); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| |
| [plugins."enabled-plugin@debug"] |
| enabled = true |
| "#, |
| ); |
| write_file( |
| &repo_root.join(".codex/config.toml"), |
| r#"[[skills.config]] |
| name = "enabled-plugin:sample-search" |
| enabled = false |
| "#, |
| ); |
|
|
| let config = load_config(tmp.path(), &repo_root).await; |
| let outcome = test_plugins_manager(tmp.path().to_path_buf()) |
| .read_plugin_for_config( |
| &config, |
| &PluginReadRequest { |
| plugin_name: "enabled-plugin".to_string(), |
| marketplace_path: AbsolutePathBuf::try_from( |
| repo_root.join(".agents/plugins/marketplace.json"), |
| ) |
| .unwrap(), |
| }, |
| ) |
| .await |
| .unwrap(); |
|
|
| assert!(outcome.plugin.disabled_skill_paths.is_empty()); |
| } |
|
|
| #[tokio::test] |
| async fn read_plugin_for_config_uninstalled_git_source_requires_install_without_cloning() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo"); |
| let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo) |
| .unwrap() |
| .to_string(); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| write_file( |
| &repo_root.join(".agents/plugins/marketplace.json"), |
| &format!( |
| r#"{{ |
| "name": "debug", |
| "plugins": [ |
| {{ |
| "name": "toolkit", |
| "source": {{ |
| "source": "git-subdir", |
| "url": "{missing_remote_repo_url}", |
| "path": "plugins/toolkit" |
| }}, |
| "policy": {{ |
| "installation": "AVAILABLE", |
| "authentication": "ON_INSTALL" |
| }} |
| }} |
| ] |
| }}"# |
| ), |
| ); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| "#, |
| ); |
|
|
| let config = load_config(tmp.path(), &repo_root).await; |
| let outcome = test_plugins_manager(tmp.path().to_path_buf()) |
| .read_plugin_for_config( |
| &config, |
| &PluginReadRequest { |
| plugin_name: "toolkit".to_string(), |
| marketplace_path: AbsolutePathBuf::try_from( |
| repo_root.join(".agents/plugins/marketplace.json"), |
| ) |
| .unwrap(), |
| }, |
| ) |
| .await |
| .unwrap(); |
|
|
| assert_eq!( |
| outcome.plugin.details_unavailable_reason, |
| Some(PluginDetailsUnavailableReason::InstallRequiredForRemoteSource) |
| ); |
| assert!(!outcome.plugin.installed); |
| let expected_description = format!( |
| "This is a cross-repo plugin. Install it to view more detailed information. The source of the plugin is {missing_remote_repo_url}, path `plugins/toolkit`." |
| ); |
| assert_eq!( |
| outcome.plugin.description.as_deref(), |
| Some(expected_description.as_str()) |
| ); |
| assert!(outcome.plugin.skills.is_empty()); |
| assert!(outcome.plugin.apps.is_empty()); |
| assert!(outcome.plugin.mcp_server_names.is_empty()); |
| assert!( |
| !tmp.path() |
| .join("plugins/.marketplace-plugin-source-staging") |
| .exists() |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn read_plugin_for_config_installed_git_source_reads_from_cache_without_cloning() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo"); |
| let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo) |
| .unwrap() |
| .to_string(); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| write_file( |
| &repo_root.join(".agents/plugins/marketplace.json"), |
| &format!( |
| r#"{{ |
| "name": "debug", |
| "plugins": [ |
| {{ |
| "name": "toolkit", |
| "source": {{ |
| "source": "git-subdir", |
| "url": "{missing_remote_repo_url}", |
| "path": "plugins/toolkit" |
| }}, |
| "category": "Developer Tools" |
| }} |
| ] |
| }}"# |
| ), |
| ); |
| let cached_plugin_root = tmp.path().join("plugins/cache/debug/toolkit/local"); |
| write_file( |
| &cached_plugin_root.join(".codex-plugin/plugin.json"), |
| r#"{ |
| "name": "toolkit", |
| "description": "Cached toolkit plugin", |
| "interface": { |
| "displayName": "Toolkit" |
| } |
| }"#, |
| ); |
| write_file( |
| &cached_plugin_root.join("skills/search/SKILL.md"), |
| "---\nname: search\ndescription: search cached data\n---\n", |
| ); |
| write_file( |
| &cached_plugin_root.join(".app.json"), |
| r#"{ |
| "apps": { |
| "calendar": { |
| "id": "connector_calendar", |
| "category": "First Category" |
| }, |
| "calendar_duplicate": { |
| "id": "connector_calendar", |
| "category": "Second Category" |
| } |
| } |
| }"#, |
| ); |
| write_file( |
| &cached_plugin_root.join(".mcp.json"), |
| r#"{"mcpServers":{"toolkit":{"command":"toolkit-mcp"}}}"#, |
| ); |
| write_file( |
| &cached_plugin_root.join("hooks/hooks.json"), |
| r#"{ |
| "hooks": { |
| "SessionStart": [ |
| { |
| "hooks": [ |
| { |
| "type": "command", |
| "command": "echo startup" |
| } |
| ] |
| } |
| ], |
| "PreToolUse": [ |
| { |
| "hooks": [ |
| { |
| "type": "command", |
| "command": "echo first" |
| }, |
| { |
| "type": "command", |
| "command": "echo second" |
| } |
| ] |
| } |
| ] |
| } |
| }"#, |
| ); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| |
| [plugins."toolkit@debug"] |
| enabled = true |
| |
| [hooks.state."toolkit@debug:hooks/hooks.json:pre_tool_use:0:0"] |
| enabled = false |
| "#, |
| ); |
|
|
| let config = load_config(tmp.path(), &repo_root).await; |
| let outcome = test_plugins_manager_with_options( |
| tmp.path().to_path_buf(), |
| Some(Product::Codex), |
| Some(AuthMode::Chatgpt), |
| ) |
| .read_plugin_for_config( |
| &config, |
| &PluginReadRequest { |
| plugin_name: "toolkit".to_string(), |
| marketplace_path: AbsolutePathBuf::try_from( |
| repo_root.join(".agents/plugins/marketplace.json"), |
| ) |
| .unwrap(), |
| }, |
| ) |
| .await |
| .unwrap(); |
|
|
| assert_eq!(outcome.plugin.details_unavailable_reason, None); |
| assert_eq!( |
| outcome.plugin.description.as_deref(), |
| Some("Cached toolkit plugin") |
| ); |
| assert_eq!( |
| outcome.plugin.interface, |
| Some(PluginManifestInterface { |
| display_name: Some("Toolkit".to_string()), |
| category: Some("Developer Tools".to_string()), |
| ..Default::default() |
| }) |
| ); |
| assert!(outcome.plugin.installed); |
| assert_eq!(outcome.plugin.skills.len(), 1); |
| assert_eq!(outcome.plugin.skills[0].name, "toolkit:search"); |
| assert_eq!( |
| outcome.plugin.apps, |
| vec![AppConnectorId("connector_calendar".to_string())] |
| ); |
| assert_eq!( |
| outcome.plugin.app_category_by_id, |
| HashMap::from([( |
| "connector_calendar".to_string(), |
| "First Category".to_string() |
| )]) |
| ); |
| assert_eq!( |
| outcome.plugin.hooks, |
| vec![ |
| PluginHookSummary { |
| key: "toolkit@debug:hooks/hooks.json:pre_tool_use:0:0".to_string(), |
| event_name: HookEventName::PreToolUse, |
| }, |
| PluginHookSummary { |
| key: "toolkit@debug:hooks/hooks.json:pre_tool_use:0:1".to_string(), |
| event_name: HookEventName::PreToolUse, |
| }, |
| PluginHookSummary { |
| key: "toolkit@debug:hooks/hooks.json:session_start:0:0".to_string(), |
| event_name: HookEventName::SessionStart, |
| }, |
| ] |
| ); |
| assert_eq!(outcome.plugin.mcp_server_names, vec!["toolkit".to_string()]); |
| assert!( |
| !tmp.path() |
| .join("plugins/.marketplace-plugin-source-staging") |
| .exists() |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn list_marketplaces_installed_git_source_reads_metadata_from_cache_without_cloning() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo"); |
| let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo) |
| .unwrap() |
| .to_string(); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| write_file( |
| &repo_root.join(".agents/plugins/marketplace.json"), |
| &format!( |
| r#"{{ |
| "name": "debug", |
| "plugins": [ |
| {{ |
| "name": "toolkit", |
| "source": {{ |
| "source": "git-subdir", |
| "url": "{missing_remote_repo_url}", |
| "path": "plugins/toolkit" |
| }}, |
| "category": "Developer Tools" |
| }} |
| ] |
| }}"# |
| ), |
| ); |
| let cached_plugin_root = tmp.path().join("plugins/cache/debug/toolkit/local"); |
| write_file( |
| &cached_plugin_root.join(".codex-plugin/plugin.json"), |
| r##"{ |
| "name": "toolkit", |
| "interface": { |
| "displayName": "Toolkit", |
| "shortDescription": "Search cached data", |
| "category": "Cached Category", |
| "brandColor": "#3B82F6", |
| "composerIcon": "./assets/icon.png", |
| "logo": "./assets/logo.png", |
| "screenshots": ["./assets/screenshot.png"] |
| } |
| }"##, |
| ); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| |
| [plugins."toolkit@debug"] |
| enabled = true |
| "#, |
| ); |
|
|
| let config = load_config(tmp.path(), &repo_root).await; |
| let marketplaces = test_plugins_manager(tmp.path().to_path_buf()) |
| .list_marketplaces_for_config( |
| &config, |
| &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], |
| true, |
| ) |
| .unwrap() |
| .marketplaces; |
|
|
| let marketplace = marketplaces |
| .into_iter() |
| .find(|marketplace| { |
| marketplace.path |
| == AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")) |
| .unwrap() |
| }) |
| .expect("debug marketplace should be listed"); |
|
|
| let mut plugins = marketplace.plugins; |
| assert!(plugins[0].manifest_fallback.is_some()); |
| plugins[0].manifest_fallback = None; |
| assert_eq!( |
| plugins, |
| vec![ConfiguredMarketplacePlugin { |
| id: "toolkit@debug".to_string(), |
| name: "toolkit".to_string(), |
| local_version: None, |
| installed_version: Some("local".to_string()), |
| source: MarketplacePluginSource::Git { |
| url: missing_remote_repo_url, |
| path: Some("plugins/toolkit".to_string()), |
| ref_name: None, |
| sha: None, |
| }, |
| policy: MarketplacePluginPolicy { |
| installation: MarketplacePluginInstallPolicy::Available, |
| authentication: MarketplacePluginAuthPolicy::OnInstall, |
| products: None, |
| }, |
| interface: Some(PluginManifestInterface { |
| display_name: Some("Toolkit".to_string()), |
| short_description: Some("Search cached data".to_string()), |
| category: Some("Developer Tools".to_string()), |
| brand_color: Some("#3B82F6".to_string()), |
| composer_icon: Some( |
| AbsolutePathBuf::try_from(cached_plugin_root.join("assets/icon.png")).unwrap(), |
| ), |
| logo: Some( |
| AbsolutePathBuf::try_from(cached_plugin_root.join("assets/logo.png")).unwrap(), |
| ), |
| screenshots: vec![ |
| AbsolutePathBuf::try_from(cached_plugin_root.join("assets/screenshot.png")) |
| .unwrap(), |
| ], |
| ..Default::default() |
| }), |
| keywords: Vec::new(), |
| manifest_fallback: None, |
| installed: true, |
| enabled: true, |
| }] |
| ); |
| assert!( |
| !tmp.path() |
| .join("plugins/.marketplace-plugin-source-staging") |
| .exists() |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn list_marketplaces_includes_curated_repo_marketplace() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let curated_root = curated_plugins_repo_path(tmp.path()); |
| let plugin_root = curated_root.join("plugins/linear"); |
|
|
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| "#, |
| ); |
| fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap(); |
| fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); |
| fs::write( |
| curated_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "openai-curated", |
| "plugins": [ |
| { |
| "name": "linear", |
| "source": { |
| "source": "local", |
| "path": "./plugins/linear" |
| } |
| } |
| ] |
| }"#, |
| ) |
| .unwrap(); |
| fs::write( |
| plugin_root.join(".codex-plugin/plugin.json"), |
| r#"{"name":"linear"}"#, |
| ) |
| .unwrap(); |
|
|
| let config = load_config(tmp.path(), tmp.path()).await; |
| let marketplaces = test_plugins_manager_with_options( |
| tmp.path().to_path_buf(), |
| Some(Product::Codex), |
| Some(AuthMode::Chatgpt), |
| ) |
| .list_marketplaces_for_config(&config, &[], true) |
| .unwrap() |
| .marketplaces; |
|
|
| let curated_marketplace = marketplaces |
| .into_iter() |
| .find(|marketplace| marketplace.name == "openai-curated") |
| .expect("curated marketplace should be listed"); |
|
|
| assert_eq!( |
| curated_marketplace, |
| ConfiguredMarketplace { |
| name: "openai-curated".to_string(), |
| path: AbsolutePathBuf::try_from(curated_root.join(".agents/plugins/marketplace.json")) |
| .unwrap(), |
| interface: None, |
| plugins: vec![ConfiguredMarketplacePlugin { |
| id: "linear@openai-curated".to_string(), |
| name: "linear".to_string(), |
| local_version: None, |
| installed_version: None, |
| source: MarketplacePluginSource::Local { |
| path: AbsolutePathBuf::try_from(curated_root.join("plugins/linear")).unwrap(), |
| }, |
| policy: MarketplacePluginPolicy { |
| installation: MarketplacePluginInstallPolicy::Available, |
| authentication: MarketplacePluginAuthPolicy::OnInstall, |
| products: None, |
| }, |
| interface: None, |
| keywords: Vec::new(), |
| manifest_fallback: None, |
| installed: false, |
| enabled: false, |
| }], |
| } |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn list_marketplaces_can_skip_openai_curated_before_loading() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let curated_root = curated_plugins_repo_path(tmp.path()); |
|
|
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| "#, |
| ); |
| write_file( |
| &curated_root.join(".agents/plugins/marketplace.json"), |
| "{not valid json", |
| ); |
|
|
| let config = load_config(tmp.path(), tmp.path()).await; |
| let outcome = test_plugins_manager(tmp.path().to_path_buf()) |
| .list_marketplaces_for_config(&config, &[], false) |
| .unwrap(); |
|
|
| assert_eq!(outcome.errors, Vec::new()); |
| assert_eq!( |
| outcome |
| .marketplaces |
| .iter() |
| .any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME), |
| false |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn list_marketplaces_uses_api_curated_manifest_when_selected() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let curated_root = curated_plugins_repo_path(tmp.path()); |
|
|
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| "#, |
| ); |
| write_file( |
| &curated_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "openai-curated", |
| "plugins": [ |
| { |
| "name": "siwc-plugin", |
| "source": { |
| "source": "local", |
| "path": "./plugins/siwc-plugin" |
| } |
| } |
| ] |
| }"#, |
| ); |
| write_file( |
| &curated_root.join(".agents/plugins/api_marketplace.json"), |
| r#"{ |
| "name": "openai-api-curated", |
| "interface": { |
| "displayName": "OpenAI Curated" |
| }, |
| "plugins": [ |
| { |
| "name": "api-plugin", |
| "source": { |
| "source": "local", |
| "path": "./plugins/api-plugin" |
| } |
| } |
| ] |
| }"#, |
| ); |
|
|
| let config = load_config(tmp.path(), tmp.path()).await; |
| let manager = test_plugins_manager_with_options( |
| tmp.path().to_path_buf(), |
| Some(Product::Codex), |
| Some(AuthMode::ApiKey), |
| ); |
| let marketplaces = manager |
| .list_marketplaces_for_config(&config, &[], true) |
| .unwrap() |
| .marketplaces; |
| let curated_marketplace = marketplaces |
| .into_iter() |
| .find(|marketplace| marketplace.name == OPENAI_API_CURATED_MARKETPLACE_NAME) |
| .expect("API curated marketplace should be listed"); |
|
|
| assert_eq!( |
| curated_marketplace, |
| ConfiguredMarketplace { |
| name: "openai-api-curated".to_string(), |
| path: AbsolutePathBuf::try_from( |
| curated_root.join(".agents/plugins/api_marketplace.json") |
| ) |
| .unwrap(), |
| interface: Some(MarketplaceInterface { |
| display_name: Some("OpenAI Curated".to_string()), |
| }), |
| plugins: vec![ConfiguredMarketplacePlugin { |
| id: "api-plugin@openai-api-curated".to_string(), |
| name: "api-plugin".to_string(), |
| local_version: None, |
| installed_version: None, |
| source: MarketplacePluginSource::Local { |
| path: AbsolutePathBuf::try_from(curated_root.join("plugins/api-plugin")) |
| .unwrap(), |
| }, |
| policy: MarketplacePluginPolicy { |
| installation: MarketplacePluginInstallPolicy::Available, |
| authentication: MarketplacePluginAuthPolicy::OnInstall, |
| products: None, |
| }, |
| interface: None, |
| keywords: Vec::new(), |
| manifest_fallback: None, |
| installed: false, |
| enabled: false, |
| }], |
| } |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn list_marketplaces_selects_curated_catalog_only_from_authentication() { |
| for (configured_provider, resolved_provider, auth_mode, expected_marketplace) in [ |
| ( |
| "openai", |
| AMAZON_BEDROCK_PROVIDER_ID, |
| None, |
| OPENAI_API_CURATED_MARKETPLACE_NAME, |
| ), |
| ( |
| AMAZON_BEDROCK_PROVIDER_ID, |
| "openai", |
| None, |
| OPENAI_API_CURATED_MARKETPLACE_NAME, |
| ), |
| ( |
| "openai", |
| "ollama", |
| None, |
| OPENAI_API_CURATED_MARKETPLACE_NAME, |
| ), |
| ( |
| "openai", |
| "ollama", |
| Some(AuthMode::ApiKey), |
| OPENAI_API_CURATED_MARKETPLACE_NAME, |
| ), |
| ( |
| "openai", |
| "ollama", |
| Some(AuthMode::Chatgpt), |
| OPENAI_CURATED_MARKETPLACE_NAME, |
| ), |
| ] { |
| let tmp = tempfile::tempdir().unwrap(); |
| let curated_root = curated_plugins_repo_path(tmp.path()); |
|
|
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| &format!( |
| r#"model_provider = "{configured_provider}" |
| |
| [features] |
| plugins = true |
| "# |
| ), |
| ); |
| write_openai_curated_marketplace(&curated_root, &["chatgpt-plugin"]); |
| write_openai_api_curated_marketplace(&curated_root, &["api-plugin"]); |
|
|
| let mut config = load_config(tmp.path(), tmp.path()).await; |
| config.model_provider_id = resolved_provider.to_string(); |
| let marketplaces = test_plugins_manager_with_options( |
| tmp.path().to_path_buf(), |
| Some(Product::Codex), |
| auth_mode, |
| ) |
| .list_marketplaces_for_config(&config, &[], true) |
| .unwrap() |
| .marketplaces; |
|
|
| assert_eq!( |
| marketplaces |
| .iter() |
| .map(|marketplace| marketplace.name.as_str()) |
| |
| .filter(|name| { |
| matches!( |
| *name, |
| OPENAI_CURATED_MARKETPLACE_NAME | OPENAI_API_CURATED_MARKETPLACE_NAME |
| ) |
| }) |
| .collect::<Vec<_>>(), |
| vec![expected_marketplace], |
| "unexpected curated catalog for provider `{resolved_provider}` with auth {auth_mode:?}" |
| ); |
| } |
| } |
|
|
| #[tokio::test] |
| async fn list_marketplaces_uses_chatgpt_curated_manifest_for_bedrock_with_chatgpt_auth() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let curated_root = curated_plugins_repo_path(tmp.path()); |
|
|
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"model_provider = "amazon-bedrock" |
| |
| [features] |
| plugins = true |
| "#, |
| ); |
| write_openai_curated_marketplace(&curated_root, &["chatgpt-plugin"]); |
| write_openai_api_curated_marketplace(&curated_root, &["api-plugin"]); |
|
|
| let config = load_config(tmp.path(), tmp.path()).await; |
| let manager = test_plugins_manager_with_options( |
| tmp.path().to_path_buf(), |
| Some(Product::Codex), |
| Some(AuthMode::Chatgpt), |
| ); |
| let marketplaces = manager |
| .list_marketplaces_for_config(&config, &[], true) |
| .unwrap() |
| .marketplaces; |
|
|
| assert!( |
| marketplaces |
| .iter() |
| .any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) |
| ); |
| assert!( |
| marketplaces |
| .iter() |
| .all(|marketplace| marketplace.name != OPENAI_API_CURATED_MARKETPLACE_NAME) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn list_marketplaces_skips_missing_api_curated_manifest() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let curated_root = curated_plugins_repo_path(tmp.path()); |
|
|
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| "#, |
| ); |
| write_file( |
| &curated_root.join(".agents/plugins/marketplace.json"), |
| "{not valid json", |
| ); |
|
|
| let config = load_config(tmp.path(), tmp.path()).await; |
| let manager = test_plugins_manager_with_options( |
| tmp.path().to_path_buf(), |
| Some(Product::Codex), |
| Some(AuthMode::BedrockApiKey), |
| ); |
| let outcome = manager |
| .list_marketplaces_for_config(&config, &[], true) |
| .unwrap(); |
|
|
| assert_eq!(outcome.errors, Vec::new()); |
| assert_eq!( |
| outcome |
| .marketplaces |
| .iter() |
| .any(|marketplace| marketplace.name == OPENAI_API_CURATED_MARKETPLACE_NAME), |
| false |
| ); |
| } |
|
|
| #[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 = "git" |
| 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 = test_plugins_manager(tmp.path().to_path_buf()) |
| .list_marketplaces_for_config(&config, &[], true) |
| .unwrap() |
| .marketplaces; |
|
|
| let marketplace = marketplaces |
| .into_iter() |
| .find(|marketplace| { |
| marketplace.path |
| == AbsolutePathBuf::try_from( |
| marketplace_root.join(".agents/plugins/marketplace.json"), |
| ) |
| .unwrap() |
| }) |
| .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 configured_marketplace_upgrade_invalidates_cached_tool_suggest_metadata() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let remote_repo = tmp.path().join("remote-marketplace"); |
| let remote_repo_url = url::Url::from_directory_path(&remote_repo) |
| .unwrap() |
| .to_string(); |
| write_file( |
| &remote_repo.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "sample", |
| "source": { |
| "source": "local", |
| "path": "./plugins/sample" |
| } |
| } |
| ] |
| }"#, |
| ); |
| write_curated_plugin(&remote_repo, "sample"); |
| write_file( |
| &remote_repo.join("plugins/sample/.codex-plugin/plugin.json"), |
| r#"{"name":"sample","description":"Before upgrade"}"#, |
| ); |
| init_git_repo(&remote_repo); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| &format!( |
| r#"[features] |
| plugins = true |
| |
| [marketplaces.debug] |
| source_type = "git" |
| source = "{remote_repo_url}" |
| "# |
| ), |
| ); |
|
|
| let manager = test_plugins_manager(tmp.path().to_path_buf()); |
| let config = load_config(tmp.path(), tmp.path()).await; |
| let reload_stack = config.config_layer_stack.clone(); |
| let reload_config: ConfigLayerReload = Arc::new(move || Ok(reload_stack.clone())); |
| let initial_upgrade = manager |
| .upgrade_configured_marketplaces_for_config( |
| &config, |
| None, |
| &reload_config, |
| ) |
| .expect("initial marketplace install should succeed"); |
| assert_eq!(initial_upgrade.errors, Vec::new()); |
| assert_eq!(initial_upgrade.upgraded_roots.len(), 1); |
|
|
| let config = load_config(tmp.path(), tmp.path()).await; |
| let input = ToolSuggestPluginDiscoveryInput { |
| plugins: config.clone(), |
| configured_plugin_ids: HashSet::from(["sample@debug".to_string()]), |
| disabled_plugin_ids: HashSet::new(), |
| loaded_plugin_app_connector_ids: HashSet::new(), |
| }; |
| let expected = ToolSuggestDiscoverablePlugin { |
| id: "sample@debug".to_string(), |
| remote_plugin_id: None, |
| name: "sample".to_string(), |
| description: Some("Before upgrade".to_string()), |
| has_skills: true, |
| mcp_server_names: vec!["sample-docs".to_string()], |
| app_connector_ids: vec!["connector_calendar".to_string()], |
| }; |
| assert_eq!( |
| manager |
| .list_tool_suggest_discoverable_plugins(&input, None) |
| .await |
| .expect("initial tool-suggest metadata should load"), |
| vec![expected.clone()] |
| ); |
|
|
| write_file( |
| &remote_repo.join("plugins/sample/.codex-plugin/plugin.json"), |
| r#"{"name":"sample","description":"After upgrade"}"#, |
| ); |
| run_git(&remote_repo, &["add", "."]); |
| run_git(&remote_repo, &["commit", "-m", "update plugin"]); |
| let upgrade = manager |
| .upgrade_configured_marketplaces_for_config(&config, Some("debug"), &reload_config) |
| .expect("marketplace upgrade should succeed"); |
| assert_eq!(upgrade.errors, Vec::new()); |
| assert_eq!(upgrade.upgraded_roots.len(), 1); |
|
|
| assert_eq!( |
| manager |
| .list_tool_suggest_discoverable_plugins(&input, None) |
| .await |
| .expect("refreshed tool-suggest metadata should load"), |
| vec![ToolSuggestDiscoverablePlugin { |
| description: Some("After upgrade".to_string()), |
| ..expected |
| }] |
| ); |
| } |
|
|
| #[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 = "git" |
| 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 = test_plugins_manager(tmp.path().to_path_buf()) |
| .list_marketplaces_for_config(&config, &[], true) |
| .unwrap() |
| .marketplaces; |
|
|
| let marketplace = marketplaces |
| .into_iter() |
| .find(|marketplace| { |
| marketplace.path |
| == AbsolutePathBuf::try_from( |
| marketplace_root.join(".agents/plugins/marketplace.json"), |
| ) |
| .unwrap() |
| }) |
| .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 = test_plugins_manager(tmp.path().to_path_buf()) |
| .list_marketplaces_for_config(&config, &[], true) |
| .unwrap() |
| .marketplaces; |
|
|
| assert!( |
| marketplaces.iter().all(|marketplace| { |
| marketplace.path |
| != AbsolutePathBuf::try_from( |
| marketplace_root.join(".agents/plugins/marketplace.json"), |
| ) |
| .unwrap() |
| }), |
| "installed marketplace root missing from config should not be listed" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn list_marketplaces_uses_first_duplicate_plugin_entry() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_a_root = tmp.path().join("repo-a"); |
| let repo_b_root = tmp.path().join("repo-b"); |
| fs::create_dir_all(repo_a_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_b_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_a_root.join(".agents/plugins")).unwrap(); |
| fs::create_dir_all(repo_b_root.join(".agents/plugins")).unwrap(); |
| fs::write( |
| repo_a_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "dup-plugin", |
| "source": { |
| "source": "local", |
| "path": "./from-a" |
| } |
| } |
| ] |
| }"#, |
| ) |
| .unwrap(); |
| fs::write( |
| repo_b_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "dup-plugin", |
| "source": { |
| "source": "local", |
| "path": "./from-b" |
| } |
| }, |
| { |
| "name": "b-only-plugin", |
| "source": { |
| "source": "local", |
| "path": "./from-b-only" |
| } |
| } |
| ] |
| }"#, |
| ) |
| .unwrap(); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| |
| [plugins."dup-plugin@debug"] |
| enabled = true |
| |
| [plugins."b-only-plugin@debug"] |
| enabled = false |
| "#, |
| ); |
|
|
| let config = load_config(tmp.path(), &repo_a_root).await; |
| let marketplaces = test_plugins_manager(tmp.path().to_path_buf()) |
| .list_marketplaces_for_config( |
| &config, |
| &[ |
| AbsolutePathBuf::try_from(repo_a_root).unwrap(), |
| AbsolutePathBuf::try_from(repo_b_root).unwrap(), |
| ], |
| true, |
| ) |
| .unwrap() |
| .marketplaces; |
|
|
| let repo_a_marketplace = marketplaces |
| .iter() |
| .find(|marketplace| { |
| marketplace.path |
| == AbsolutePathBuf::try_from( |
| tmp.path().join("repo-a/.agents/plugins/marketplace.json"), |
| ) |
| .unwrap() |
| }) |
| .expect("repo-a marketplace should be listed"); |
| assert_eq!( |
| repo_a_marketplace.plugins, |
| vec![ConfiguredMarketplacePlugin { |
| id: "dup-plugin@debug".to_string(), |
| name: "dup-plugin".to_string(), |
| local_version: None, |
| installed_version: None, |
| source: MarketplacePluginSource::Local { |
| path: AbsolutePathBuf::try_from(tmp.path().join("repo-a/from-a")).unwrap(), |
| }, |
| policy: MarketplacePluginPolicy { |
| installation: MarketplacePluginInstallPolicy::Available, |
| authentication: MarketplacePluginAuthPolicy::OnInstall, |
| products: None, |
| }, |
| interface: None, |
| keywords: Vec::new(), |
| manifest_fallback: None, |
| installed: false, |
| enabled: true, |
| }] |
| ); |
|
|
| let repo_b_marketplace = marketplaces |
| .iter() |
| .find(|marketplace| { |
| marketplace.path |
| == AbsolutePathBuf::try_from( |
| tmp.path().join("repo-b/.agents/plugins/marketplace.json"), |
| ) |
| .unwrap() |
| }) |
| .expect("repo-b marketplace should be listed"); |
| assert_eq!( |
| repo_b_marketplace.plugins, |
| vec![ConfiguredMarketplacePlugin { |
| id: "b-only-plugin@debug".to_string(), |
| name: "b-only-plugin".to_string(), |
| local_version: None, |
| installed_version: None, |
| source: MarketplacePluginSource::Local { |
| path: AbsolutePathBuf::try_from(tmp.path().join("repo-b/from-b-only")).unwrap(), |
| }, |
| policy: MarketplacePluginPolicy { |
| installation: MarketplacePluginInstallPolicy::Available, |
| authentication: MarketplacePluginAuthPolicy::OnInstall, |
| products: None, |
| }, |
| interface: None, |
| keywords: Vec::new(), |
| manifest_fallback: None, |
| installed: false, |
| enabled: false, |
| }] |
| ); |
|
|
| let duplicate_plugin_count = marketplaces |
| .iter() |
| .flat_map(|marketplace| marketplace.plugins.iter()) |
| .filter(|plugin| plugin.name == "dup-plugin") |
| .count(); |
| assert_eq!(duplicate_plugin_count, 1); |
| } |
|
|
| #[tokio::test] |
| async fn list_marketplaces_marks_configured_plugin_uninstalled_when_cache_is_missing() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); |
| fs::write( |
| repo_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "sample-plugin", |
| "source": { |
| "source": "local", |
| "path": "./sample-plugin" |
| } |
| } |
| ] |
| }"#, |
| ) |
| .unwrap(); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| |
| [plugins."sample-plugin@debug"] |
| enabled = true |
| "#, |
| ); |
|
|
| let config = load_config(tmp.path(), &repo_root).await; |
| let marketplaces = test_plugins_manager(tmp.path().to_path_buf()) |
| .list_marketplaces_for_config( |
| &config, |
| &[AbsolutePathBuf::try_from(repo_root).unwrap()], |
| true, |
| ) |
| .unwrap() |
| .marketplaces; |
|
|
| let marketplace = marketplaces |
| .into_iter() |
| .find(|marketplace| { |
| marketplace.path |
| == AbsolutePathBuf::try_from( |
| tmp.path().join("repo/.agents/plugins/marketplace.json"), |
| ) |
| .unwrap() |
| }) |
| .expect("expected repo marketplace entry"); |
|
|
| assert_eq!( |
| marketplace, |
| ConfiguredMarketplace { |
| name: "debug".to_string(), |
| path: AbsolutePathBuf::try_from( |
| tmp.path().join("repo/.agents/plugins/marketplace.json"), |
| ) |
| .unwrap(), |
| interface: None, |
| plugins: vec![ConfiguredMarketplacePlugin { |
| id: "sample-plugin@debug".to_string(), |
| name: "sample-plugin".to_string(), |
| local_version: None, |
| installed_version: None, |
| source: MarketplacePluginSource::Local { |
| path: AbsolutePathBuf::try_from(tmp.path().join("repo/sample-plugin")).unwrap(), |
| }, |
| policy: MarketplacePluginPolicy { |
| installation: MarketplacePluginInstallPolicy::Available, |
| authentication: MarketplacePluginAuthPolicy::OnInstall, |
| products: None, |
| }, |
| interface: None, |
| keywords: Vec::new(), |
| manifest_fallback: None, |
| installed: false, |
| enabled: true, |
| }], |
| } |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn featured_plugin_ids_for_config_uses_restriction_product_query_param() { |
| let tmp = tempfile::tempdir().unwrap(); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| "#, |
| ); |
|
|
| let server = MockServer::start().await; |
| Mock::given(method("GET")) |
| .and(path("/backend-api/plugins/featured")) |
| .and(query_param("platform", "chat")) |
| .and(header("authorization", "Bearer Access Token")) |
| .and(header("chatgpt-account-id", "account_id")) |
| .respond_with(ResponseTemplate::new(200).set_body_string(r#"["chat-plugin"]"#)) |
| .mount(&server) |
| .await; |
|
|
| let mut config = load_config(tmp.path(), tmp.path()).await; |
| config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); |
| let manager = test_plugins_manager_with_options( |
| tmp.path().to_path_buf(), |
| Some(Product::Chatgpt), |
| None, |
| ); |
|
|
| let featured_plugin_ids = manager |
| .featured_plugin_ids_for_config( |
| &config, |
| Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), |
| ) |
| .await |
| .unwrap(); |
|
|
| assert_eq!(featured_plugin_ids, vec!["chat-plugin".to_string()]); |
| } |
|
|
| #[tokio::test] |
| async fn featured_plugin_ids_for_config_defaults_query_param_to_codex() { |
| let tmp = tempfile::tempdir().unwrap(); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| "#, |
| ); |
|
|
| let server = MockServer::start().await; |
| Mock::given(method("GET")) |
| .and(path("/backend-api/plugins/featured")) |
| .and(query_param("platform", "codex")) |
| .respond_with(ResponseTemplate::new(200).set_body_string(r#"["codex-plugin"]"#)) |
| .mount(&server) |
| .await; |
|
|
| let mut config = load_config(tmp.path(), tmp.path()).await; |
| config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); |
| let manager = test_plugins_manager_with_options( |
| tmp.path().to_path_buf(), |
| None, |
| None, |
| ); |
|
|
| let featured_plugin_ids = manager |
| .featured_plugin_ids_for_config(&config, None) |
| .await |
| .unwrap(); |
|
|
| assert_eq!(featured_plugin_ids, vec!["codex-plugin".to_string()]); |
| } |
|
|
| #[tokio::test] |
| async fn remote_plugin_caches_refresh_warms_recommended_plugins_cache() { |
| let tmp = tempfile::tempdir().unwrap(); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| "#, |
| ); |
|
|
| let server = MockServer::start().await; |
| Mock::given(method("GET")) |
| .and(path("/ps/plugins/suggested/codex")) |
| .and(query_param("scope", "GLOBAL")) |
| .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ |
| "enabled": true, |
| "plugins": [] |
| }))) |
| .expect(1) |
| .mount(&server) |
| .await; |
|
|
| let mut config = load_config(tmp.path(), tmp.path()).await; |
| config.chatgpt_base_url = server.uri(); |
| let manager = std::sync::Arc::new(test_plugins_manager(tmp.path().to_path_buf())); |
| let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); |
| let cache_key = recommended_plugins_cache_key(&config); |
|
|
| manager.maybe_start_remote_plugin_caches_refresh( |
| &config, |
| Some(auth.clone()), |
| None, |
| ); |
|
|
| let mode = tokio::time::timeout(Duration::from_secs(2), async { |
| loop { |
| if let Some(mode) = manager.cached_recommended_plugins_mode(&cache_key) { |
| break mode; |
| } |
| tokio::time::sleep(Duration::from_millis(10)).await; |
| } |
| }) |
| .await |
| .expect("recommended plugins cache should be warmed"); |
| assert_eq!( |
| mode, |
| RecommendedPluginsMode::Endpoint { |
| plugins: Vec::new() |
| } |
| ); |
| assert_eq!( |
| manager |
| .recommended_plugins_mode_for_config(&config, Some(&auth)) |
| .await, |
| mode |
| ); |
| manager.clear_recommended_plugins_cache(); |
| assert_eq!(manager.cached_recommended_plugins_mode(&cache_key), None); |
| } |
|
|
| #[tokio::test] |
| async fn recommended_plugins_mode_deduplicates_concurrent_cache_misses() { |
| let tmp = tempfile::tempdir().unwrap(); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| "#, |
| ); |
|
|
| let server = MockServer::start().await; |
| Mock::given(method("GET")) |
| .and(path("/ps/plugins/suggested/codex")) |
| .and(query_param("scope", "GLOBAL")) |
| .and(header("authorization", "Bearer Access Token")) |
| .and(header("chatgpt-account-id", "account_id")) |
| .and(header("OAI-Product-Sku", "codex")) |
| .respond_with( |
| ResponseTemplate::new(200) |
| .set_body_json(serde_json::json!({ |
| "enabled": true, |
| "plugins": [ |
| { |
| "id": "plugin_slack", |
| "name": "slack", |
| "display_name": "Slack" |
| }, |
| { |
| "id": "plugin_github", |
| "name": "github", |
| "display_name": "GitHub" |
| } |
| ] |
| })) |
| .set_delay(Duration::from_millis(100)), |
| ) |
| .expect(1) |
| .mount(&server) |
| .await; |
|
|
| let mut config = load_config(tmp.path(), tmp.path()).await; |
| config.chatgpt_base_url = server.uri(); |
| let manager = test_plugins_manager(tmp.path().to_path_buf()); |
| let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); |
| let expected = RecommendedPluginsMode::Endpoint { |
| plugins: vec![ |
| RecommendedPlugin { |
| config_id: "github@openai-curated-remote".to_string(), |
| remote_plugin_id: "plugin_github".to_string(), |
| display_name: "GitHub".to_string(), |
| }, |
| RecommendedPlugin { |
| config_id: "slack@openai-curated-remote".to_string(), |
| remote_plugin_id: "plugin_slack".to_string(), |
| display_name: "Slack".to_string(), |
| }, |
| ], |
| }; |
|
|
| let (left, right) = tokio::join!( |
| manager.recommended_plugins_mode_for_config(&config, Some(&auth)), |
| manager.recommended_plugins_mode_for_config(&config, Some(&auth)), |
| ); |
| assert_eq!((left, right), (expected.clone(), expected.clone())); |
| assert_eq!( |
| manager |
| .recommended_plugins_mode_for_config(&config, Some(&auth)) |
| .await, |
| expected |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn recommended_plugins_cache_clear_rejects_stale_fetch_completion() { |
| let tmp = tempfile::tempdir().unwrap(); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| "[features]\nplugins = true\n", |
| ); |
| let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); |
| let server = MockServer::start().await; |
| let request_started = Arc::new(tokio::sync::Notify::new()); |
| let started = Arc::clone(&request_started); |
| let (release_response, released) = std::sync::mpsc::channel(); |
| let released = std::sync::Mutex::new(released); |
| let old_response = ResponseTemplate::new(200).set_body_json(serde_json::json!({ |
| "enabled": true, |
| "plugins": [{"id": "plugin_old", "name": "old", "display_name": "Old"}] |
| })); |
| Mock::given(method("GET")) |
| .and(path("/ps/plugins/suggested/codex")) |
| .respond_with(move |_: &wiremock::Request| { |
| started.notify_one(); |
| |
| let _ = released.lock().unwrap().recv(); |
| old_response.clone() |
| }) |
| .up_to_n_times(1) |
| .expect(1) |
| .mount(&server) |
| .await; |
| Mock::given(method("GET")) |
| .and(path("/ps/plugins/suggested/codex")) |
| .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ |
| "enabled": true, |
| "plugins": [] |
| }))) |
| .expect(1) |
| .mount(&server) |
| .await; |
|
|
| let mut config = load_config(tmp.path(), tmp.path()).await; |
| config.chatgpt_base_url = server.uri(); |
| let manager = test_plugins_manager(tmp.path().to_path_buf()); |
| let old_lookup = manager.recommended_plugins_mode_for_config(&config, Some(&auth)); |
| tokio::pin!(old_lookup); |
| tokio::select! { |
| biased; |
| _ = request_started.notified() => {} |
| mode = old_lookup.as_mut() => panic!("old fetch finished before release: {mode:?}"), |
| } |
|
|
| manager.clear_recommended_plugins_cache(); |
| release_response |
| .send(()) |
| .expect("release old HTTP response"); |
|
|
| |
| |
| let new_mode = tokio::time::timeout( |
| Duration::from_secs(10), |
| manager.recommended_plugins_mode_for_config(&config, Some(&auth)), |
| ) |
| .await |
| .expect("new lookup should finish independently of the invalidated fetch"); |
| assert_eq!( |
| new_mode, |
| RecommendedPluginsMode::Endpoint { |
| plugins: Vec::new() |
| } |
| ); |
| assert_eq!( |
| old_lookup.await, |
| RecommendedPluginsMode::Endpoint { |
| plugins: vec![RecommendedPlugin { |
| config_id: "old@openai-curated-remote".to_string(), |
| remote_plugin_id: "plugin_old".to_string(), |
| display_name: "Old".to_string(), |
| }] |
| } |
| ); |
| assert_eq!( |
| manager |
| .recommended_plugins_mode_for_config(&config, Some(&auth)) |
| .await, |
| new_mode |
| ); |
| server.verify().await; |
| } |
|
|
| #[tokio::test] |
| async fn recommended_plugin_candidates_filter_installed_and_disabled_plugins() { |
| let tmp = tempfile::tempdir().unwrap(); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| "#, |
| ); |
| let server = MockServer::start().await; |
| Mock::given(method("GET")) |
| .and(path("/ps/plugins/suggested/codex")) |
| .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ |
| "enabled": true, |
| "plugins": [ |
| { |
| "id": "plugin_linear", |
| "name": "linear", |
| "display_name": "Linear" |
| }, |
| { |
| "id": "plugin_github", |
| "name": "github", |
| "display_name": "GitHub" |
| }, |
| { |
| "id": "plugin_slack", |
| "name": "slack", |
| "display_name": "Slack" |
| } |
| ] |
| }))) |
| .expect(1) |
| .mount(&server) |
| .await; |
|
|
| let mut config = load_config(tmp.path(), tmp.path()).await; |
| config.chatgpt_base_url = server.uri(); |
| let manager = test_plugins_manager(tmp.path().to_path_buf()); |
| let mut installed_linear = remote_installed_plugin("linear"); |
| installed_linear.id = "plugin_linear".to_string(); |
| manager.write_remote_installed_plugins_cache(vec![installed_linear]); |
| let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); |
| let disabled_tools = [ToolSuggestDisabledTool::plugin( |
| "github@openai-curated-remote", |
| )]; |
| let loaded_plugins = manager.plugins_for_config(&config).await; |
|
|
| let candidates = manager |
| .recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput { |
| plugins_config: &config, |
| loaded_plugins: &loaded_plugins, |
| auth: Some(&auth), |
| disabled_tools: &disabled_tools, |
| app_server_client_name: None, |
| }) |
| .await; |
|
|
| assert_eq!( |
| candidates, |
| Some(vec![DiscoverableTool::from(DiscoverablePluginInfo { |
| id: "slack@openai-curated-remote".to_string(), |
| remote_plugin_id: Some("plugin_slack".to_string()), |
| name: "Slack".to_string(), |
| description: None, |
| has_skills: false, |
| mcp_server_names: Vec::new(), |
| app_connector_ids: Vec::new(), |
| })]) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn recommended_plugins_mode_caches_explicit_false() { |
| let tmp = tempfile::tempdir().unwrap(); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| "#, |
| ); |
|
|
| let server = MockServer::start().await; |
| Mock::given(method("GET")) |
| .and(path("/ps/plugins/suggested/codex")) |
| .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ |
| "enabled": false, |
| "plugins": [] |
| }))) |
| .expect(1) |
| .mount(&server) |
| .await; |
|
|
| let mut config = load_config(tmp.path(), tmp.path()).await; |
| config.chatgpt_base_url = server.uri(); |
| let manager = test_plugins_manager(tmp.path().to_path_buf()); |
| let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); |
| assert_eq!( |
| manager |
| .recommended_plugins_mode_for_config(&config, Some(&auth)) |
| .await, |
| RecommendedPluginsMode::Legacy |
| ); |
| assert_eq!( |
| manager |
| .recommended_plugins_mode_for_config(&config, Some(&auth)) |
| .await, |
| RecommendedPluginsMode::Legacy |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn recommended_plugins_mode_retries_after_fetch_failure() { |
| let tmp = tempfile::tempdir().unwrap(); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| "#, |
| ); |
|
|
| let server = MockServer::start().await; |
| Mock::given(method("GET")) |
| .and(path("/ps/plugins/suggested/codex")) |
| .respond_with(ResponseTemplate::new(500).set_body_string("unavailable")) |
| .expect(1) |
| .mount(&server) |
| .await; |
|
|
| let mut config = load_config(tmp.path(), tmp.path()).await; |
| config.chatgpt_base_url = server.uri(); |
| let manager = test_plugins_manager(tmp.path().to_path_buf()); |
| let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); |
| assert_eq!( |
| manager |
| .recommended_plugins_mode_for_config(&config, Some(&auth)) |
| .await, |
| RecommendedPluginsMode::Legacy |
| ); |
|
|
| server.reset().await; |
| Mock::given(method("GET")) |
| .and(path("/ps/plugins/suggested/codex")) |
| .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ |
| "enabled": true, |
| "plugins": [] |
| }))) |
| .expect(1) |
| .mount(&server) |
| .await; |
|
|
| assert_eq!( |
| manager |
| .recommended_plugins_mode_for_config(&config, Some(&auth)) |
| .await, |
| RecommendedPluginsMode::Endpoint { |
| plugins: Vec::new() |
| } |
| ); |
| } |
|
|
| #[test] |
| fn refresh_curated_plugin_cache_replaces_existing_local_version_with_short_sha_version() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let curated_root = curated_plugins_repo_path(tmp.path()); |
| write_openai_curated_marketplace(&curated_root, &["slack"]); |
| write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); |
| let plugin_id = PluginId::new( |
| "slack".to_string(), |
| OPENAI_CURATED_MARKETPLACE_NAME.to_string(), |
| ) |
| .unwrap(); |
| write_plugin( |
| &tmp.path().join("plugins/cache/openai-curated"), |
| "slack/local", |
| "slack", |
| ); |
|
|
| assert!( |
| refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) |
| .expect("cache refresh should succeed") |
| ); |
|
|
| assert!( |
| !tmp.path() |
| .join("plugins/cache/openai-curated/slack/local") |
| .exists() |
| ); |
| assert!( |
| tmp.path() |
| .join(format!( |
| "plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}" |
| )) |
| .is_dir() |
| ); |
| } |
|
|
| #[test] |
| fn refresh_curated_plugin_cache_reinstalls_missing_configured_plugin_with_current_short_version() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let curated_root = curated_plugins_repo_path(tmp.path()); |
| write_openai_curated_marketplace(&curated_root, &["slack"]); |
| write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); |
| let plugin_id = PluginId::new( |
| "slack".to_string(), |
| OPENAI_CURATED_MARKETPLACE_NAME.to_string(), |
| ) |
| .unwrap(); |
|
|
| assert!( |
| refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) |
| .expect("cache refresh should recreate missing configured plugin") |
| ); |
|
|
| assert!( |
| tmp.path() |
| .join(format!( |
| "plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}" |
| )) |
| .is_dir() |
| ); |
| } |
|
|
| #[test] |
| fn refresh_curated_plugin_cache_reinstalls_missing_api_curated_plugin() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let curated_root = curated_plugins_repo_path(tmp.path()); |
| write_openai_curated_marketplace(&curated_root, &[]); |
| write_openai_api_curated_marketplace(&curated_root, &["api-only"]); |
| write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); |
| let plugin_id = PluginId::new( |
| "api-only".to_string(), |
| OPENAI_API_CURATED_MARKETPLACE_NAME.to_string(), |
| ) |
| .unwrap(); |
|
|
| assert!( |
| refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) |
| .expect("cache refresh should recreate missing configured API curated plugin") |
| ); |
|
|
| assert!( |
| tmp.path() |
| .join(format!( |
| "plugins/cache/openai-api-curated/api-only/{TEST_CURATED_PLUGIN_CACHE_VERSION}" |
| )) |
| .is_dir() |
| ); |
| } |
|
|
| #[test] |
| fn refresh_curated_plugin_cache_leaves_api_curated_plugin_when_api_manifest_missing() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let curated_root = curated_plugins_repo_path(tmp.path()); |
| write_openai_curated_marketplace(&curated_root, &[]); |
| write_cached_plugin(tmp.path(), OPENAI_API_CURATED_MARKETPLACE_NAME, "api-only"); |
| let plugin_id = PluginId::new( |
| "api-only".to_string(), |
| OPENAI_API_CURATED_MARKETPLACE_NAME.to_string(), |
| ) |
| .unwrap(); |
|
|
| assert!( |
| !refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) |
| .expect("cache refresh should skip missing API curated manifest") |
| ); |
| assert!( |
| tmp.path() |
| .join("plugins/cache/openai-api-curated/api-only/local") |
| .is_dir() |
| ); |
| } |
|
|
| #[test] |
| fn refresh_curated_plugin_cache_removes_cache_for_plugin_removed_from_marketplace() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let curated_root = curated_plugins_repo_path(tmp.path()); |
| write_openai_curated_marketplace(&curated_root, &[]); |
| let plugin_id = PluginId::new( |
| "google-sheets".to_string(), |
| OPENAI_CURATED_MARKETPLACE_NAME.to_string(), |
| ) |
| .unwrap(); |
| let plugin_cache_root = tmp |
| .path() |
| .join("plugins/cache/openai-curated/google-sheets"); |
| write_plugin( |
| &tmp.path().join("plugins/cache/openai-curated"), |
| &format!("google-sheets/{TEST_CURATED_PLUGIN_CACHE_VERSION}"), |
| "google-sheets", |
| ); |
|
|
| assert!( |
| refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) |
| .expect("cache refresh should remove stale configured plugin") |
| ); |
|
|
| assert!(!plugin_cache_root.exists()); |
| } |
|
|
| #[test] |
| fn curated_plugin_ids_from_config_keys_reads_latest_codex_home_user_config() { |
| let tmp = tempfile::tempdir().unwrap(); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| |
| [plugins."slack@openai-curated"] |
| enabled = true |
| |
| [plugins."api-only@openai-api-curated"] |
| enabled = true |
| |
| [plugins."sample@debug"] |
| enabled = true |
| "#, |
| ); |
|
|
| assert_eq!( |
| configured_curated_plugin_ids_from_codex_home(tmp.path()) |
| .into_iter() |
| .map(|plugin_id| plugin_id.as_key()) |
| .collect::<Vec<_>>(), |
| vec![ |
| "api-only@openai-api-curated".to_string(), |
| "slack@openai-curated".to_string(), |
| ] |
| ); |
|
|
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| "#, |
| ); |
|
|
| assert_eq!( |
| configured_curated_plugin_ids_from_codex_home(tmp.path()), |
| Vec::<PluginId>::new() |
| ); |
| } |
|
|
| #[test] |
| fn refresh_curated_plugin_cache_returns_false_when_configured_plugins_are_current() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let curated_root = curated_plugins_repo_path(tmp.path()); |
| write_openai_curated_marketplace(&curated_root, &["slack"]); |
| let plugin_id = PluginId::new( |
| "slack".to_string(), |
| OPENAI_CURATED_MARKETPLACE_NAME.to_string(), |
| ) |
| .unwrap(); |
| write_plugin( |
| &tmp.path().join("plugins/cache/openai-curated"), |
| &format!("slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}"), |
| "slack", |
| ); |
|
|
| assert!( |
| !refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) |
| .expect("cache refresh should be a no-op when configured plugins are current") |
| ); |
| } |
|
|
| #[test] |
| fn refresh_curated_plugin_cache_migrates_full_sha_cache_version_to_short_version() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let curated_root = curated_plugins_repo_path(tmp.path()); |
| write_openai_curated_marketplace(&curated_root, &["slack"]); |
| let plugin_id = PluginId::new( |
| "slack".to_string(), |
| OPENAI_CURATED_MARKETPLACE_NAME.to_string(), |
| ) |
| .unwrap(); |
| write_plugin( |
| &tmp.path().join("plugins/cache/openai-curated"), |
| &format!("slack/{TEST_CURATED_PLUGIN_SHA}"), |
| "slack", |
| ); |
|
|
| assert!( |
| refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) |
| .expect("cache refresh should migrate the full sha cache version") |
| ); |
| assert!( |
| !tmp.path() |
| .join(format!( |
| "plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_SHA}" |
| )) |
| .exists() |
| ); |
| assert!( |
| tmp.path() |
| .join(format!( |
| "plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}" |
| )) |
| .is_dir() |
| ); |
| } |
|
|
| #[test] |
| fn refresh_non_curated_plugin_cache_replaces_existing_local_version_with_manifest_version() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); |
| write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3")); |
| write_file( |
| &repo_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "sample-plugin", |
| "source": { |
| "source": "local", |
| "path": "./sample-plugin" |
| } |
| } |
| ] |
| }"#, |
| ); |
| write_plugin( |
| &tmp.path().join("plugins/cache/debug"), |
| "sample-plugin/local", |
| "sample-plugin", |
| ); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| |
| [plugins."sample-plugin@debug"] |
| enabled = true |
| "#, |
| ); |
|
|
| assert!( |
| refresh_non_curated_plugin_cache( |
| tmp.path(), |
| &[AbsolutePathBuf::try_from(repo_root).unwrap()], |
| &["sample-plugin@debug".to_string()], |
| ) |
| .expect("cache refresh should succeed") |
| ); |
|
|
| assert!( |
| !tmp.path() |
| .join("plugins/cache/debug/sample-plugin/local") |
| .exists() |
| ); |
| assert!( |
| tmp.path() |
| .join("plugins/cache/debug/sample-plugin/1.2.3") |
| .is_dir() |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn non_curated_plugin_cache_refresh_preserves_manual_priority() { |
| let codex_home = TempDir::new().unwrap(); |
| let marketplace_root = codex_home.path().join("marketplace"); |
| fs::create_dir_all(marketplace_root.join(".git")).unwrap(); |
| write_plugin_with_version( |
| &marketplace_root, |
| "sample-plugin", |
| "sample-plugin", |
| Some("1.0.0"), |
| ); |
| write_file( |
| &marketplace_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [{ |
| "name": "sample-plugin", |
| "source": { "source": "local", "path": "./sample-plugin" } |
| }] |
| }"#, |
| ); |
| write_file( |
| &codex_home.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| |
| [plugins."sample-plugin@debug"] |
| enabled = true |
| "#, |
| ); |
| let config = load_plugins_config_input(codex_home.path(), &marketplace_root).await; |
| let context = PluginMarketplaceContext { |
| global_config: config.clone(), |
| scopes: vec![PluginMarketplaceScope { |
| cwd: Some(AbsolutePathBuf::try_from(marketplace_root.clone()).unwrap()), |
| config, |
| }], |
| load_errors: Vec::new(), |
| }; |
| let manager = Arc::new(test_plugins_manager(codex_home.path().to_path_buf())); |
| manager |
| .non_curated_cache_refresh_state |
| .write() |
| .expect("refresh state lock") |
| .in_flight = true; |
| let marketplaces = manager |
| .list_marketplaces_for_context(&context, false) |
| .unwrap() |
| .marketplaces; |
|
|
| for git_mode in [ |
| PluginGitMode::Automatic, |
| PluginGitMode::Manual, |
| PluginGitMode::Automatic, |
| ] { |
| let request = context |
| .non_curated_cache_refresh_request( |
| &manager, |
| &marketplaces, |
| NonCuratedCacheRefreshMode::IfVersionChanged, |
| git_mode, |
| ) |
| .expect("refresh request"); |
| manager.schedule_non_curated_plugin_cache_refresh(request); |
| } |
|
|
| { |
| let mut state = manager |
| .non_curated_cache_refresh_state |
| .write() |
| .expect("refresh state lock"); |
| assert!(matches!( |
| state.requested.as_ref().map(|request| request.git_mode), |
| Some(PluginGitMode::Manual) |
| )); |
| state.last_refreshed = state.requested.take(); |
| state.in_flight = false; |
| } |
|
|
| manager.maybe_start_non_curated_plugin_cache_refresh(&context, &marketplaces); |
|
|
| { |
| let mut state = manager |
| .non_curated_cache_refresh_state |
| .write() |
| .expect("refresh state lock"); |
| assert!(!state.in_flight); |
| assert!(state.requested.is_none()); |
| state.requested = state.last_refreshed.clone(); |
| state.in_flight = true; |
| } |
|
|
| write_plugin_with_version( |
| &marketplace_root, |
| "sample-plugin", |
| "sample-plugin", |
| Some("2.0.0"), |
| ); |
| let marketplaces = manager |
| .list_marketplaces_for_context(&context, false) |
| .unwrap() |
| .marketplaces; |
| manager.maybe_start_non_curated_plugin_cache_refresh(&context, &marketplaces); |
|
|
| let state = manager |
| .non_curated_cache_refresh_state |
| .read() |
| .expect("refresh state lock"); |
| let request = state.requested.as_ref().expect("pending refresh"); |
| assert!(matches!(request.git_mode, PluginGitMode::Automatic)); |
| assert_eq!( |
| request.configured_plugin_sources[0] |
| .local_version |
| .as_deref(), |
| Some("2.0.0") |
| ); |
| } |
|
|
| #[test] |
| fn refresh_non_curated_plugin_cache_reinstalls_missing_configured_plugin_with_manifest_version() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); |
| write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3")); |
| write_file( |
| &repo_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "sample-plugin", |
| "source": { |
| "source": "local", |
| "path": "./sample-plugin" |
| } |
| } |
| ] |
| }"#, |
| ); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| |
| [plugins."sample-plugin@debug"] |
| enabled = true |
| "#, |
| ); |
|
|
| assert!( |
| refresh_non_curated_plugin_cache( |
| tmp.path(), |
| &[AbsolutePathBuf::try_from(repo_root).unwrap()], |
| &["sample-plugin@debug".to_string()], |
| ) |
| .expect("cache refresh should reinstall missing configured plugin") |
| ); |
|
|
| assert!( |
| tmp.path() |
| .join("plugins/cache/debug/sample-plugin/1.2.3") |
| .is_dir() |
| ); |
| } |
|
|
| #[test] |
| fn refresh_non_curated_plugin_cache_refreshes_configured_git_source() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| let remote_repo = tmp.path().join("remote-plugin-repo"); |
| let remote_repo_url = url::Url::from_directory_path(&remote_repo) |
| .unwrap() |
| .to_string(); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| write_plugin_with_version( |
| &remote_repo, |
| "plugins/sample-plugin", |
| "sample-plugin", |
| Some("1.2.3"), |
| ); |
| init_git_repo(&remote_repo); |
| write_file( |
| &repo_root.join(".agents/plugins/marketplace.json"), |
| &format!( |
| r#"{{ |
| "name": "debug", |
| "plugins": [ |
| {{ |
| "name": "sample-plugin", |
| "source": {{ |
| "source": "git-subdir", |
| "url": "{remote_repo_url}", |
| "path": "plugins/sample-plugin" |
| }} |
| }} |
| ] |
| }}"# |
| ), |
| ); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| |
| [plugins."sample-plugin@debug"] |
| enabled = true |
| "#, |
| ); |
|
|
| assert!( |
| refresh_non_curated_plugin_cache( |
| tmp.path(), |
| &[AbsolutePathBuf::try_from(repo_root).unwrap()], |
| &["sample-plugin@debug".to_string()], |
| ) |
| .expect("cache refresh should materialize configured Git plugin") |
| ); |
|
|
| assert!( |
| tmp.path() |
| .join("plugins/cache/debug/sample-plugin/1.2.3") |
| .is_dir() |
| ); |
| } |
|
|
| #[test] |
| fn refresh_non_curated_plugin_cache_returns_false_when_configured_plugins_are_current() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); |
| write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3")); |
| write_file( |
| &repo_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "sample-plugin", |
| "source": { |
| "source": "local", |
| "path": "./sample-plugin" |
| } |
| } |
| ] |
| }"#, |
| ); |
| write_plugin_with_version( |
| &tmp.path().join("plugins/cache/debug"), |
| "sample-plugin/1.2.3", |
| "sample-plugin", |
| Some("1.2.3"), |
| ); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| |
| [plugins."sample-plugin@debug"] |
| enabled = true |
| "#, |
| ); |
|
|
| assert!( |
| !refresh_non_curated_plugin_cache( |
| tmp.path(), |
| &[AbsolutePathBuf::try_from(repo_root).unwrap()], |
| &["sample-plugin@debug".to_string()], |
| ) |
| .expect("cache refresh should be a no-op when configured plugins are current") |
| ); |
| } |
|
|
| #[test] |
| fn refresh_non_curated_plugin_cache_force_reinstalls_current_local_version() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); |
| write_plugin(&repo_root, "sample-plugin", "sample-plugin"); |
| fs::write(repo_root.join("sample-plugin/skills/SKILL.md"), "new skill").unwrap(); |
| write_file( |
| &repo_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "sample-plugin", |
| "source": { |
| "source": "local", |
| "path": "./sample-plugin" |
| } |
| } |
| ] |
| }"#, |
| ); |
| write_plugin( |
| &tmp.path().join("plugins/cache/debug"), |
| "sample-plugin/local", |
| "sample-plugin", |
| ); |
| fs::write( |
| tmp.path() |
| .join("plugins/cache/debug/sample-plugin/local/skills/SKILL.md"), |
| "old skill", |
| ) |
| .unwrap(); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| |
| [plugins."sample-plugin@debug"] |
| enabled = true |
| "#, |
| ); |
|
|
| assert!( |
| refresh_non_curated_plugin_cache_force_reinstall( |
| tmp.path(), |
| &[AbsolutePathBuf::try_from(repo_root).unwrap()], |
| &["sample-plugin@debug".to_string()], |
| ) |
| .expect("cache refresh should reinstall unchanged local version") |
| ); |
|
|
| assert_eq!( |
| fs::read_to_string( |
| tmp.path() |
| .join("plugins/cache/debug/sample-plugin/local/skills/SKILL.md") |
| ) |
| .unwrap(), |
| "new skill" |
| ); |
| } |
|
|
| #[test] |
| fn refresh_non_curated_plugin_cache_ignores_invalid_unconfigured_plugin_versions() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); |
| write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3")); |
| write_plugin_with_version(&repo_root, "broken-plugin", "broken-plugin", Some(" ")); |
| write_file( |
| &repo_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "sample-plugin", |
| "source": { |
| "source": "local", |
| "path": "./sample-plugin" |
| } |
| }, |
| { |
| "name": "broken-plugin", |
| "source": { |
| "source": "local", |
| "path": "./broken-plugin" |
| } |
| } |
| ] |
| }"#, |
| ); |
| write_file( |
| &tmp.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| |
| [plugins."sample-plugin@debug"] |
| enabled = true |
| "#, |
| ); |
|
|
| assert!( |
| refresh_non_curated_plugin_cache( |
| tmp.path(), |
| &[AbsolutePathBuf::try_from(repo_root).unwrap()], |
| &["sample-plugin@debug".to_string()], |
| ) |
| .expect("cache refresh should ignore unrelated invalid plugin manifests") |
| ); |
|
|
| assert!( |
| tmp.path() |
| .join("plugins/cache/debug/sample-plugin/1.2.3") |
| .is_dir() |
| ); |
| } |
|
|
| #[test] |
| fn refresh_non_curated_plugin_cache_continues_after_plugin_error() { |
| let tmp = tempfile::tempdir().unwrap(); |
| let repo_root = tmp.path().join("repo"); |
| fs::create_dir_all(repo_root.join(".git")).unwrap(); |
| fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); |
| write_plugin_with_version(&repo_root, "z-good", "z-good", Some("1.2.3")); |
| write_file( |
| &repo_root.join(".agents/plugins/marketplace.json"), |
| r#"{ |
| "name": "debug", |
| "plugins": [ |
| { |
| "name": "a-broken", |
| "source": { |
| "source": "local", |
| "path": "./missing" |
| } |
| }, |
| { |
| "name": "z-good", |
| "source": { |
| "source": "local", |
| "path": "./z-good" |
| } |
| } |
| ] |
| }"#, |
| ); |
|
|
| let err = refresh_non_curated_plugin_cache( |
| tmp.path(), |
| &[AbsolutePathBuf::try_from(repo_root).unwrap()], |
| &["a-broken@debug".to_string(), "z-good@debug".to_string()], |
| ) |
| .expect_err("broken plugin should be reported after refreshing the remaining plugins"); |
|
|
| assert!(err.contains("a-broken@debug")); |
| assert!(tmp.path().join("plugins/cache/debug/z-good/1.2.3").is_dir()); |
| } |
|
|
| #[tokio::test] |
| async fn load_plugins_uses_project_config_files() { |
| let codex_home = TempDir::new().unwrap(); |
| let project_root = codex_home.path().join("project"); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/sample/local"); |
|
|
| write_file( |
| &plugin_root.join(".codex-plugin/plugin.json"), |
| r#"{"name":"sample"}"#, |
| ); |
| write_file( |
| &project_root.join(".codex/config.toml"), |
| &plugin_config_toml( true, true), |
| ); |
|
|
| let stack = ConfigLayerStack::new( |
| vec![ConfigLayerEntry::new( |
| ConfigLayerSource::Project { |
| dot_codex_folder: AbsolutePathBuf::try_from(project_root.join(".codex")).unwrap(), |
| }, |
| toml::from_str(&plugin_config_toml( |
| true, true, |
| )) |
| .expect("project config should parse"), |
| )], |
| ConfigRequirements::default(), |
| ConfigRequirementsToml::default(), |
| ) |
| .expect("config layer stack should build"); |
|
|
| let plugins = load_plugins_from_layer_stack( |
| &stack, |
| crate::remote_plugin_id_resolver::RemoteInstalledPluginsSnapshot::default(), |
| &PluginStore::new(codex_home.path().to_path_buf()), |
| None, |
| Some(Product::Codex), |
| false, |
| test_skill_root_loader().as_ref(), |
| &BTreeSet::new(), |
| ) |
| .await; |
|
|
| assert_eq!( |
| plugins |
| .iter() |
| .map(|plugin| (plugin.config_name.as_str(), plugin.enabled)) |
| .collect::<Vec<_>>(), |
| vec![("sample@test", true)] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn plugin_hooks_for_layer_stack_loads_configured_plugin_hooks() { |
| let codex_home = TempDir::new().unwrap(); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache") |
| .join("test/sample/local"); |
| write_plugin( |
| codex_home.path().join("plugins/cache/test").as_path(), |
| "sample/local", |
| "sample", |
| ); |
| write_file( |
| &plugin_root.join("hooks/hooks.json"), |
| r#"{ |
| "hooks": { |
| "SessionStart": [ |
| { |
| "hooks": [ |
| { |
| "type": "command", |
| "command": "echo startup" |
| } |
| ] |
| } |
| ] |
| } |
| }"#, |
| ); |
| write_file( |
| &codex_home.path().join(CONFIG_TOML_FILE), |
| &plugin_config_toml( true, true), |
| ); |
| let config = load_config(codex_home.path(), codex_home.path()).await; |
|
|
| let outcome = test_plugins_manager(codex_home.path().to_path_buf()) |
| .plugin_hooks_for_layer_stack(&config.config_layer_stack, &config) |
| .await; |
|
|
| assert_eq!(outcome.hook_sources.len(), 1); |
| assert_eq!( |
| outcome.hook_sources[0].source_relative_path, |
| "hooks/hooks.json" |
| ); |
| assert_eq!(outcome.hook_load_warnings, Vec::<String>::new()); |
| } |
|
|
| #[tokio::test] |
| async fn plugin_hooks_for_layer_stack_follow_auth_mode_and_provider() { |
| let codex_home = TempDir::new().unwrap(); |
| write_file( |
| &codex_home.path().join(CONFIG_TOML_FILE), |
| r#"model_provider = "amazon-bedrock" |
| |
| [features] |
| plugins = true |
| remote_plugin = false |
| |
| [plugins."linear@openai-curated"] |
| enabled = true |
| |
| [plugins."linear@openai-api-curated"] |
| enabled = true |
| "#, |
| ); |
| for marketplace_name in [ |
| OPENAI_CURATED_MARKETPLACE_NAME, |
| OPENAI_API_CURATED_MARKETPLACE_NAME, |
| ] { |
| write_cached_plugin(codex_home.path(), marketplace_name, "linear"); |
| write_file( |
| &codex_home |
| .path() |
| .join("plugins/cache") |
| .join(marketplace_name) |
| .join("linear/local/hooks/hooks.json"), |
| r#"{ |
| "hooks": { |
| "SessionStart": [ |
| { |
| "hooks": [ |
| { |
| "type": "command", |
| "command": "echo startup" |
| } |
| ] |
| } |
| ] |
| } |
| }"#, |
| ); |
| } |
| let config = load_config(codex_home.path(), codex_home.path()).await; |
| let auth_manager = test_auth_manager(Some(AuthMode::Chatgpt)); |
| let manager = test_plugins_manager_with_auth_manager( |
| codex_home.path().to_path_buf(), |
| Some(Product::Codex), |
| Arc::clone(&auth_manager), |
| ); |
|
|
| let chatgpt_hooks = manager |
| .plugin_hooks_for_layer_stack(&config.config_layer_stack, &config) |
| .await; |
| assert_eq!( |
| chatgpt_hooks |
| .hook_sources |
| .iter() |
| .map(|source| source.plugin_id.as_key()) |
| .collect::<Vec<_>>(), |
| vec!["linear@openai-curated"] |
| ); |
|
|
| set_test_auth_mode(&auth_manager, Some(AuthMode::ApiKey)).await; |
| let api_hooks = manager |
| .plugin_hooks_for_layer_stack(&config.config_layer_stack, &config) |
| .await; |
| assert_eq!( |
| api_hooks |
| .hook_sources |
| .iter() |
| .map(|source| source.plugin_id.as_key()) |
| .collect::<Vec<_>>(), |
| vec!["linear@openai-api-curated"] |
| ); |
|
|
| set_test_auth_mode(&auth_manager, None).await; |
| let bedrock_hooks = manager |
| .plugin_hooks_for_layer_stack(&config.config_layer_stack, &config) |
| .await; |
| assert_eq!( |
| bedrock_hooks |
| .hook_sources |
| .iter() |
| .map(|source| source.plugin_id.as_key()) |
| .collect::<Vec<_>>(), |
| vec!["linear@openai-api-curated"] |
| ); |
| } |
|
|
| #[test] |
| fn remote_installed_plugins_cache_refresh_coalesces_materializations() { |
| let tmp = TempDir::new().unwrap(); |
| let manager = std::sync::Arc::new(test_plugins_manager(tmp.path().to_path_buf())); |
| let materialization_callback_count = |
| std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); |
| let unrelated_callback_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); |
| manager |
| .remote_installed_plugins_cache_refresh_state |
| .write() |
| .expect("refresh state lock") |
| .in_flight = true; |
| let materialization = |name: &str| RemotePluginMaterialization { |
| plugin_id: PluginId::new( |
| name.to_string(), |
| REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(), |
| ) |
| .expect("valid plugin id"), |
| scope: crate::remote::RemotePluginScope::Workspace, |
| discoverability: Some(crate::remote::RemotePluginShareDiscoverability::Listed), |
| authenticated_account_id: Some("account-123".to_string()), |
| }; |
| let change = |name: &str| EffectivePluginsChange { |
| materialized_remote_plugins: vec![materialization(name)], |
| }; |
| let callback = |count: std::sync::Arc<std::sync::atomic::AtomicUsize>| { |
| let callback: EffectivePluginsChangedCallback = std::sync::Arc::new(move |_change| { |
| count.fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
| }); |
| callback |
| }; |
| let generation = manager |
| .prepare_remote_installed_plugins_cache_generation( None) |
| .expect("prepare remote installed cache generation"); |
| let request = |
| |change, on_effective_plugins_changed| RemoteInstalledPluginsCacheRefreshRequest { |
| generation, |
| service_config: RemotePluginServiceConfig::new( |
| "https://example.com".to_string(), |
| test_http_client_factory(), |
| ), |
| auth: None, |
| notify: RemoteInstalledPluginsCacheRefreshNotify::IfCacheChanged, |
| on_effective_plugins_changed: Some(on_effective_plugins_changed), |
| change, |
| }; |
|
|
| manager.schedule_remote_installed_plugins_cache_refresh(request( |
| change("beta"), |
| callback(std::sync::Arc::clone(&materialization_callback_count)), |
| )); |
| manager.schedule_remote_installed_plugins_cache_refresh(request( |
| change("alpha"), |
| callback(std::sync::Arc::clone(&unrelated_callback_count)), |
| )); |
|
|
| let state = manager |
| .remote_installed_plugins_cache_refresh_state |
| .read() |
| .expect("refresh state lock"); |
| let request = state.requested.as_ref().expect("pending refresh"); |
| assert_eq!( |
| request.change, |
| EffectivePluginsChange { |
| materialized_remote_plugins: vec![materialization("alpha"), materialization("beta"),], |
| } |
| ); |
| request |
| .on_effective_plugins_changed |
| .as_ref() |
| .expect("pending callback")(request.change.clone()); |
| assert_eq!( |
| materialization_callback_count.load(std::sync::atomic::Ordering::Relaxed), |
| 1 |
| ); |
| assert_eq!( |
| unrelated_callback_count.load(std::sync::atomic::Ordering::Relaxed), |
| 0 |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn background_remote_installed_bundle_sync_stays_deduped() { |
| let codex_home = TempDir::new().unwrap(); |
| write_file( |
| &codex_home.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| remote_plugin = true |
| "#, |
| ); |
| let server = MockServer::start().await; |
| Mock::given(method("GET")) |
| .and(path("/backend-api/ps/plugins/installed")) |
| .and(query_param("includeDownloadUrls", "true")) |
| .respond_with( |
| ResponseTemplate::new(200) |
| .set_delay(Duration::from_millis(200)) |
| .set_body_json(serde_json::json!({ |
| "plugins": [], |
| "pagination": {"next_page_token": null}, |
| })), |
| ) |
| .expect(1) |
| .mount(&server) |
| .await; |
|
|
| let mut config = load_config(codex_home.path(), codex_home.path()).await; |
| config.chatgpt_base_url = format!("{}/backend-api", server.uri()); |
| let first_manager = std::sync::Arc::new(test_plugins_manager_with_options( |
| codex_home.path().to_path_buf(), |
| Some(Product::Codex), |
| Some(AuthMode::Chatgpt), |
| )); |
| let second_manager = std::sync::Arc::new(test_plugins_manager_with_options( |
| codex_home.path().to_path_buf(), |
| Some(Product::Codex), |
| Some(AuthMode::Chatgpt), |
| )); |
| let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); |
|
|
| first_manager.maybe_start_remote_installed_plugin_bundle_sync( |
| &config, |
| Some(auth.clone()), |
| None, |
| ); |
| second_manager.maybe_start_remote_installed_plugin_bundle_sync( |
| &config, |
| Some(auth), |
| None, |
| ); |
|
|
| tokio::time::sleep(Duration::from_millis(400)).await; |
| server.verify().await; |
| } |
|
|
| #[tokio::test] |
| async fn sites_migration_throttles_absence_without_blocking_normal_sync() { |
| let home = TempDir::new().unwrap(); |
| write_file( |
| &home.path().join(CONFIG_TOML_FILE), |
| "[features]\nplugins = true\nremote_plugin = true\n", |
| ); |
| let server = MockServer::start().await; |
| let mut config = load_config(home.path(), home.path()).await; |
| config.chatgpt_base_url = format!("{}/backend-api", server.uri()); |
| let manager = test_plugins_manager_with_options( |
| home.path().to_path_buf(), |
| Some(Product::Codex), |
| Some(AuthMode::Chatgpt), |
| ); |
| let auth = manager.auth_manager.auth_cached().unwrap(); |
| let installed_path = "/backend-api/ps/plugins/installed"; |
| let failed_check = Mock::given(method("GET")) |
| .and(path(installed_path)) |
| .respond_with(ResponseTemplate::new(503)) |
| .mount_as_scoped(&server) |
| .await; |
| assert!( |
| manager |
| .ensure_sites_migration_ready(&config, Some(&auth)) |
| .await |
| .is_err() |
| ); |
| drop(failed_check); |
| Mock::given(method("GET")) |
| .and(path(installed_path)) |
| .and(query_param_is_missing("includeDownloadUrls")) |
| .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ |
| "plugins": [], "pagination": {"next_page_token": null} |
| }))) |
| .expect(1) |
| .mount(&server) |
| .await; |
| |
| let unrelated_sync = manager |
| .acquire_remote_installed_plugin_sync_guard() |
| .await |
| .unwrap(); |
| for _ in 0..2 { |
| assert!( |
| tokio::time::timeout( |
| Duration::from_secs(1), |
| manager.ensure_sites_migration_ready(&config, Some(&auth)), |
| ) |
| .await |
| .expect("Sites absence must not wait for unrelated bundle downloads") |
| .unwrap() |
| .is_none() |
| ); |
| } |
| drop(unrelated_sync); |
| Mock::given(method("GET")) |
| .and(path(installed_path)) |
| .and(query_param("includeDownloadUrls", "true")) |
| .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ |
| "plugins": [], "pagination": {"next_page_token": null} |
| }))) |
| .expect(1) |
| .mount(&server) |
| .await; |
| manager |
| .reconcile_remote_installed_plugins(&config, Some(&auth)) |
| .await |
| .unwrap(); |
| } |
|
|
| #[tokio::test] |
| async fn sites_migration_persists_only_exclusion_without_repeating_rollout() { |
| let home = TempDir::new().unwrap(); |
| let marketplace_root = home.path().join(".tmp/bundled-marketplaces/openai-bundled"); |
| write_file( |
| &home.path().join(CONFIG_TOML_FILE), |
| &format!( |
| "[features]\nplugins = true\nremote_plugin = true\n[plugins.\"sites@openai-bundled\"]\nenabled = true\n[marketplaces.openai-bundled]\nsource_type = \"local\"\nsource = {marketplace_root:?}\n" |
| ), |
| ); |
| write_cached_plugin(home.path(), "openai-bundled", "sites"); |
| write_plugin(&marketplace_root, "sites", "sites"); |
| write_file( |
| &marketplace_root.join(".agents/plugins/marketplace.json"), |
| r#"{"name":"openai-bundled","plugins":[{"name":"sites","source":{"source":"local","path":"./sites"}}]}"#, |
| ); |
| let server = MockServer::start().await; |
| let mut config = load_config(home.path(), home.path()).await; |
| config.chatgpt_base_url = format!("{}/backend-api", server.uri()); |
| let auth_manager = test_auth_manager(Some(AuthMode::Chatgpt)); |
| let auth = auth_manager.auth_cached().unwrap(); |
| Mock::given(method("GET")) |
| .and(path("/backend-api/ps/plugins/installed")) |
| .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ |
| "plugins": [{ |
| "id": "plugins~plugin_connector_1p_689987207de08191979cf68eca2941c6", |
| "name": "sites", "scope": "GLOBAL", "enabled": false, |
| "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", |
| "release": {"version": "local", "display_name": "Sites", "description": "Sites", "interface": {}} |
| }], |
| "pagination": {"next_page_token": null} |
| }))) |
| .mount(&server).await; |
| let manager = test_plugins_manager_with_auth_manager( |
| home.path().to_path_buf(), |
| Some(Product::Codex), |
| auth_manager.clone(), |
| ); |
| let has_bundled_sites = |manager: &PluginsManager| { |
| manager |
| .list_marketplaces_for_config(&config, &[], false) |
| .unwrap() |
| .marketplaces |
| .into_iter() |
| .flat_map(|marketplace| marketplace.plugins) |
| .any(|plugin| plugin.id == "sites@openai-bundled") |
| }; |
| |
| manager |
| .reconcile_remote_installed_plugins(&config, Some(&auth)) |
| .await |
| .unwrap(); |
| assert!(has_bundled_sites(&manager)); |
| assert_eq!( |
| loaded_plugin_names(&manager, &config).await, |
| vec!["sites@openai-bundled"] |
| ); |
| write_cached_plugin(home.path(), REMOTE_GLOBAL_MARKETPLACE_NAME, "sites"); |
| let read_request = PluginReadRequest { |
| plugin_name: "sites".to_string(), |
| marketplace_path: AbsolutePathBuf::try_from( |
| marketplace_root.join(".agents/plugins/marketplace.json"), |
| ) |
| .unwrap(), |
| }; |
| let install_request = PluginInstallRequest { |
| plugin_name: read_request.plugin_name.clone(), |
| marketplace_path: read_request.marketplace_path.clone(), |
| }; |
| let (first, concurrent) = tokio::join!( |
| manager.ensure_sites_migration_ready(&config, Some(&auth)), |
| manager.ensure_sites_migration_ready(&config, Some(&auth)), |
| ); |
| assert!(first.unwrap().is_some() ^ concurrent.unwrap().is_some()); |
| assert!(!manager.remote_installed_plugin_configs()["sites@openai-curated-remote"].enabled); |
| assert!( |
| server |
| .received_requests() |
| .await |
| .unwrap() |
| .iter() |
| .all(|request| request.method == "GET") |
| ); |
| let config_before_rejected_install = fs::read(home.path().join(CONFIG_TOML_FILE)).unwrap(); |
| assert!(matches!( |
| manager.read_plugin_for_config(&config, &read_request).await, |
| Err(MarketplaceError::PluginNotFound { plugin_name, marketplace_name }) |
| if plugin_name == "sites" && marketplace_name == "openai-bundled" |
| )); |
| assert!(matches!( |
| manager.install_plugin(&config, install_request.clone()).await, |
| Err(PluginInstallError::Marketplace(MarketplaceError::PluginNotFound { plugin_name, marketplace_name })) |
| if plugin_name == "sites" && marketplace_name == "openai-bundled" |
| )); |
| assert_eq!( |
| fs::read(home.path().join(CONFIG_TOML_FILE)).unwrap(), |
| config_before_rejected_install |
| ); |
| let exclusion_file = std::fs::read_dir(home.path().join("cache/bundled_plugin_exclusions")) |
| .unwrap() |
| .next() |
| .unwrap() |
| .unwrap() |
| .path(); |
| assert_eq!( |
| serde_json::from_slice::<serde_json::Value>(&std::fs::read(exclusion_file).unwrap()) |
| .unwrap(), |
| serde_json::json!({"disabled-bundled-plugin-ids": ["sites@openai-bundled"]}) |
| ); |
| let requests = server.received_requests().await.unwrap().len(); |
| let restarted = test_plugins_manager_with_auth_manager( |
| home.path().to_path_buf(), |
| Some(Product::Codex), |
| auth_manager.clone(), |
| ); |
| assert!( |
| restarted |
| .ensure_sites_migration_ready(&config, Some(&auth)) |
| .await |
| .unwrap() |
| .is_none() |
| ); |
| assert_eq!(server.received_requests().await.unwrap().len(), requests); |
| assert!(!has_bundled_sites(&restarted)); |
| assert!(restarted.remote_installed_plugin_configs().is_empty()); |
| assert!( |
| !loaded_plugin_names(&restarted, &config) |
| .await |
| .contains(&"sites@openai-bundled".to_string()) |
| ); |
|
|
| |
| let mut other_backend = config.clone(); |
| other_backend.chatgpt_base_url = format!("{}/other-backend", server.uri()); |
| assert!( |
| restarted |
| .excluded_bundled_plugin_ids(&other_backend) |
| .is_empty() |
| ); |
| assert!( |
| restarted |
| .plugin_skill_snapshots_for_config(&config) |
| .is_some() |
| ); |
| assert!( |
| restarted |
| .plugin_skill_snapshots_for_config(&other_backend) |
| .is_none() |
| ); |
| assert_eq!( |
| loaded_plugin_names(&restarted, &other_backend).await, |
| vec!["sites@openai-bundled"] |
| ); |
| assert!(loaded_plugin_names(&restarted, &config).await.is_empty()); |
|
|
| server.reset().await; |
| Mock::given(method("GET")) |
| .and(path("/backend-api/ps/plugins/installed")) |
| .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ |
| "plugins": [], "pagination": {"next_page_token": null} |
| }))) |
| .mount(&server) |
| .await; |
| restarted |
| .reconcile_remote_installed_plugins(&config, Some(&auth)) |
| .await |
| .unwrap(); |
| assert!(has_bundled_sites(&restarted)); |
| restarted |
| .read_plugin_for_config(&config, &read_request) |
| .await |
| .unwrap(); |
| restarted |
| .install_plugin(&config, install_request) |
| .await |
| .unwrap(); |
| } |
|
|
| #[tokio::test] |
| async fn reconcile_remote_installed_plugins_reports_cached_state_changes() { |
| let codex_home = TempDir::new().unwrap(); |
| write_file( |
| &codex_home.path().join(CONFIG_TOML_FILE), |
| "[features]\nplugins = true\n", |
| ); |
| write_cached_plugin( |
| codex_home.path(), |
| REMOTE_WORKSPACE_MARKETPLACE_NAME, |
| "linear", |
| ); |
| let plugin_root = codex_home |
| .path() |
| .join("plugins/cache/workspace-directory/linear/local"); |
| write_file( |
| &plugin_root.join(".mcp.json"), |
| r#"{"mcpServers":{"example":{"command":"unused"}}}"#, |
| ); |
| write_file( |
| &plugin_root.join("hooks/hooks.json"), |
| r#"{"hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"echo hook"}]}]}}"#, |
| ); |
| let server = MockServer::start().await; |
| let mut config = load_config(codex_home.path(), codex_home.path()).await; |
| config.chatgpt_base_url = format!("{}/backend-api", server.uri()); |
| let auth_manager = test_auth_manager(Some(AuthMode::Chatgpt)); |
| let auth = auth_manager.auth_cached().expect("test ChatGPT auth"); |
| let manager = test_plugins_manager_with_auth_manager( |
| codex_home.path().to_path_buf(), |
| Some(Product::Codex), |
| auth_manager, |
| ); |
| let change = RemotePluginChange { |
| plugin_id: "linear@workspace-directory".to_string(), |
| capabilities: RemotePluginCapabilities { |
| has_mcps: true, |
| has_hooks: true, |
| has_skills: true, |
| ..Default::default() |
| }, |
| }; |
| |
| |
| for (enabled, changes) in [ |
| (Some(true), Vec::new()), |
| (Some(false), vec![change.clone()]), |
| (Some(false), Vec::new()), |
| (Some(true), vec![change.clone()]), |
| (None, vec![change.clone()]), |
| (Some(true), vec![change.clone()]), |
| (Some(true), Vec::new()), |
| ] { |
| if enabled.is_none() { |
| |
| write_file( |
| &codex_home |
| .path() |
| .join("plugins/cache/openai-curated-remote"), |
| "not a directory", |
| ); |
| } |
| let plugins = enabled |
| .into_iter() |
| .map(|enabled| { |
| serde_json::json!({ |
| "id": "plugins~Plugin_linear", |
| "name": "linear", |
| "scope": "WORKSPACE", |
| "discoverability": "LISTED", |
| "installation_policy": "AVAILABLE", |
| "authentication_policy": "ON_USE", |
| "release": { |
| "version": "local", |
| "display_name": "Linear", |
| "description": "Test plugin", |
| "interface": {}, |
| }, |
| "enabled": enabled, |
| }) |
| }) |
| .collect::<Vec<_>>(); |
| |
| Mock::given(method("GET")) |
| .and(path("/backend-api/ps/plugins/installed")) |
| .and(query_param("includeDownloadUrls", "true")) |
| .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ |
| "plugins": plugins, |
| "pagination": { "next_page_token": null }, |
| }))) |
| .expect(1) |
| .mount(&server) |
| .await; |
| assert_eq!( |
| manager |
| .reconcile_remote_installed_plugins(&config, Some(&auth)) |
| .await |
| .expect("reconcile cached plugin state"), |
| RemoteInstalledPluginBundleSyncOutcome { |
| changed_plugins: changes, |
| ..Default::default() |
| } |
| ); |
| assert!(plugin_root.exists()); |
| if enabled.is_none() { |
| assert_eq!( |
| manager.plugins_for_config(&config).await, |
| PluginLoadOutcome::default() |
| ); |
| } |
| server.verify().await; |
| server.reset().await; |
| } |
| } |
|
|
| #[tokio::test] |
| async fn reconcile_remote_installed_plugins_rejects_incomplete_snapshot_without_cleanup() { |
| let codex_home = TempDir::new().unwrap(); |
| write_file( |
| &codex_home.path().join(CONFIG_TOML_FILE), |
| r#"[features] |
| plugins = true |
| remote_plugin = true |
| "#, |
| ); |
| write_cached_plugin(codex_home.path(), REMOTE_GLOBAL_MARKETPLACE_NAME, "linear"); |
| write_cached_plugin( |
| codex_home.path(), |
| REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME, |
| "malformed", |
| ); |
| write_cached_plugin( |
| codex_home.path(), |
| REMOTE_WORKSPACE_MARKETPLACE_NAME, |
| "renamed-malformed", |
| ); |
| let malformed_remote_plugin_id = "plugins~Plugin_malformed"; |
| let malformed_plugin_id = PluginId::new( |
| "malformed".to_string(), |
| REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME.to_string(), |
| ) |
| .expect("valid malformed plugin id"); |
| PluginStore::new(codex_home.path().to_path_buf()) |
| .write_remote_plugin_id(&malformed_plugin_id, malformed_remote_plugin_id) |
| .expect("persist malformed plugin identity"); |
|
|
| let server = MockServer::start().await; |
| Mock::given(method("GET")) |
| .and(path("/backend-api/ps/plugins/installed")) |
| .and(query_param("includeDownloadUrls", "true")) |
| .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ |
| "plugins": [ |
| { |
| "id": "plugins~Plugin_linear", |
| "name": "linear", |
| "scope": "GLOBAL", |
| "installation_policy": "AVAILABLE", |
| "authentication_policy": "ON_USE", |
| "status": "ENABLED", |
| "release": { |
| "version": "local", |
| "display_name": "", |
| "description": "Track work", |
| "interface": {}, |
| }, |
| "enabled": true, |
| }, |
| { |
| "id": malformed_remote_plugin_id, |
| "name": "renamed-malformed", |
| "scope": "WORKSPACE", |
| "installation_policy": "AVAILABLE", |
| "authentication_policy": "ON_USE", |
| "status": "ENABLED", |
| "release": { |
| "version": "local", |
| "display_name": "Malformed", |
| "description": "Missing discoverability", |
| "interface": {}, |
| }, |
| "enabled": true, |
| }, |
| ], |
| "pagination": {"next_page_token": null}, |
| }))) |
| .expect(1) |
| .mount(&server) |
| .await; |
|
|
| let mut config = load_config(codex_home.path(), codex_home.path()).await; |
| config.chatgpt_base_url = format!("{}/backend-api", server.uri()); |
| let auth_manager = test_auth_manager(Some(AuthMode::Chatgpt)); |
| let auth = auth_manager.auth_cached().expect("test ChatGPT auth"); |
| let manager = std::sync::Arc::new(test_plugins_manager_with_auth_manager( |
| codex_home.path().to_path_buf(), |
| Some(Product::Codex), |
| auth_manager, |
| )); |
| let mut previous_malformed = remote_installed_plugin_in_marketplace( |
| "malformed", |
| REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME, |
| ); |
| previous_malformed.id = malformed_remote_plugin_id.to_string(); |
| previous_malformed.version = Some("local".to_string()); |
| previous_malformed.enabled = false; |
| manager.write_remote_installed_plugins_cache(vec![previous_malformed.clone()]); |
|
|
| let result = manager |
| .reconcile_remote_installed_plugins(&config, Some(&auth)) |
| .await; |
|
|
| assert!(matches!( |
| result, |
| Err(RemoteInstalledPluginBundleSyncError::Catalog( |
| RemotePluginCatalogError::UnexpectedResponse(_) |
| )) |
| )); |
| server.verify().await; |
| assert_eq!( |
| manager |
| .remote_installed_plugins_cache |
| .read() |
| .expect("installed plugin cache lock") |
| .plugins |
| .clone(), |
| Some(vec![previous_malformed]) |
| ); |
| let plugin_cache_root = codex_home.path().join("plugins/cache"); |
| let renamed_malformed_cache = plugin_cache_root |
| .join(REMOTE_WORKSPACE_MARKETPLACE_NAME) |
| .join("renamed-malformed"); |
| let previous_malformed_cache = plugin_cache_root |
| .join(REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME) |
| .join("malformed"); |
| let linear_metadata = plugin_cache_root |
| .join(REMOTE_GLOBAL_MARKETPLACE_NAME) |
| .join("linear") |
| .join(".codex-remote-plugin-install.json"); |
| assert!(renamed_malformed_cache.exists()); |
| assert!(previous_malformed_cache.exists()); |
| assert!(!linear_metadata.exists()); |
| } |
|
|
| #[test] |
| fn reconciliation_fences_refresh_publication_and_recovers_after_cancellation() { |
| let codex_home = TempDir::new().unwrap(); |
| let manager = test_plugins_manager(codex_home.path().to_path_buf()); |
| let reconcile_generation = manager |
| .begin_remote_installed_plugins_reconcile( None) |
| .expect("begin reconciliation"); |
| let stale_refresh_generation = manager |
| .remote_installed_plugins_cache_generation_if_current( None) |
| .expect("capture concurrent refresh generation"); |
|
|
| assert_eq!( |
| manager.write_remote_installed_plugins_cache_snapshot( |
| stale_refresh_generation, |
| Vec::new(), |
| None, |
| "", |
| RemoteInstalledPluginsCachePublication::Refresh, |
| ), |
| None |
| ); |
| assert_eq!( |
| manager.write_remote_installed_plugins_cache_snapshot( |
| reconcile_generation, |
| vec![remote_installed_linear_plugin()], |
| None, |
| "", |
| RemoteInstalledPluginsCachePublication::Reconcile, |
| ), |
| Some(true) |
| ); |
| let cancelled_generation = manager |
| .begin_remote_installed_plugins_reconcile( None) |
| .expect("begin cancelled reconciliation"); |
| let reconciliation = RemoteInstalledPluginsReconciliationGuard { |
| manager: &manager, |
| generation: cancelled_generation, |
| committed: false, |
| }; |
|
|
| drop(reconciliation); |
|
|
| let refresh_generation = manager |
| .remote_installed_plugins_cache_generation_if_current( None) |
| .expect("capture refresh generation after cancellation"); |
| assert_eq!( |
| manager.write_remote_installed_plugins_cache_snapshot( |
| refresh_generation, |
| vec![remote_installed_plugin("beta")], |
| None, |
| "", |
| RemoteInstalledPluginsCachePublication::Refresh, |
| ), |
| Some(true) |
| ); |
| let retry_generation = manager |
| .begin_remote_installed_plugins_reconcile( None) |
| .expect("begin retry after cancellation"); |
| assert_eq!( |
| manager.write_remote_installed_plugins_cache_snapshot( |
| retry_generation, |
| vec![remote_installed_plugin("beta")], |
| None, |
| "", |
| RemoteInstalledPluginsCachePublication::Reconcile, |
| ), |
| Some(true) |
| ); |
| } |
|
|
| #[test] |
| fn plugin_install_error_preserves_store_io_sub_error_type() { |
| let error = PluginInstallError::Store(PluginStoreError::Io { |
| context: "failed to copy plugin file", |
| source: std::io::Error::other("copy failed"), |
| }); |
|
|
| assert_eq!( |
| error.sub_error_type(), |
| Some("failed_to_copy_plugin_file".to_string()) |
| ); |
| } |
|
|