Download codex-rs/core-plugins/src/manager_tests.rs from SaylorTwift/codex: direct link, hf CLI and curl.
- Browser
- Download file 251 kB
-
https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/core-plugins/src/manager_tests.rs
- Command line
-
hf download hf://SaylorTwift/codex/codex-rs/core-plugins/src/manager_tests.rs
-
curl -L -o manager_tests.rs https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/core-plugins/src/manager_tests.rs
251 kB
| 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; | |
| 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(), | |
| /*plugins_enabled*/ true, | |
| /*remote_plugin_enabled*/ 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(), | |
| /*plugins_enabled*/ true, | |
| /*remote_plugin_enabled*/ false, | |
| String::new(), | |
| test_http_client_factory(), | |
| ) | |
| } | |
| async fn plugins_manager_reads_auth_mode_from_auth_manager() { | |
| let tmp = TempDir::new().unwrap(); | |
| let auth_manager = test_auth_manager(/*auth_mode*/ 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, /*auth_mode*/ 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)); | |
| } | |
| 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(), | |
| /*plugins_enabled*/ true, | |
| /*remote_plugin_enabled*/ 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, /*on_effective_plugins_changed*/ None, | |
| ); | |
| assert!(!CURATED_REPO_SYNC_STARTED.load(std::sync::atomic::Ordering::SeqCst)); | |
| } | |
| 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( | |
| /*installed_plugin_cache_refreshed*/ false, | |
| Some(&callback), | |
| ); | |
| assert_eq!(callback_count.load(std::sync::atomic::Ordering::Relaxed), 0); | |
| manager.clear_caches_after_marketplace_source_refresh( | |
| /*installed_plugin_cache_refreshed*/ true, | |
| Some(&callback), | |
| ); | |
| assert_eq!(callback_count.load(std::sync::atomic::Ordering::Relaxed), 1); | |
| } | |
| 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()); | |
| } | |
| 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 { .. } | |
| )); | |
| } | |
| 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), | |
| /*include_openai_curated*/ 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), | |
| /*include_openai_curated*/ 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 | |
| } | |
| async fn plugin_auth_projection_hides_apps_without_chatgpt_auth() { | |
| let codex_home = TempDir::new().unwrap(); | |
| write_auth_projection_plugin(codex_home.path(), "sample", /*include_app*/ true); | |
| write_auth_projection_plugin(codex_home.path(), "docs", /*include_app*/ 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()); | |
| } | |
| 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", /*include_app*/ true); | |
| write_auth_projection_plugin(codex_home.path(), "docs", /*include_app*/ 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()); | |
| } | |
| 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", /*include_app*/ true); | |
| write_auth_projection_plugin(codex_home.path(), "docs", /*include_app*/ 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()] | |
| ); | |
| } | |
| 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", /*include_app*/ false); | |
| write_auth_projection_app(codex_home.path(), "sample", "sample_app"); | |
| write_auth_projection_plugin(codex_home.path(), "docs", /*include_app*/ 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())] | |
| ); | |
| } | |
| 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())] | |
| ); | |
| } | |
| 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", /*include_app*/ true); | |
| write_auth_projection_plugin(codex_home.path(), "docs", /*include_app*/ false); | |
| let config = auth_projection_config(codex_home.path()).await; | |
| let auth_manager = test_auth_manager(/*auth_mode*/ 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, | |
| /*manifest_version*/ 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, | |
| /*manifest_version*/ 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() | |
| } | |
| 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(/*enabled*/ true, /*plugins_feature_enabled*/ 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())] | |
| ); | |
| } | |
| 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(), /*auth_mode*/ 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(), | |
| }, | |
| )]) | |
| ); | |
| } | |
| 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(), /*auth_mode*/ 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), | |
| }) | |
| ); | |
| } | |
| 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)]) | |
| ); | |
| } | |
| 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()); | |
| } | |
| 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(), | |
| }), | |
| } | |
| ); | |
| } | |
| 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(), | |
| }), | |
| } | |
| ); | |
| } | |
| } | |
| 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(), | |
| }), | |
| } | |
| ); | |
| } | |
| 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, | |
| } | |
| ); | |
| } | |
| 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(), | |
| }), | |
| } | |
| ); | |
| } | |
| 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, | |
| } | |
| ); | |
| } | |
| 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), | |
| }) | |
| ); | |
| } | |
| 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), | |
| }) | |
| ); | |
| } | |
| 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(), | |
| ] | |
| ); | |
| } | |
| 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()] | |
| ); | |
| } | |
| 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(), | |
| ] | |
| ); | |
| } | |
| 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(/*auth_mode*/ 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, /*auth_mode*/ 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, /*auth_mode*/ 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()] | |
| ); | |
| } | |
| 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() | |
| ); | |
| } | |
| 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"] | |
| ); | |
| } | |
| 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(), /*auth_mode*/ 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()); | |
| } | |
| 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(), /*auth_mode*/ 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(), | |
| }] | |
| ); | |
| } | |
| 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(), | |
| }) | |
| ); | |
| } | |
| 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(), | |
| }) | |
| ); | |
| } | |
| 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(/*enabled*/ true, /*plugins_feature_enabled*/ true), | |
| codex_home.path(), | |
| /*auth_mode*/ 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") | |
| ); | |
| } | |
| 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(/*enabled*/ true, /*plugins_feature_enabled*/ true), | |
| codex_home.path(), | |
| /*auth_mode*/ 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)) | |
| ); | |
| } | |
| 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(/*enabled*/ true, /*plugins_feature_enabled*/ 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")] | |
| ); | |
| } | |
| } | |
| 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, | |
| /*restriction_product*/ None, | |
| /*plugin_skill_snapshots*/ 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() | |
| ] | |
| ); | |
| } | |
| 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() | |
| ); | |
| } | |
| 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()); | |
| } | |
| 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, | |
| /*restriction_product*/ None, | |
| /*plugin_skill_snapshots*/ 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") | |
| ] | |
| ); | |
| } | |
| 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(/*enabled*/ true, /*plugins_feature_enabled*/ 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")] | |
| ); | |
| } | |
| 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(/*enabled*/ true, /*plugins_feature_enabled*/ true), | |
| codex_home.path(), | |
| /*auth_mode*/ None, | |
| ) | |
| .await; | |
| assert_eq!(outcome.plugins()[0].error, None); | |
| assert_eq!( | |
| outcome.plugins()[0].skill_roots, | |
| vec![plugin_root.join("skills").abs()] | |
| ); | |
| } | |
| 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( | |
| /*enabled*/ false, /*plugins_feature_enabled*/ true, | |
| ), | |
| codex_home.path(), | |
| /*auth_mode*/ 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()); | |
| } | |
| 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()), | |
| ] | |
| ); | |
| } | |
| 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(/*enabled*/ true, /*plugins_feature_enabled*/ true), | |
| codex_home.path(), | |
| Some(AuthMode::Chatgpt), | |
| ) | |
| .await; | |
| assert_eq!( | |
| outcome.effective_apps(), | |
| vec![ | |
| AppConnectorId("connector_slack".to_string()), | |
| AppConnectorId("connector_github".to_string()), | |
| ] | |
| ); | |
| } | |
| 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") | |
| }, | |
| ] | |
| ); | |
| } | |
| 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( | |
| /*enabled*/ true, /*plugins_feature_enabled*/ 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()); | |
| } | |
| 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( | |
| /*enabled*/ true, /*plugins_feature_enabled*/ 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(), | |
| /*plugins_enabled*/ true, | |
| /*remote_plugin_enabled*/ 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], | |
| ); | |
| } | |
| 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), | |
| )], | |
| ); | |
| } | |
| } | |
| 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, | |
| ); | |
| } | |
| 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<_>>(), | |
| ); | |
| } | |
| 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); | |
| } | |
| 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(), | |
| /*plugins_enabled*/ true, | |
| /*remote_plugin_enabled*/ 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", | |
| /*chatgpt_plan_type*/ 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()] | |
| ); | |
| } | |
| 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(), | |
| /*auth_mode*/ 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()); | |
| } | |
| 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")); | |
| } | |
| 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") | |
| ); | |
| } | |
| 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, | |
| } | |
| ); | |
| } | |
| 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, | |
| } | |
| ); | |
| } | |
| 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" | |
| ); | |
| } | |
| 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()); | |
| } | |
| 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()); | |
| } | |
| 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"]"#)); | |
| } | |
| 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()], | |
| /*include_openai_curated*/ 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, | |
| }, | |
| ], | |
| } | |
| ); | |
| } | |
| 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()], | |
| /*include_openai_curated*/ true, | |
| ) | |
| .unwrap() | |
| .marketplaces; | |
| assert_eq!(marketplaces, Vec::new()); | |
| } | |
| 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()], | |
| /*include_openai_curated*/ 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, | |
| }] | |
| ); | |
| } | |
| 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)); | |
| } | |
| 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), | |
| /*auth_mode*/ 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()); | |
| } | |
| 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()], | |
| /*include_openai_curated*/ 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()] | |
| ); | |
| } | |
| 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()], | |
| /*include_openai_curated*/ 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()); | |
| } | |
| 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"); | |
| } | |
| 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()); | |
| } | |
| 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() | |
| ); | |
| } | |
| 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() | |
| ); | |
| } | |
| 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()], | |
| /*include_openai_curated*/ 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() | |
| ); | |
| } | |
| 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, &[], /*include_openai_curated*/ 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, | |
| }], | |
| } | |
| ); | |
| } | |
| 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, &[], /*include_openai_curated*/ false) | |
| .unwrap(); | |
| assert_eq!(outcome.errors, Vec::new()); | |
| assert_eq!( | |
| outcome | |
| .marketplaces | |
| .iter() | |
| .any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME), | |
| false | |
| ); | |
| } | |
| 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, &[], /*include_openai_curated*/ 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, | |
| }], | |
| } | |
| ); | |
| } | |
| 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, &[], /*include_openai_curated*/ true) | |
| .unwrap() | |
| .marketplaces; | |
| assert_eq!( | |
| marketplaces | |
| .iter() | |
| .map(|marketplace| marketplace.name.as_str()) | |
| // Personal marketplaces do not affect which curated catalog auth selects. | |
| .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:?}" | |
| ); | |
| } | |
| } | |
| 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, &[], /*include_openai_curated*/ 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) | |
| ); | |
| } | |
| 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, &[], /*include_openai_curated*/ true) | |
| .unwrap(); | |
| assert_eq!(outcome.errors, Vec::new()); | |
| assert_eq!( | |
| outcome | |
| .marketplaces | |
| .iter() | |
| .any(|marketplace| marketplace.name == OPENAI_API_CURATED_MARKETPLACE_NAME), | |
| false | |
| ); | |
| } | |
| 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, &[], /*include_openai_curated*/ 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(), | |
| } | |
| ); | |
| } | |
| 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, | |
| /*marketplace_name*/ 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, /*auth*/ 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, /*auth*/ None) | |
| .await | |
| .expect("refreshed tool-suggest metadata should load"), | |
| vec![ToolSuggestDiscoverablePlugin { | |
| description: Some("After upgrade".to_string()), | |
| ..expected | |
| }] | |
| ); | |
| } | |
| 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, &[], /*include_openai_curated*/ 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"); | |
| } | |
| 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, &[], /*include_openai_curated*/ 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" | |
| ); | |
| } | |
| 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(), | |
| ], | |
| /*include_openai_curated*/ 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); | |
| } | |
| 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()], | |
| /*include_openai_curated*/ 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, | |
| }], | |
| } | |
| ); | |
| } | |
| 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), | |
| /*auth_mode*/ 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()]); | |
| } | |
| 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(), | |
| /*restriction_product*/ None, | |
| /*auth_mode*/ None, | |
| ); | |
| let featured_plugin_ids = manager | |
| .featured_plugin_ids_for_config(&config, /*auth*/ None) | |
| .await | |
| .unwrap(); | |
| assert_eq!(featured_plugin_ids, vec!["codex-plugin".to_string()]); | |
| } | |
| 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()), | |
| /*on_effective_plugins_changed*/ 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); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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(); | |
| // Wiremock runs its own server thread. Dropping the sender also releases it. | |
| 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"); | |
| // Keep the old initializer unpolled until the new fetch publishes. An invalidated | |
| // fetch must not overwrite the fresh result. | |
| 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; | |
| } | |
| 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(), | |
| })]) | |
| ); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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() | |
| } | |
| ); | |
| } | |
| 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() | |
| ); | |
| } | |
| 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() | |
| ); | |
| } | |
| 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() | |
| ); | |
| } | |
| 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() | |
| ); | |
| } | |
| 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()); | |
| } | |
| 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() | |
| ); | |
| } | |
| 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") | |
| ); | |
| } | |
| 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() | |
| ); | |
| } | |
| 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() | |
| ); | |
| } | |
| 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, /*include_openai_curated*/ 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, /*include_openai_curated*/ 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") | |
| ); | |
| } | |
| 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() | |
| ); | |
| } | |
| 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() | |
| ); | |
| } | |
| 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") | |
| ); | |
| } | |
| 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" | |
| ); | |
| } | |
| 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() | |
| ); | |
| } | |
| 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()); | |
| } | |
| 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(/*enabled*/ true, /*plugins_feature_enabled*/ 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( | |
| /*enabled*/ true, /*plugins_feature_enabled*/ 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()), | |
| /*plugin_skill_snapshots*/ None, | |
| Some(Product::Codex), | |
| /*remote_global_catalog_active*/ 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)] | |
| ); | |
| } | |
| 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(/*enabled*/ true, /*plugins_feature_enabled*/ 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()); | |
| } | |
| 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, /*auth_mode*/ 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"] | |
| ); | |
| } | |
| 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(/*auth*/ 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 | |
| ); | |
| } | |
| 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()), | |
| /*on_effective_plugins_changed*/ None, | |
| ); | |
| second_manager.maybe_start_remote_installed_plugin_bundle_sync( | |
| &config, | |
| Some(auth), | |
| /*on_effective_plugins_changed*/ None, | |
| ); | |
| tokio::time::sleep(Duration::from_millis(400)).await; | |
| server.verify().await; | |
| } | |
| 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; | |
| // An unrelated startup download must not block a successful Sites-absence check. | |
| 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(); | |
| } | |
| 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, &[], /*include_openai_curated*/ false) | |
| .unwrap() | |
| .marketplaces | |
| .into_iter() | |
| .flat_map(|marketplace| marketplace.plugins) | |
| .any(|plugin| plugin.id == "sites@openai-bundled") | |
| }; | |
| // An installed remote entry without local files must preserve the bundled runtime fallback. | |
| 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()) | |
| ); | |
| // A warm runtime/skill cache must not carry this backend's exclusion into another backend. | |
| 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(); | |
| } | |
| 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() | |
| }, | |
| }; | |
| // None represents an uninstall. Reinstalling its retained bundle must report an | |
| // activation even though the known previous snapshot has no entry for the plugin. | |
| 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() { | |
| // Fail cleanup before it reaches the bundle; removal hints must survive. | |
| 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<_>>(); | |
| // No download URL: every installed pass must reuse the cached version. | |
| 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; | |
| } | |
| } | |
| 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()); | |
| } | |
| 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(/*auth*/ None) | |
| .expect("begin reconciliation"); | |
| let stale_refresh_generation = manager | |
| .remote_installed_plugins_cache_generation_if_current(/*auth*/ None) | |
| .expect("capture concurrent refresh generation"); | |
| assert_eq!( | |
| manager.write_remote_installed_plugins_cache_snapshot( | |
| stale_refresh_generation, | |
| Vec::new(), | |
| /*auth*/ None, | |
| "", | |
| RemoteInstalledPluginsCachePublication::Refresh, | |
| ), | |
| None | |
| ); | |
| assert_eq!( | |
| manager.write_remote_installed_plugins_cache_snapshot( | |
| reconcile_generation, | |
| vec![remote_installed_linear_plugin()], | |
| /*auth*/ None, | |
| "", | |
| RemoteInstalledPluginsCachePublication::Reconcile, | |
| ), | |
| Some(true) | |
| ); | |
| let cancelled_generation = manager | |
| .begin_remote_installed_plugins_reconcile(/*auth*/ 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(/*auth*/ None) | |
| .expect("capture refresh generation after cancellation"); | |
| assert_eq!( | |
| manager.write_remote_installed_plugins_cache_snapshot( | |
| refresh_generation, | |
| vec![remote_installed_plugin("beta")], | |
| /*auth*/ None, | |
| "", | |
| RemoteInstalledPluginsCachePublication::Refresh, | |
| ), | |
| Some(true) | |
| ); | |
| let retry_generation = manager | |
| .begin_remote_installed_plugins_reconcile(/*auth*/ None) | |
| .expect("begin retry after cancellation"); | |
| assert_eq!( | |
| manager.write_remote_installed_plugins_cache_snapshot( | |
| retry_generation, | |
| vec![remote_installed_plugin("beta")], | |
| /*auth*/ None, | |
| "", | |
| RemoteInstalledPluginsCachePublication::Reconcile, | |
| ), | |
| Some(true) | |
| ); | |
| } | |
| 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()) | |
| ); | |
| } | |