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; const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024; fn unrestricted_config_layer_stack() -> ConfigLayerStack { ConfigLayerStack::default() } 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(), ) } #[tokio::test] 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)); } #[test] fn curated_repo_sync_stays_deferred_for_remote_chatgpt_catalog() { CURATED_REPO_SYNC_STARTED.store(false, std::sync::atomic::Ordering::SeqCst); let tmp = TempDir::new().unwrap(); let config = PluginsConfigInput::new( unrestricted_config_layer_stack(), "openai".to_string(), /*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)); } #[test] fn marketplace_source_refresh_notifies_only_after_installed_cache_changes() { let tmp = TempDir::new().unwrap(); let manager = test_plugins_manager(tmp.path().to_path_buf()); let callback_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let callback_count_for_callback = Arc::clone(&callback_count); let callback: EffectivePluginsChangedCallback = Arc::new(move |_change| { callback_count_for_callback.fetch_add(1, std::sync::atomic::Ordering::Relaxed); }); manager.clear_caches_after_marketplace_source_refresh( /*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); } #[tokio::test] async fn marketplace_policy_projection_disables_installed_plugin_and_invalidates_cache() { let codex_home = TempDir::new().expect("create Codex home"); write_plugin( &codex_home.path().join("plugins/cache/company"), "sample/local", "sample", ); let user_config = r#" [marketplaces.company] source_type = "git" source = "https://github.com/example/company.git" [plugins."sample@company"] enabled = true "#; let allowed = plugins_config_input_with_requirements( codex_home.path(), user_config, r#" [marketplaces] restrict_to_allowed_sources = true [marketplaces.allowed_sources.company] source = "git" url = "https://github.com/example/company.git" "#, ); let blocked = plugins_config_input_with_requirements( codex_home.path(), user_config, r#" [marketplaces] restrict_to_allowed_sources = true [marketplaces.allowed_sources.other] source = "git" url = "https://github.com/example/other.git" "#, ); let manager = test_plugins_manager(codex_home.path().to_path_buf()); let allowed_outcome = manager.plugins_for_config(&allowed).await; assert_eq!(allowed_outcome.plugins().len(), 1); assert_eq!(allowed_outcome.plugins()[0].config_name, "sample@company"); let blocked_outcome = manager.plugins_for_config(&blocked).await; assert_eq!(blocked_outcome, PluginLoadOutcome::default()); } #[tokio::test] async fn plugin_read_rejects_marketplace_blocked_by_requirements() { let codex_home = TempDir::new().expect("create Codex home"); let marketplace_root = codex_home.path().join("marketplace"); write_plugin(&marketplace_root, "sample", "sample"); write_file( &marketplace_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "company", "plugins": [ { "name": "sample", "source": {"source": "local", "path": "./sample"} } ] }"#, ); let config = plugins_config_input_with_requirements( codex_home.path(), "", r#" [marketplaces] restrict_to_allowed_sources = true "#, ); let marketplace_path = AbsolutePathBuf::try_from(marketplace_root.join(".agents/plugins/marketplace.json")) .expect("absolute marketplace path"); let err = test_plugins_manager(codex_home.path().to_path_buf()) .read_plugin_for_config( &config, &PluginReadRequest { plugin_name: "sample".to_string(), marketplace_path, }, ) .await .expect_err("blocked marketplace should not be readable"); assert!(matches!( err, MarketplaceError::InvalidMarketplaceFile { .. } )); } #[test] fn marketplace_policy_filters_discovered_marketplaces_by_configured_name() { let codex_home = TempDir::new().expect("create Codex home"); let repo_root = codex_home.path().join("repo"); let subdirectory = repo_root.join("worktree/subdirectory"); fs::create_dir_all(&subdirectory).expect("create input subdirectory"); write_plugin(&repo_root, "sample", "sample"); write_file( &repo_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "company", "plugins": [ { "name": "sample", "source": {"source": "local", "path": "./sample"} } ] }"#, ); init_git_repo(&repo_root); let repo_root = AbsolutePathBuf::try_from(repo_root).expect("absolute repository root"); let subdirectory = AbsolutePathBuf::try_from(subdirectory).expect("absolute input subdirectory"); let manager = test_plugins_manager(codex_home.path().to_path_buf()); let user_config = format!( r#" [marketplaces.company] source_type = "local" source = {:?} "#, repo_root.as_path() ); let allowed = plugins_config_input_with_requirements( codex_home.path(), &user_config, &format!( r#" [marketplaces] restrict_to_allowed_sources = true [marketplaces.allowed_sources.company] source = "local" path = {:?} "#, repo_root.as_path() ), ); let blocked = plugins_config_input_with_requirements( codex_home.path(), &user_config, &format!( r#" [marketplaces] restrict_to_allowed_sources = true [marketplaces.allowed_sources.subdirectory] source = "local" path = {:?} "#, subdirectory.as_path() ), ); let allowed_outcome = manager .list_marketplaces_for_config( &allowed, std::slice::from_ref(&subdirectory), /*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 { let mut names = outcome .effective_mcp_servers() .keys() .cloned() .collect::>(); names.sort(); names } #[tokio::test] async fn plugin_auth_projection_hides_apps_without_chatgpt_auth() { let codex_home = TempDir::new().unwrap(); write_auth_projection_plugin(codex_home.path(), "sample", /*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()); } #[tokio::test] async fn plugin_auth_projection_hides_matching_mcp_with_chatgpt_apps_route() { let codex_home = TempDir::new().unwrap(); write_auth_projection_plugin(codex_home.path(), "sample", /*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()); } #[tokio::test] async fn plugin_auth_projection_hides_dual_surface_mcp_with_agent_identity_apps_route() { let codex_home = TempDir::new().unwrap(); write_auth_projection_plugin(codex_home.path(), "sample", /*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()] ); } #[tokio::test] async fn plugin_auth_projection_keeps_non_conflicting_mcp_with_chatgpt_apps_route() { let codex_home = TempDir::new().unwrap(); write_auth_projection_plugin(codex_home.path(), "sample", /*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())] ); } #[tokio::test] async fn plugin_auth_projection_preserves_duplicate_connector_declaration_names() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache") .join("test") .join("sample") .join("local"); write_file( &plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample"}"#, ); write_file( &plugin_root.join(".mcp.json"), r#"{ "mcpServers": { "foo": { "type": "stdio", "command": "foo-mcp" }, "foo2": { "type": "stdio", "command": "foo2-mcp" }, "other": { "type": "stdio", "command": "other-mcp" } } }"#, ); write_file( &plugin_root.join(".app.json"), r#"{ "apps": { "foo": { "id": "connector_shared" }, "foo2": { "id": "connector_shared" } } }"#, ); write_file( &codex_home.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."sample@test"] enabled = true "#, ); let config = load_config(codex_home.path(), codex_home.path()).await; let manager = test_plugins_manager_with_options( codex_home.path().to_path_buf(), Some(Product::Codex), Some(AuthMode::Chatgpt), ); let outcome = manager.plugins_for_config(&config).await; assert_eq!( outcome.effective_apps(), vec![AppConnectorId("connector_shared".to_string())] ); assert_eq!( sorted_effective_mcp_server_names(&outcome), vec!["other".to_string()] ); let sample = outcome .capability_summaries() .iter() .find(|plugin| plugin.config_name == "sample@test") .expect("sample plugin summary should exist"); assert_eq!(sample.mcp_server_names, vec!["other".to_string()]); assert_eq!( sample.app_connector_ids, vec![AppConnectorId("connector_shared".to_string())] ); } #[tokio::test] async fn plugin_auth_projection_reprojects_cached_plugins_when_auth_changes() { let codex_home = TempDir::new().unwrap(); write_auth_projection_plugin(codex_home.path(), "sample", /*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, ) -> 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 { 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 { manager .plugins_for_config(config) .await .plugins() .iter() .map(|plugin| plugin.config_name.clone()) .collect() } #[tokio::test] async fn load_plugins_loads_default_skills_and_mcp_servers() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache") .join("test/sample/local"); write_file( &plugin_root.join(".codex-plugin/plugin.json"), r#"{ "name": "sample", "description": "Plugin that includes the sample MCP server and Skills" }"#, ); write_file( &plugin_root.join("skills/sample-search/SKILL.md"), "---\nname: sample-search\ndescription: search sample data\n---\n", ); write_file( &plugin_root.join(".mcp.json"), r#"{ "mcpServers": { "sample": { "type": "http", "url": "https://sample.example/mcp", "oauth": { "clientId": "client-id", "callbackPort": 3118 } } } }"#, ); write_file( &plugin_root.join(".app.json"), r#"{ "apps": { "example": { "id": "connector_example" } } }"#, ); let outcome = load_plugins_from_config( &plugin_config_toml(/*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_port: Some(3118), }), 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![plugin_root.join("skills").abs()] ); assert_eq!(outcome.effective_mcp_servers().len(), 1); assert_eq!( outcome.effective_apps(), vec![AppConnectorId("connector_example".to_string())] ); } #[tokio::test] async fn load_plugins_loads_manifest_mcp_server_objects() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache") .join("test/counter-sample/local"); write_file( &plugin_root.join(".codex-plugin/plugin.json"), r#"{ "name": "counter-sample", "version": "1.1.1", "description": "Plugin that declares MCP servers in the manifest", "mcpServers": { "counter": { "type": "http", "url": "https://sample.example/counter/mcp" } } }"#, ); let config_toml = r#" [features] plugins = true [plugins."counter-sample@test"] enabled = true "#; let outcome = load_plugins_from_config(config_toml, codex_home.path(), /*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(), }, )]) ); } #[tokio::test] async fn load_plugins_applies_plugin_mcp_server_policy() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache") .join("test/sample/local"); write_file( &plugin_root.join(".codex-plugin/plugin.json"), r#"{ "name": "sample" }"#, ); write_file( &plugin_root.join(".mcp.json"), r#"{ "mcpServers": { "sample": { "type": "http", "url": "https://sample.example/mcp", "default_tools_approval_mode": "prompt", "enabled_tools": ["read", "search"], "tools": { "search": { "approval_mode": "prompt" } } } } }"#, ); 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" "#; 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), }) ); } #[tokio::test] async fn remote_installed_plugin_preserves_configured_mcp_server_policy() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache/openai-curated-remote/linear/local"); write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); write_file( &plugin_root.join(".mcp.json"), r#"{ "mcpServers": { "linear": { "type": "http", "url": "https://linear.example/mcp" } } }"#, ); write_file( &codex_home.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."linear@openai-curated-remote"] enabled = false [plugins."linear@openai-curated-remote".mcp_servers.linear] enabled = false default_tools_approval_mode = "approve" enabled_tools = ["search"] disabled_tools = ["delete"] [plugins."linear@openai-curated-remote".mcp_servers.linear.tools.search] approval_mode = "approve" "#, ); let config = load_config(codex_home.path(), codex_home.path()).await; let manager = test_plugins_manager_with_options( codex_home.path().to_path_buf(), Some(Product::Codex), Some(AuthMode::Chatgpt), ); manager.write_remote_installed_plugins_cache(vec![remote_installed_linear_plugin()]); let outcome = manager.plugins_for_config(&config).await; let plugin = outcome .plugins() .iter() .find(|plugin| plugin.config_name == "linear@openai-curated-remote") .expect("remote plugin should be loaded"); let expected_server = serde_json::from_value::(serde_json::json!({ "url": "https://linear.example/mcp", "enabled": false, "default_tools_approval_mode": "approve", "enabled_tools": ["search"], "disabled_tools": ["delete"], "tools": { "search": { "approval_mode": "approve" } } })) .expect("valid expected MCP server"); assert!(plugin.enabled); assert_eq!( plugin.mcp_servers, HashMap::from([("linear".to_string(), expected_server)]) ); } #[tokio::test] async fn remote_installed_cache_ignores_plugins_missing_local_cache() { let codex_home = TempDir::new().unwrap(); write_file( &codex_home.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true "#, ); let config = load_config(codex_home.path(), codex_home.path()).await; let manager = test_plugins_manager(codex_home.path().to_path_buf()); manager.write_remote_installed_plugins_cache(vec![remote_installed_linear_plugin()]); let outcome = manager.plugins_for_config(&config).await; assert_eq!(outcome, PluginLoadOutcome::default()); } #[tokio::test] async fn installed_plugin_telemetry_metadata_collects_capabilities() { let codex_home = TempDir::new().unwrap(); write_cached_plugin(codex_home.path(), "test", "sample"); let manager = test_plugins_manager(codex_home.path().to_path_buf()); let plugin_id = PluginId::parse("sample@test").expect("plugin id should parse"); let metadata = manager .telemetry_metadata_for_installed_plugin(&plugin_id) .await; assert_eq!( metadata, PluginTelemetryMetadata { plugin_id: Some(plugin_id), remote_plugin_id: None, capability_summary: Some(PluginCapabilitySummary { config_name: "sample@test".to_string(), display_name: "sample".to_string(), plugin_namespace: Some("sample".to_string()), description: None, has_skills: true, mcp_server_names: Vec::new(), app_connector_ids: Vec::new(), }), } ); } #[tokio::test] async fn installed_agent_plugin_telemetry_metadata_uses_portable_capabilities() { for (skill_path, has_skills) in [ ("skills/direct/SKILL.md", true), ("skills/group/nested/SKILL.md", false), ] { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache/test/agent-plugin/local"); write_file( &plugin_root.join("plugin.json"), r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"agent.tools"}"#, ); write_file( &plugin_root.join(skill_path), "---\nname: portable\ndescription: Portable skill\n---\n", ); write_file( &plugin_root.join("mcp.json"), r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"portable":{"type":"stdio","command":"echo"}}}"#, ); write_file( &plugin_root.join(".codex-plugin/plugin.json"), r#"{"apps":"./.app.json"}"#, ); write_file( &plugin_root.join(".app.json"), r#"{"apps":{"legacy":{"id":"connector_legacy"}}}"#, ); let manager = test_plugins_manager(codex_home.path().to_path_buf()); let plugin_id = PluginId::parse("agent-plugin@test").expect("plugin id should parse"); let metadata = manager .telemetry_metadata_for_installed_plugin(&plugin_id) .await; assert_eq!( metadata, PluginTelemetryMetadata { plugin_id: Some(plugin_id), remote_plugin_id: None, capability_summary: Some(PluginCapabilitySummary { config_name: "agent-plugin@test".to_string(), display_name: "agent-plugin".to_string(), plugin_namespace: Some("agent.tools".to_string()), description: None, has_skills, mcp_server_names: vec!["portable".to_string()], app_connector_ids: Vec::new(), }), } ); } } #[tokio::test] async fn installed_plugin_telemetry_metadata_resolves_persisted_remote_identity() { let codex_home = TempDir::new().unwrap(); write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); let plugin_id = PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse"); PluginStore::new(codex_home.path().to_path_buf()) .write_remote_plugin_id(&plugin_id, "plugins~Plugin_linear") .expect("persist remote plugin id"); let manager = test_plugins_manager(codex_home.path().to_path_buf()); let metadata = manager .telemetry_metadata_for_installed_plugin(&plugin_id) .await; assert_eq!( metadata, PluginTelemetryMetadata { plugin_id: Some(plugin_id), remote_plugin_id: Some("plugins~Plugin_linear".to_string()), capability_summary: Some(PluginCapabilitySummary { config_name: "linear@openai-curated-remote".to_string(), display_name: "linear".to_string(), plugin_namespace: Some("linear".to_string()), description: None, has_skills: true, mcp_server_names: Vec::new(), app_connector_ids: Vec::new(), }), } ); } #[test] fn plugin_telemetry_ignores_local_marketplace_sidecars() { let codex_home = TempDir::new().unwrap(); write_cached_plugin(codex_home.path(), "test", "sample"); let plugin_id = PluginId::parse("sample@test").expect("plugin id should parse"); PluginStore::new(codex_home.path().to_path_buf()) .write_remote_plugin_id(&plugin_id, "plugins~Plugin_sample") .expect("persist remote plugin id"); let manager = test_plugins_manager(codex_home.path().to_path_buf()); assert_eq!( manager.telemetry_metadata_for_plugin_id(&plugin_id), PluginTelemetryMetadata { plugin_id: Some(plugin_id), remote_plugin_id: None, capability_summary: None, } ); } #[tokio::test] async fn installed_plugin_telemetry_metadata_prefers_remote_snapshot_identity() { let codex_home = TempDir::new().unwrap(); write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); let plugin_id = PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse"); PluginStore::new(codex_home.path().to_path_buf()) .write_remote_plugin_id(&plugin_id, "plugins~Plugin_stale") .expect("persist remote plugin id"); let manager = test_plugins_manager(codex_home.path().to_path_buf()); manager.write_remote_installed_plugins_cache(vec![remote_installed_linear_plugin()]); let metadata = manager .telemetry_metadata_for_installed_plugin(&plugin_id) .await; assert_eq!( metadata, PluginTelemetryMetadata { plugin_id: Some(plugin_id), remote_plugin_id: Some("plugins~Plugin_linear".to_string()), capability_summary: Some(PluginCapabilitySummary { config_name: "linear@openai-curated-remote".to_string(), display_name: "linear".to_string(), plugin_namespace: Some("linear".to_string()), description: None, has_skills: true, mcp_server_names: Vec::new(), app_connector_ids: Vec::new(), }), } ); } #[tokio::test] async fn installed_plugin_telemetry_metadata_accepts_authoritative_remote_identity() { let codex_home = TempDir::new().unwrap(); let manager = test_plugins_manager(codex_home.path().to_path_buf()); let plugin_id = PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse"); let metadata = manager .telemetry_metadata_for_installed_plugin_with_remote_id(&plugin_id, "plugins~Plugin_linear") .await; assert_eq!( metadata, PluginTelemetryMetadata { plugin_id: Some(plugin_id), remote_plugin_id: Some("plugins~Plugin_linear".to_string()), capability_summary: None, } ); } #[test] fn capability_summary_telemetry_metadata_uses_local_identity() { let codex_home = TempDir::new().unwrap(); let manager = test_plugins_manager(codex_home.path().to_path_buf()); let summary = PluginCapabilitySummary { config_name: "linear@openai-curated-remote".to_string(), display_name: "Linear".to_string(), plugin_namespace: Some("linear".to_string()), description: Some("Track work".to_string()), has_skills: true, mcp_server_names: vec!["linear".to_string()], app_connector_ids: vec![AppConnectorId("linear-app".to_string())], }; let metadata = manager.telemetry_metadata_for_capability_summary(&summary); assert_eq!( metadata, Some(PluginTelemetryMetadata { plugin_id: Some( PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse"), ), remote_plugin_id: None, capability_summary: Some(summary), }) ); } #[test] fn capability_summary_telemetry_metadata_resolves_persisted_remote_identity() { let codex_home = TempDir::new().unwrap(); write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); let plugin_id = PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse"); PluginStore::new(codex_home.path().to_path_buf()) .write_remote_plugin_id(&plugin_id, "plugins~Plugin_linear") .expect("persist remote plugin id"); let manager = test_plugins_manager(codex_home.path().to_path_buf()); let summary = PluginCapabilitySummary { config_name: "linear@openai-curated-remote".to_string(), display_name: "Linear".to_string(), plugin_namespace: Some("linear".to_string()), description: Some("Track work".to_string()), has_skills: true, mcp_server_names: vec!["linear".to_string()], app_connector_ids: vec![AppConnectorId("linear-app".to_string())], }; let metadata = manager.telemetry_metadata_for_capability_summary(&summary); assert_eq!( metadata, Some(PluginTelemetryMetadata { plugin_id: Some(plugin_id), remote_plugin_id: Some("plugins~Plugin_linear".to_string()), capability_summary: Some(summary), }) ); } #[tokio::test] async fn remote_installed_cache_prefers_local_curated_conflicts_when_remote_plugin_disabled() { let codex_home = TempDir::new().unwrap(); write_file( &codex_home.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true remote_plugin = false [plugins."linear@openai-curated"] enabled = true [plugins."calendar@openai-curated"] enabled = true [plugins."linear@openai-api-curated"] enabled = true "#, ); write_cached_plugin(codex_home.path(), "openai-curated", "linear"); write_cached_plugin(codex_home.path(), "openai-curated", "calendar"); write_cached_plugin(codex_home.path(), "openai-api-curated", "linear"); write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); write_cached_plugin(codex_home.path(), "openai-curated-remote", "remote-only"); let config = load_config(codex_home.path(), codex_home.path()).await; let manager = test_plugins_manager_with_options( codex_home.path().to_path_buf(), Some(Product::Codex), Some(AuthMode::Chatgpt), ); manager.write_remote_installed_plugins_cache(vec![ remote_installed_plugin("linear"), remote_installed_plugin("remote-only"), ]); assert_eq!( loaded_plugin_names(&manager, &config).await, vec![ "calendar@openai-curated".to_string(), "linear@openai-curated".to_string(), "remote-only@openai-curated-remote".to_string(), ] ); } #[tokio::test] async fn api_curated_plugin_does_not_suppress_remote_curated_conflict_for_chatgpt() { let codex_home = TempDir::new().unwrap(); write_file( &codex_home.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true remote_plugin = false [plugins."linear@openai-api-curated"] enabled = true "#, ); write_cached_plugin(codex_home.path(), "openai-api-curated", "linear"); write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); let config = load_config(codex_home.path(), codex_home.path()).await; let manager = test_plugins_manager_with_options( codex_home.path().to_path_buf(), Some(Product::Codex), Some(AuthMode::Chatgpt), ); manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("linear")]); assert_eq!( loaded_plugin_names(&manager, &config).await, vec!["linear@openai-curated-remote".to_string()] ); } #[tokio::test] async fn remote_global_catalog_ignores_local_curated_plugins() { let codex_home = TempDir::new().unwrap(); write_file( &codex_home.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."linear@openai-curated"] enabled = true [plugins."linear@openai-api-curated"] enabled = true [plugins."calendar@openai-curated"] enabled = true "#, ); write_cached_plugin(codex_home.path(), "openai-curated", "linear"); write_cached_plugin(codex_home.path(), "openai-api-curated", "linear"); write_cached_plugin(codex_home.path(), "openai-curated", "calendar"); write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); write_cached_plugin(codex_home.path(), "openai-curated-remote", "remote-only"); let config = load_config(codex_home.path(), codex_home.path()).await; let manager = test_plugins_manager_with_options( codex_home.path().to_path_buf(), Some(Product::Codex), Some(AuthMode::Chatgpt), ); manager.write_remote_installed_plugins_cache(vec![ remote_installed_plugin("linear"), remote_installed_plugin("remote-only"), ]); assert_eq!( loaded_plugin_names(&manager, &config).await, vec![ "linear@openai-curated-remote".to_string(), "remote-only@openai-curated-remote".to_string(), ] ); } #[tokio::test] async fn non_chatgpt_auth_rejects_cached_remote_curated_plugins_after_auth_switch() { let codex_home = TempDir::new().unwrap(); write_file( &codex_home.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."linear@openai-curated"] enabled = true [plugins."linear@openai-api-curated"] enabled = true "#, ); write_cached_plugin(codex_home.path(), "openai-curated", "linear"); write_cached_plugin(codex_home.path(), "openai-api-curated", "linear"); write_cached_plugin(codex_home.path(), "openai-curated-remote", "remote-only"); let mut config = load_config(codex_home.path(), codex_home.path()).await; let auth_manager = test_auth_manager(/*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()] ); } #[tokio::test] async fn build_remote_installed_plugin_marketplaces_from_cache_uses_remote_metadata() { let codex_home = TempDir::new().unwrap(); let manager = test_plugins_manager(codex_home.path().to_path_buf()); let mut plugin = remote_installed_linear_plugin(); plugin.install_policy = codex_app_server_protocol::PluginInstallPolicy::InstalledByDefault; plugin.auth_policy = codex_app_server_protocol::PluginAuthPolicy::OnInstall; plugin.interface = Some(codex_app_server_protocol::PluginInterface { display_name: Some("Linear".to_string()), short_description: Some("Track remote work".to_string()), long_description: None, developer_name: None, category: None, capabilities: Vec::new(), website_url: None, privacy_policy_url: None, terms_of_service_url: None, default_prompt: None, brand_color: Some("#111111".to_string()), composer_icon: None, composer_icon_url: None, logo: None, logo_dark: None, logo_url: None, logo_url_dark: None, screenshots: Vec::new(), screenshot_urls: Vec::new(), }); plugin.keywords = vec!["issues".to_string()]; manager.write_remote_installed_plugins_cache(vec![plugin]); let marketplaces = manager .build_remote_installed_plugin_marketplaces_from_cache(&[REMOTE_GLOBAL_MARKETPLACE_NAME]) .expect("remote installed cache should be present"); assert_eq!(marketplaces.len(), 1); assert_eq!(marketplaces[0].name, "openai-curated-remote"); assert_eq!(marketplaces[0].display_name, "OpenAI Curated Remote"); assert_eq!(marketplaces[0].plugins.len(), 1); let plugin = &marketplaces[0].plugins[0]; assert_eq!(plugin.id, "linear@openai-curated-remote"); assert_eq!(plugin.remote_plugin_id, "plugins~Plugin_linear"); assert_eq!(plugin.name, "linear"); assert_eq!(plugin.installed, true); assert_eq!(plugin.enabled, true); assert_eq!( plugin.install_policy, codex_app_server_protocol::PluginInstallPolicy::InstalledByDefault ); assert_eq!( plugin.auth_policy, codex_app_server_protocol::PluginAuthPolicy::OnInstall ); assert_eq!(plugin.keywords, vec!["issues".to_string()]); assert_eq!( plugin .interface .as_ref() .and_then(|interface| interface.display_name.as_deref()), Some("Linear") ); assert_eq!( plugin .interface .as_ref() .and_then(|interface| interface.short_description.as_deref()), Some("Track remote work") ); assert_eq!( manager .build_remote_installed_plugin_marketplaces_from_cache(&[ REMOTE_WORKSPACE_MARKETPLACE_NAME ]) .expect("remote installed cache should be present"), Vec::new() ); } #[tokio::test] async fn build_remote_installed_plugin_marketplaces_from_cache_filters_by_marketplace_name() { let codex_home = TempDir::new().unwrap(); let manager = test_plugins_manager(codex_home.path().to_path_buf()); manager.write_remote_installed_plugins_cache(vec![ remote_installed_plugin_in_marketplace( "workspace-linear", REMOTE_WORKSPACE_MARKETPLACE_NAME, ), remote_installed_plugin_in_marketplace( "shared-linear", REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME, ), ]); let marketplaces = manager .build_remote_installed_plugin_marketplaces_from_cache(&[REMOTE_WORKSPACE_MARKETPLACE_NAME]) .expect("remote installed cache should be present"); assert_eq!(marketplaces.len(), 1); assert_eq!(marketplaces[0].name, REMOTE_WORKSPACE_MARKETPLACE_NAME); assert_eq!( marketplaces[0] .plugins .iter() .map(|plugin| plugin.id.as_str()) .collect::>(), vec!["workspace-linear@workspace-directory"] ); } #[tokio::test] async fn load_plugins_resolves_disabled_skill_names_against_loaded_plugin_skills() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache") .join("test/sample/local"); let skill_path = plugin_root.join("skills/sample-search/SKILL.md"); write_file( &plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample"}"#, ); write_file( &skill_path, "---\nname: sample-search\ndescription: search sample data\n---\n", ); let config_toml = r#"[features] plugins = true [[skills.config]] name = "sample:sample-search" enabled = false [plugins."sample@test"] enabled = true "#; let outcome = load_plugins_from_config(config_toml, codex_home.path(), /*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()); } #[tokio::test] async fn load_plugins_ignores_unknown_disabled_skill_names() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache") .join("test/sample/local"); write_file( &plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample"}"#, ); write_file( &plugin_root.join("skills/sample-search/SKILL.md"), "---\nname: sample-search\ndescription: search sample data\n---\n", ); let config_toml = r#"[features] plugins = true [[skills.config]] name = "sample:missing-skill" enabled = false [plugins."sample@test"] enabled = true "#; let outcome = load_plugins_from_config(config_toml, codex_home.path(), /*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(), }] ); } #[tokio::test] async fn plugin_telemetry_metadata_uses_default_mcp_config_path() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache") .join("test/sample/local"); write_file( &plugin_root.join(".codex-plugin/plugin.json"), r#"{ "name": "sample" }"#, ); write_file( &plugin_root.join(".mcp.json"), r#"{ "mcpServers": { "sample": { "type": "http", "url": "https://sample.example/mcp" } } }"#, ); let summary = plugin_capability_summary_from_root( &PluginId::parse("sample@test").expect("plugin id should parse"), &plugin_root.abs(), test_skill_root_loader().as_ref(), ) .await; assert_eq!( summary, Some(PluginCapabilitySummary { config_name: "sample@test".to_string(), display_name: "sample".to_string(), plugin_namespace: Some("sample".to_string()), description: None, has_skills: false, mcp_server_names: vec!["sample".to_string()], app_connector_ids: Vec::new(), }) ); } #[tokio::test] async fn plugin_capability_summary_uses_manifest_mcp_server_objects() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache") .join("test/counter-sample/local"); write_file( &plugin_root.join(".codex-plugin/plugin.json"), r#"{ "name": "counter-sample", "version": "1.1.1", "mcpServers": { "counter": { "type": "http", "url": "https://sample.example/counter/mcp" } } }"#, ); let summary = plugin_capability_summary_from_root( &PluginId::parse("counter-sample@test").expect("plugin id should parse"), &plugin_root.abs(), test_skill_root_loader().as_ref(), ) .await; assert_eq!( summary, Some(PluginCapabilitySummary { config_name: "counter-sample@test".to_string(), display_name: "counter-sample".to_string(), plugin_namespace: Some("counter-sample".to_string()), description: None, has_skills: false, mcp_server_names: vec!["counter".to_string()], app_connector_ids: Vec::new(), }) ); } #[tokio::test] async fn capability_summary_sanitizes_plugin_descriptions_to_one_line() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache") .join("test/sample/local"); write_file( &plugin_root.join(".codex-plugin/plugin.json"), r#"{ "name": "sample", "description": "Plugin that\n includes the sample\tserver" }"#, ); write_file( &plugin_root.join("skills/sample-search/SKILL.md"), "---\nname: sample-search\ndescription: search sample data\n---\n", ); let outcome = load_plugins_from_config( &plugin_config_toml(/*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") ); } #[tokio::test] async fn capability_summary_truncates_overlong_plugin_descriptions() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache") .join("test/sample/local"); let too_long = "x".repeat(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN + 1); write_file( &plugin_root.join(".codex-plugin/plugin.json"), &format!( r#"{{ "name": "sample", "description": "{too_long}" }}"# ), ); write_file( &plugin_root.join("skills/sample-search/SKILL.md"), "---\nname: sample-search\ndescription: search sample data\n---\n", ); let outcome = load_plugins_from_config( &plugin_config_toml(/*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)) ); } #[tokio::test] async fn load_plugins_uses_manifest_configured_component_paths() { for (skills_json, expected_skill_dirs) in [ (r#""./custom-skills/""#, &["custom-skills"][..]), ( r#"["./custom-skills/", "./extra-skills/"]"#, &["custom-skills", "extra-skills"][..], ), ( r#"["./custom-skills/", "./custom-skills/"]"#, &["custom-skills"][..], ), (r#""./skills/""#, &["skills"][..]), ( r#"["./skills/abc/", "./skills/edk/"]"#, &["skills/abc", "skills/edk"][..], ), ] { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache") .join("test/sample/local"); write_file( &plugin_root.join(".codex-plugin/plugin.json"), &format!( r#"{{ "name": "sample", "skills": {skills_json}, "mcpServers": "./config/custom.mcp.json", "apps": "./config/custom.app.json" }}"# ), ); write_file( &plugin_root.join("skills/default-skill/SKILL.md"), "---\nname: default-skill\ndescription: default skill\n---\n", ); write_file( &plugin_root.join("skills/abc/SKILL.md"), "---\nname: abc\ndescription: abc skill\n---\n", ); write_file( &plugin_root.join("skills/edk/SKILL.md"), "---\nname: edk\ndescription: edk skill\n---\n", ); write_file( &plugin_root.join("custom-skills/custom-skill/SKILL.md"), "---\nname: custom-skill\ndescription: custom skill\n---\n", ); write_file( &plugin_root.join("extra-skills/extra-skill/SKILL.md"), "---\nname: extra-skill\ndescription: extra skill\n---\n", ); write_file( &plugin_root.join(".mcp.json"), r#"{ "mcpServers": { "default": { "type": "http", "url": "https://default.example/mcp" } } }"#, ); write_file( &plugin_root.join("config/custom.mcp.json"), r#"{ "mcpServers": { "custom": { "type": "http", "url": "https://custom.example/mcp" } } }"#, ); write_file( &plugin_root.join(".app.json"), r#"{ "apps": { "default-app": { "id": "connector_default" } } }"#, ); write_file( &plugin_root.join("config/custom.app.json"), r#"{ "apps": { "custom-app": { "id": "connector_custom" } } }"#, ); let outcome = load_plugins_from_config( &plugin_config_toml(/*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::>(); expected_skill_roots.sort_unstable(); expected_skill_roots.dedup(); assert_eq!(outcome.plugins()[0].skill_roots, expected_skill_roots); assert_eq!( outcome.plugins()[0].mcp_servers, HashMap::from([( "custom".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://custom.example/mcp".to_string(), bearer_token_env_var: None, http_headers: None, env_http_headers: None, http_headers_helper: None, }, environment_id: "local".to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, )]) ); assert_eq!( outcome.plugins()[0].apps, vec![app_declaration("custom-app", "connector_custom")] ); } } #[tokio::test] async fn install_plugin_materializes_default_command_skills() { let codex_home = TempDir::new().unwrap(); let source_root = codex_home.path().join("source/sample"); write_file( &source_root.join(".codex-plugin/plugin.json"), r#"{ "name": "sample", "skills": "./custom-skills/" }"#, ); fs::create_dir_all(source_root.join("custom-skills")).unwrap(); write_file( &source_root.join("custom-skills/source-command-pr-review/SKILL.md"), "---\nname: source-command-pr-review\ndescription: Native review skill\n---\n", ); write_file( &source_root.join("commands/pr/review.md"), "---\ndescription: Review a pull request\n---\nInspect the proposed changes.\n", ); write_file( &source_root.join("commands/summarize.md"), "---\ndescription: Summarize a change\n---\nSummarize the proposed changes.\n", ); write_file( &source_root.join("commands/oversized.md"), &format!("---\ndescription: Oversized\n---\n{}", "x".repeat(4_000)), ); write_file( &source_root.join(".codex-plugin/migrated-command-skills/undeclared-command/SKILL.md"), "---\nname: undeclared-command\ndescription: undeclared command\n---\n", ); let result = PluginStore::new(codex_home.path().to_path_buf()) .install( source_root.abs(), PluginId::parse("sample@test").expect("plugin id should parse"), ) .unwrap(); let migrated_skill = result .installed_path .join(".codex-plugin/migrated-command-skills/source-command-pr-review/SKILL.md"); let expected_migrated_skill = "---\nname: \"source-command-pr-review\"\ndescription: \"Review a pull request\"\n---\n\n# source-command-pr-review\n\nUse this skill when the user asks to run the migrated source command `pr-review`.\n\n## Command Template\n\nInspect the proposed changes.\n"; assert_eq!( fs::read_to_string(&migrated_skill).unwrap(), expected_migrated_skill ); assert!( !result .installed_path .join(".codex-plugin/migrated-command-skills/undeclared-command") .exists() ); assert!( !result .installed_path .join(".codex-plugin/migrated-command-skills/source-command-oversized") .exists() ); let manifest = crate::manifest::load_plugin_manifest(&result.installed_path).unwrap(); let resolved = load_plugin_skill_inventory( &result.installed_path, &PluginIdentity { plugin_id: result.plugin_id.as_key(), remote_plugin_id: None, }, &manifest, PluginManifestFormat::Legacy, /*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![ AbsolutePathBuf::from_absolute_path_checked( fs::canonicalize( result .installed_path .join("custom-skills/source-command-pr-review/SKILL.md") ) .unwrap() ) .unwrap(), AbsolutePathBuf::from_absolute_path_checked( fs::canonicalize(result.installed_path.join( ".codex-plugin/migrated-command-skills/source-command-summarize/SKILL.md" )) .unwrap() ) .unwrap() ] ); } #[test] fn install_plugin_ignores_invalid_commands_manifest_field() { let codex_home = TempDir::new().unwrap(); let source_root = codex_home.path().join("source/sample"); write_file( &source_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample","commands":{}}"#, ); write_file( &source_root.join("commands/review.md"), "---\ndescription: Review\n---\nReview the current change.\n", ); let result = PluginStore::new(codex_home.path().to_path_buf()) .install( source_root.abs(), PluginId::parse("sample@test").expect("plugin id should parse"), ) .unwrap(); assert!( !result .installed_path .join(".codex-plugin/migrated-command-skills") .exists() ); } #[test] fn install_plugin_ignores_command_migration_errors() { let codex_home = TempDir::new().unwrap(); let source_root = codex_home.path().join("source/sample"); write_file( &source_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample","commands":"./commands/review.md"}"#, ); fs::create_dir_all(source_root.join("commands")).unwrap(); fs::write(source_root.join("commands/review.md"), [0xff]).unwrap(); let result = PluginStore::new(codex_home.path().to_path_buf()) .install( source_root.abs(), PluginId::parse("sample@test").expect("plugin id should parse"), ) .unwrap(); assert!(result.installed_path.join("commands/review.md").is_file()); } #[tokio::test] async fn load_plugin_skills_dedupes_overlapping_manifest_roots() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache") .join("test/sample/local") .abs(); write_file( &plugin_root.join("skills/abc/SKILL.md"), "---\nname: abc\ndescription: abc skill\n---\n", ); write_file( &plugin_root.join("skills/edk/SKILL.md"), "---\nname: edk\ndescription: edk skill\n---\n", ); let manifest = crate::manifest::PluginManifest { name: "sample".to_string(), version: None, description: None, keywords: Vec::new(), paths: crate::manifest::PluginManifestPaths { skills: vec![ plugin_root.join("skills"), plugin_root.join("skills/abc"), plugin_root.join("skills/edk"), plugin_root.join("skills/abc"), ], mcp_servers: None, apps: None, hooks: None, }, interface: None, }; let plugin_id = PluginId::parse("sample@test").expect("plugin id should parse"); let resolved = load_plugin_skill_inventory( &plugin_root, &PluginIdentity { plugin_id: plugin_id.as_key(), remote_plugin_id: None, }, &manifest, PluginManifestFormat::Legacy, /*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::>(); let canonical_skill_path = |path| { AbsolutePathBuf::from_absolute_path_checked( fs::canonicalize(plugin_root.join(path)).expect("canonical skill path"), ) .expect("absolute skill path") }; assert_eq!( skill_paths, vec![ canonical_skill_path("skills/abc/SKILL.md"), canonical_skill_path("skills/edk/SKILL.md") ] ); } #[tokio::test] async fn load_plugins_ignores_manifest_component_paths_without_dot_slash() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache") .join("test/sample/local"); write_file( &plugin_root.join(".codex-plugin/plugin.json"), r#"{ "name": "sample", "skills": "custom-skills", "mcpServers": "config/custom.mcp.json", "apps": "config/custom.app.json" }"#, ); write_file( &plugin_root.join("skills/default-skill/SKILL.md"), "---\nname: default-skill\ndescription: default skill\n---\n", ); write_file( &plugin_root.join("custom-skills/custom-skill/SKILL.md"), "---\nname: custom-skill\ndescription: custom skill\n---\n", ); write_file( &plugin_root.join(".mcp.json"), r#"{ "mcpServers": { "default": { "type": "http", "url": "https://default.example/mcp" } } }"#, ); write_file( &plugin_root.join("config/custom.mcp.json"), r#"{ "mcpServers": { "custom": { "type": "http", "url": "https://custom.example/mcp" } } }"#, ); write_file( &plugin_root.join(".app.json"), r#"{ "apps": { "default-app": { "id": "connector_default" } } }"#, ); write_file( &plugin_root.join("config/custom.app.json"), r#"{ "apps": { "custom-app": { "id": "connector_custom" } } }"#, ); let outcome = load_plugins_from_config( &plugin_config_toml(/*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")] ); } #[tokio::test] async fn load_plugins_ignores_invalid_manifest_skills_shape() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache") .join("test/sample/local"); write_file( &plugin_root.join(".codex-plugin/plugin.json"), r#"{ "name": "sample", "skills": { "path": "./custom-skills/" } }"#, ); write_file( &plugin_root.join("skills/default-skill/SKILL.md"), "---\nname: default-skill\ndescription: default skill\n---\n", ); write_file( &plugin_root.join("custom-skills/custom-skill/SKILL.md"), "---\nname: custom-skill\ndescription: custom skill\n---\n", ); let outcome = load_plugins_from_config( &plugin_config_toml(/*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()] ); } #[tokio::test] async fn load_plugins_preserves_disabled_plugins_without_effective_contributions() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache") .join("test/sample/local"); write_file( &plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample"}"#, ); write_file( &plugin_root.join(".mcp.json"), r#"{ "mcpServers": { "sample": { "type": "http", "url": "https://sample.example/mcp" } } }"#, ); let outcome = load_plugins_from_config( &plugin_config_toml( /*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()); } #[tokio::test] async fn effective_apps_dedupes_connector_ids_across_plugins() { let codex_home = TempDir::new().unwrap(); let plugin_a_root = codex_home .path() .join("plugins/cache") .join("test/plugin-a/local"); let plugin_b_root = codex_home .path() .join("plugins/cache") .join("test/plugin-b/local"); write_file( &plugin_a_root.join(".codex-plugin/plugin.json"), r#"{"name":"plugin-a"}"#, ); write_file( &plugin_a_root.join(".app.json"), r#"{ "apps": { "example": { "id": "connector_example" } } }"#, ); write_file( &plugin_b_root.join(".codex-plugin/plugin.json"), r#"{"name":"plugin-b"}"#, ); write_file( &plugin_b_root.join(".app.json"), r#"{ "apps": { "chat": { "id": "connector_example" }, "gmail": { "id": "connector_gmail" } } }"#, ); let mut root = toml::map::Map::new(); let mut features = toml::map::Map::new(); features.insert("plugins".to_string(), Value::Boolean(true)); features.insert("apps".to_string(), Value::Boolean(true)); root.insert("features".to_string(), Value::Table(features)); let mut plugins = toml::map::Map::new(); let mut plugin_a = toml::map::Map::new(); plugin_a.insert("enabled".to_string(), Value::Boolean(true)); plugins.insert("plugin-a@test".to_string(), Value::Table(plugin_a)); let mut plugin_b = toml::map::Map::new(); plugin_b.insert("enabled".to_string(), Value::Boolean(true)); plugins.insert("plugin-b@test".to_string(), Value::Table(plugin_b)); root.insert("plugins".to_string(), Value::Table(plugins)); let config_toml = toml::to_string(&Value::Table(root)).expect("plugin test config should serialize"); let outcome = load_plugins_from_config(&config_toml, codex_home.path(), Some(AuthMode::Chatgpt)).await; assert_eq!( outcome.effective_apps(), vec![ AppConnectorId("connector_example".to_string()), AppConnectorId("connector_gmail".to_string()), ] ); } #[tokio::test] async fn effective_apps_preserves_app_config_order() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache") .join("test/sample/local"); write_file( &plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample"}"#, ); write_file( &plugin_root.join(".app.json"), r#"{ "apps": { "slack": { "id": "connector_slack" }, "github": { "id": "connector_github" }, "slack-copy": { "id": "connector_slack" } } }"#, ); let outcome = load_plugins_from_config( &plugin_config_toml(/*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()), ] ); } #[test] fn capability_index_filters_inactive_and_zero_capability_plugins() { let codex_home = TempDir::new().unwrap(); let connector = |id: &str| AppConnectorId(id.to_string()); let app = |name: &str, connector_id: &str| app_declaration(name, connector_id); let http_server = |url: &str| McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: url.to_string(), bearer_token_env_var: None, http_headers: None, env_http_headers: None, http_headers_helper: None, }, environment_id: "local".to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }; let plugin = |config_name: &str, dir_name: &str, manifest_name: &str| LoadedPlugin { config_name: config_name.to_string(), remote_plugin_id: None, manifest_name: Some(manifest_name.to_string()), plugin_namespace: Some( config_name .split_once('@') .map_or(config_name, |(name, _)| name) .to_string(), ), manifest_description: None, root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(), enabled: true, skill_roots: Vec::new(), skill_discovery_mode: SkillDiscoveryMode::Recursive, disabled_skill_paths: HashSet::new(), has_enabled_skills: false, mcp_servers: HashMap::new(), apps: Vec::new(), hook_sources: Vec::new(), hook_load_warnings: Vec::new(), error: None, }; let summary = |config_name: &str, display_name: &str| PluginCapabilitySummary { config_name: config_name.to_string(), display_name: display_name.to_string(), plugin_namespace: Some( config_name .split_once('@') .map_or(config_name, |(name, _)| name) .to_string(), ), description: None, ..PluginCapabilitySummary::default() }; let outcome = PluginLoadOutcome::from_plugins(vec![ LoadedPlugin { skill_roots: vec![codex_home.path().join("skills-plugin/skills").abs()], has_enabled_skills: true, ..plugin("skills@test", "skills-plugin", "skills-plugin") }, LoadedPlugin { mcp_servers: HashMap::from([("alpha".to_string(), http_server("https://alpha"))]), apps: vec![app("example", "connector_example")], ..plugin("alpha@test", "alpha-plugin", "alpha-plugin") }, LoadedPlugin { mcp_servers: HashMap::from([("beta".to_string(), http_server("https://beta"))]), apps: vec![ app("example", "connector_example"), app("gmail", "connector_gmail"), ], ..plugin("beta@test", "beta-plugin", "beta-plugin") }, plugin("empty@test", "empty-plugin", "empty-plugin"), LoadedPlugin { enabled: false, skill_roots: vec![codex_home.path().join("disabled-plugin/skills").abs()], apps: vec![app("hidden", "connector_hidden")], ..plugin("disabled@test", "disabled-plugin", "disabled-plugin") }, LoadedPlugin { apps: vec![app("broken", "connector_broken")], error: Some("failed to load".to_string()), ..plugin("broken@test", "broken-plugin", "broken-plugin") }, ]); assert_eq!( outcome.capability_summaries(), &[ PluginCapabilitySummary { has_skills: true, ..summary("skills@test", "skills-plugin") }, PluginCapabilitySummary { mcp_server_names: vec!["alpha".to_string()], app_connector_ids: vec![connector("connector_example")], ..summary("alpha@test", "alpha-plugin") }, PluginCapabilitySummary { mcp_server_names: vec!["beta".to_string()], app_connector_ids: vec![ connector("connector_example"), connector("connector_gmail"), ], ..summary("beta@test", "beta-plugin") }, ] ); } #[tokio::test] async fn load_plugins_returns_empty_when_feature_disabled() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache") .join("test/sample/local"); write_file( &plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample"}"#, ); write_file( &plugin_root.join("skills/sample-search/SKILL.md"), "---\nname: sample-search\ndescription: search sample data\n---\n", ); write_file( &codex_home.path().join(CONFIG_TOML_FILE), &plugin_config_toml( /*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()); } #[tokio::test] async fn plugin_cache_ignores_unrelated_session_overrides() { 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 = manager .plugins_for_config(&config(r#"model = "first""#)) .await; std::fs::remove_file(plugin_root.join(".mcp.json")).unwrap(); let second = manager .plugins_for_config(&config(r#"model = "second""#)) .await; assert_eq!(second, first); assert_eq!(second.plugins()[0].mcp_servers.len(), 1); } #[tokio::test] async fn skill_roots_resolve_remote_plugin_identity_from_authoritative_source() { let mut duplicate_plugin = remote_installed_plugin("sample"); duplicate_plugin.id = "plugins~Plugin_duplicate".to_string(); for (installed_plugins, expected_remote_plugin_id) in [ (None, Some("plugins~Plugin_persisted")), (Some(Vec::new()), None), ( Some(vec![remote_installed_plugin("sample"), duplicate_plugin]), Some("plugins~Plugin_sample"), ), ] { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache/openai-curated-remote/sample/local"); write_plugin( codex_home .path() .join("plugins/cache/openai-curated-remote") .as_path(), "sample/local", "sample", ); write_file( &plugin_root.join("skills/SKILL.md"), "---\nname: search\ndescription: first\n---\n", ); write_file( &codex_home.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true remote_plugin = true [plugins."sample@openai-curated-remote"] enabled = true "#, ); let plugin_id = PluginId::parse("sample@openai-curated-remote").expect("remote plugin id should parse"); PluginStore::new(codex_home.path().to_path_buf()) .write_remote_plugin_id(&plugin_id, "plugins~Plugin_persisted") .expect("persist remote plugin id"); let config = load_config(codex_home.path(), codex_home.path()).await; let manager = test_plugins_manager_with_options( codex_home.path().to_path_buf(), Some(Product::Codex), Some(AuthMode::Chatgpt), ); if let Some(installed_plugins) = installed_plugins { manager.write_remote_installed_plugins_cache(installed_plugins); } let plugin_outcome = manager.plugins_for_config(&config).await; assert_eq!( manager .telemetry_metadata_for_plugin_id(&plugin_id) .remote_plugin_id .as_deref(), expected_remote_plugin_id ); assert_eq!( plugin_outcome .effective_plugin_skill_roots() .into_iter() .map(|root| { ( root.plugin_identity.plugin_id, root.plugin_identity.remote_plugin_id, ) }) .collect::>(), vec![( "sample@openai-curated-remote".to_string(), expected_remote_plugin_id.map(str::to_string), )], ); } } #[test] fn loaded_plugins_cache_invalidation_rejects_stale_load_completion() { let codex_home = TempDir::new().unwrap(); let manager = test_plugins_manager(codex_home.path().to_path_buf()); let cache_key = PluginLoadCacheKey { configured_plugins: HashMap::new(), skill_config_rules: SkillConfigRules::default(), remote_global_catalog_active: false, auth_identity: None, }; let stale_generation = manager.loaded_plugins_cache_generation(); manager.clear_loaded_plugins_cache(); manager.cache_loaded_plugins_if_current( stale_generation, cache_key.clone(), Vec::new(), crate::skill_snapshots::new_plugin_skill_snapshots(), ); assert_eq!(manager.cached_loaded_plugins(&cache_key), None); } #[tokio::test] async fn plugins_for_config_discards_in_flight_load_after_account_change() { let codex_home = TempDir::new().unwrap(); write_cached_plugin( codex_home.path(), REMOTE_GLOBAL_MARKETPLACE_NAME, "remote-only", ); let config = PluginsConfigInput::new( unrestricted_config_layer_stack(), String::new(), /*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()] ); } #[tokio::test] async fn load_plugins_rejects_invalid_plugin_keys() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache") .join("test/sample/local"); write_file( &plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample"}"#, ); let mut root = toml::map::Map::new(); let mut features = toml::map::Map::new(); features.insert("plugins".to_string(), Value::Boolean(true)); root.insert("features".to_string(), Value::Table(features)); let mut plugin = toml::map::Map::new(); plugin.insert("enabled".to_string(), Value::Boolean(true)); let mut plugins = toml::map::Map::new(); plugins.insert("sample".to_string(), Value::Table(plugin)); root.insert("plugins".to_string(), Value::Table(plugins)); let outcome = load_plugins_from_config( &toml::to_string(&Value::Table(root)).expect("plugin test config should serialize"), codex_home.path(), /*auth_mode*/ None, ) .await; assert_eq!(outcome.plugins().len(), 1); assert_eq!( outcome.plugins()[0].error.as_deref(), Some("invalid plugin key `sample`; expected @") ); assert!(outcome.effective_plugin_skill_roots().is_empty()); assert!(outcome.effective_mcp_servers().is_empty()); } #[tokio::test] async fn install_plugin_updates_config_with_relative_path_and_plugin_key() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); write_plugin(&repo_root, "sample-plugin", "sample-plugin"); fs::write( repo_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "sample-plugin", "source": { "source": "local", "path": "./sample-plugin" }, "policy": { "authentication": "ON_USE" } } ] }"#, ) .unwrap(); let result = test_plugins_manager(tmp.path().to_path_buf()) .install_plugin( &unrestricted_config_layer_stack(), PluginInstallRequest { plugin_name: "sample-plugin".to_string(), marketplace_path: AbsolutePathBuf::try_from( repo_root.join(".agents/plugins/marketplace.json"), ) .unwrap(), }, ) .await .unwrap(); let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local"); assert_eq!( result, PluginInstallOutcome { plugin_id: PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(), plugin_version: "local".to_string(), installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(), auth_policy: MarketplacePluginAuthPolicy::OnUse, } ); let config = fs::read_to_string(tmp.path().join("config.toml")).unwrap(); assert!(config.contains(r#"[plugins."sample-plugin@debug"]"#)); assert!(config.contains("enabled = true")); } #[tokio::test] async fn strict_install_requires_allowed_local_marketplace_to_be_added_first() { let codex_home = TempDir::new().expect("create Codex home"); let marketplace_root = codex_home.path().join("company-marketplace"); write_plugin(&marketplace_root, "sample", "sample"); write_file( &marketplace_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "company", "plugins": [ { "name": "sample", "source": {"source": "local", "path": "./sample"} } ] }"#, ); let marketplace_root = marketplace_root .canonicalize() .expect("canonical marketplace root"); let requirements = format!( r#" [marketplaces] restrict_to_allowed_sources = true [marketplaces.allowed_sources.company] source = "local" path = {marketplace_root:?} "# ); let config = config_layer_stack_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 = config_layer_stack_with_requirements(codex_home.path(), &user_config, &requirements); let outcome = manager .install_plugin( &config, PluginInstallRequest { plugin_name: "sample".to_string(), marketplace_path, }, ) .await .expect("configured allowlisted marketplace should be installable"); assert_eq!( outcome.plugin_id, PluginId::new("sample".to_string(), "company".to_string()).expect("plugin id") ); } #[tokio::test] async fn install_openai_curated_plugin_uses_short_sha_cache_version() { let tmp = tempfile::tempdir().unwrap(); let curated_root = curated_plugins_repo_path(tmp.path()); write_openai_curated_marketplace(&curated_root, &["slack"]); write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); let result = test_plugins_manager(tmp.path().to_path_buf()) .install_plugin( &unrestricted_config_layer_stack(), PluginInstallRequest { plugin_name: "slack".to_string(), marketplace_path: AbsolutePathBuf::try_from( curated_root.join(".agents/plugins/marketplace.json"), ) .unwrap(), }, ) .await .unwrap(); let installed_path = tmp.path().join(format!( "plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}" )); assert_eq!( result, PluginInstallOutcome { plugin_id: PluginId::new( "slack".to_string(), OPENAI_CURATED_MARKETPLACE_NAME.to_string() ) .unwrap(), plugin_version: TEST_CURATED_PLUGIN_CACHE_VERSION.to_string(), installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(), auth_policy: MarketplacePluginAuthPolicy::OnInstall, } ); } #[tokio::test] async fn install_plugin_uses_manifest_version_for_non_curated_plugins() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); write_plugin_with_version( &repo_root, "sample-plugin", "sample-plugin", Some("1.2.3-beta+7"), ); fs::write( repo_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "sample-plugin", "source": { "source": "local", "path": "./sample-plugin" } } ] }"#, ) .unwrap(); let result = test_plugins_manager(tmp.path().to_path_buf()) .install_plugin( &unrestricted_config_layer_stack(), PluginInstallRequest { plugin_name: "sample-plugin".to_string(), marketplace_path: AbsolutePathBuf::try_from( repo_root.join(".agents/plugins/marketplace.json"), ) .unwrap(), }, ) .await .unwrap(); let installed_path = tmp .path() .join("plugins/cache/debug/sample-plugin/1.2.3-beta+7"); assert_eq!( result, PluginInstallOutcome { plugin_id: PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(), plugin_version: "1.2.3-beta+7".to_string(), installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(), auth_policy: MarketplacePluginAuthPolicy::OnInstall, } ); } #[tokio::test] async fn install_plugin_writes_marketplace_manifest_fallback_when_missing_plugin_json() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); let plugin_root = repo_root.join("plugins/quality-review"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); fs::create_dir_all(plugin_root.join("skills/thermo-nuclear-code-quality-review")).unwrap(); fs::write( plugin_root.join("skills/thermo-nuclear-code-quality-review/SKILL.md"), "review skill", ) .unwrap(); write_file( &plugin_root.join("commands/review.md"), "---\ndescription: Review code\n---\nReview the current change.\n", ); fs::write( repo_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "quality-review", "description": "Strict code quality review focused on maintainability.", "source": "./plugins/quality-review", "author": { "name": "Byron Grogan" }, "skills": [ "./skills/thermo-nuclear-code-quality-review" ], "commands": ["./commands/review.md"], "category": "code-review" } ] }"#, ) .unwrap(); let result = test_plugins_manager(tmp.path().to_path_buf()) .install_plugin( &unrestricted_config_layer_stack(), PluginInstallRequest { plugin_name: "quality-review".to_string(), marketplace_path: AbsolutePathBuf::try_from( repo_root.join(".agents/plugins/marketplace.json"), ) .unwrap(), }, ) .await .unwrap(); let installed_path = tmp.path().join("plugins/cache/debug/quality-review/local"); assert_eq!( result, PluginInstallOutcome { plugin_id: PluginId::new("quality-review".to_string(), "debug".to_string()).unwrap(), plugin_version: "local".to_string(), installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(), auth_policy: MarketplacePluginAuthPolicy::OnInstall, } ); assert!(!plugin_root.join(".codex-plugin/plugin.json").exists()); assert!( !tmp.path() .join("plugins/.marketplace-plugin-source-staging") .exists() ); let manifest = crate::manifest::load_plugin_manifest(&installed_path).unwrap(); assert_eq!(manifest.name, "quality-review"); assert_eq!( manifest.description.as_deref(), Some("Strict code quality review focused on maintainability.") ); assert_eq!( manifest.paths.skills, vec![ AbsolutePathBuf::try_from( installed_path.join("skills/thermo-nuclear-code-quality-review") ) .unwrap() ] ); let interface = manifest.interface.expect("fallback interface"); assert_eq!(interface.developer_name.as_deref(), Some("Byron Grogan")); assert_eq!(interface.category.as_deref(), Some("code-review")); let fallback_json: serde_json::Value = serde_json::from_str( &fs::read_to_string(installed_path.join(".codex-plugin/plugin.json")).unwrap(), ) .unwrap(); assert_eq!( fallback_json["author"], serde_json::json!({ "name": "Byron Grogan" }) ); assert_eq!(fallback_json["category"], "code-review"); assert_eq!( fs::read_to_string( installed_path .join(".codex-plugin/migrated-command-skills/source-command-review/SKILL.md") ) .unwrap(), "---\nname: \"source-command-review\"\ndescription: \"Review code\"\n---\n\n# source-command-review\n\nUse this skill when the user asks to run the migrated source command `review`.\n\n## Command Template\n\nReview the current change.\n" ); } #[tokio::test] async fn install_plugin_supports_git_subdir_marketplace_sources() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("marketplace"); let remote_repo = tmp.path().join("remote-plugin-repo"); let remote_repo_url = url::Url::from_directory_path(&remote_repo) .unwrap() .to_string(); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); write_plugin(&remote_repo, "plugins/toolkit", "toolkit"); init_git_repo(&remote_repo); fs::write( repo_root.join(".agents/plugins/marketplace.json"), format!( r#"{{ "name": "debug", "plugins": [ {{ "name": "toolkit", "source": {{ "source": "git-subdir", "url": "{remote_repo_url}", "path": "plugins/toolkit" }} }} ] }}"# ), ) .unwrap(); let result = test_plugins_manager(tmp.path().to_path_buf()) .install_plugin( &unrestricted_config_layer_stack(), PluginInstallRequest { plugin_name: "toolkit".to_string(), marketplace_path: AbsolutePathBuf::try_from( repo_root.join(".agents/plugins/marketplace.json"), ) .unwrap(), }, ) .await .unwrap(); let installed_path = tmp.path().join("plugins/cache/debug/toolkit/local"); assert_eq!( result, PluginInstallOutcome { plugin_id: PluginId::new("toolkit".to_string(), "debug".to_string()).unwrap(), plugin_version: "local".to_string(), installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(), auth_policy: MarketplacePluginAuthPolicy::OnInstall, } ); assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); } #[tokio::test] async fn install_plugin_supports_relative_git_subdir_marketplace_sources() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("marketplace"); let remote_repo = repo_root.join("remote-plugin-repo"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); write_plugin(&remote_repo, "plugins/toolkit", "toolkit"); init_git_repo(&remote_repo); fs::write( repo_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "toolkit", "source": { "source": "git-subdir", "url": "./remote-plugin-repo", "path": "plugins/toolkit" } } ] }"#, ) .unwrap(); let result = test_plugins_manager(tmp.path().to_path_buf()) .install_plugin( &unrestricted_config_layer_stack(), PluginInstallRequest { plugin_name: "toolkit".to_string(), marketplace_path: AbsolutePathBuf::try_from( repo_root.join(".agents/plugins/marketplace.json"), ) .unwrap(), }, ) .await .unwrap(); let installed_path = tmp.path().join("plugins/cache/debug/toolkit/local"); assert_eq!( result, PluginInstallOutcome { plugin_id: PluginId::new("toolkit".to_string(), "debug".to_string()).unwrap(), plugin_version: "local".to_string(), installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(), auth_policy: MarketplacePluginAuthPolicy::OnInstall, } ); assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); } #[tokio::test] async fn uninstall_plugin_removes_cache_and_config_entry() { let tmp = tempfile::tempdir().unwrap(); write_plugin( &tmp.path().join("plugins/cache/debug"), "sample-plugin/local", "sample-plugin", ); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."sample-plugin@debug"] enabled = true "#, ); let manager = test_plugins_manager(tmp.path().to_path_buf()); manager .uninstall_plugin("sample-plugin@debug".to_string()) .await .unwrap(); manager .uninstall_plugin("sample-plugin@debug".to_string()) .await .unwrap(); assert!( !tmp.path() .join("plugins/cache/debug/sample-plugin") .exists() ); let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#)); } #[tokio::test] async fn list_marketplaces_includes_enabled_state() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); write_plugin( &tmp.path().join("plugins/cache/debug"), "enabled-plugin/local", "enabled-plugin", ); write_plugin( &tmp.path().join("plugins/cache/debug"), "disabled-plugin/local", "disabled-plugin", ); fs::write( repo_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "enabled-plugin", "source": { "source": "local", "path": "./enabled-plugin" } }, { "name": "disabled-plugin", "source": { "source": "local", "path": "./disabled-plugin" } } ] }"#, ) .unwrap(); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."enabled-plugin@debug"] enabled = true [plugins."disabled-plugin@debug"] enabled = false "#, ); let config = load_config(tmp.path(), &repo_root).await; let marketplaces = test_plugins_manager(tmp.path().to_path_buf()) .list_marketplaces_for_config( &config, &[AbsolutePathBuf::try_from(repo_root).unwrap()], /*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, }, ], } ); } #[tokio::test] async fn list_marketplaces_returns_empty_when_feature_disabled() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); fs::write( repo_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "enabled-plugin", "source": { "source": "local", "path": "./enabled-plugin" } } ] }"#, ) .unwrap(); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = false [plugins."enabled-plugin@debug"] enabled = true "#, ); let config = load_config(tmp.path(), &repo_root).await; let marketplaces = test_plugins_manager(tmp.path().to_path_buf()) .list_marketplaces_for_config( &config, &[AbsolutePathBuf::try_from(repo_root).unwrap()], /*include_openai_curated*/ true, ) .unwrap() .marketplaces; assert_eq!(marketplaces, Vec::new()); } #[tokio::test] async fn list_marketplaces_excludes_plugins_with_explicit_empty_products() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); fs::write( repo_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "disabled-plugin", "source": { "source": "local", "path": "./disabled-plugin" }, "policy": { "products": [] } }, { "name": "default-plugin", "source": { "source": "local", "path": "./default-plugin" } } ] }"#, ) .unwrap(); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true "#, ); let config = load_config(tmp.path(), &repo_root).await; let marketplaces = test_plugins_manager(tmp.path().to_path_buf()) .list_marketplaces_for_config( &config, &[AbsolutePathBuf::try_from(repo_root).unwrap()], /*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, }] ); } #[tokio::test] async fn read_plugin_for_config_returns_plugins_disabled_when_feature_disabled() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); let marketplace_path = AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(); fs::write( marketplace_path.as_path(), r#"{ "name": "debug", "plugins": [ { "name": "enabled-plugin", "source": { "source": "local", "path": "./enabled-plugin" } } ] }"#, ) .unwrap(); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = false [plugins."enabled-plugin@debug"] enabled = true "#, ); let config = load_config(tmp.path(), &repo_root).await; let err = test_plugins_manager(tmp.path().to_path_buf()) .read_plugin_for_config( &config, &PluginReadRequest { plugin_name: "enabled-plugin".to_string(), marketplace_path, }, ) .await .unwrap_err(); assert!(matches!(err, MarketplaceError::PluginsDisabled)); } #[tokio::test] async fn read_plugin_for_config_filters_mcp_servers_for_codex_backend_auth() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); write_file( &repo_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "sample-plugin", "source": { "source": "local", "path": "./sample-plugin" } } ] }"#, ); write_file( &repo_root.join("sample-plugin/.codex-plugin/plugin.json"), r#"{"name":"sample-plugin"}"#, ); write_file( &repo_root.join("sample-plugin/.app.json"), r#"{"apps":{"sample-mcp":{"id":"connector_sample"}}}"#, ); write_file( &repo_root.join("sample-plugin/.mcp.json"), r#"{"mcpServers":{"other-mcp":{"command":"other-mcp"},"sample-mcp":{"command":"sample-mcp"}}}"#, ); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true "#, ); let config = load_config(tmp.path(), &repo_root).await; let request = PluginReadRequest { plugin_name: "sample-plugin".to_string(), marketplace_path: AbsolutePathBuf::try_from( repo_root.join(".agents/plugins/marketplace.json"), ) .unwrap(), }; let chatgpt_outcome = test_plugins_manager_with_options( tmp.path().to_path_buf(), Some(Product::Codex), Some(AuthMode::Chatgpt), ) .read_plugin_for_config(&config, &request) .await .unwrap(); assert_eq!( chatgpt_outcome.plugin.mcp_server_names, vec!["other-mcp".to_string()] ); assert_eq!( chatgpt_outcome.plugin.apps, vec![AppConnectorId("connector_sample".to_string())] ); let api_key_outcome = test_plugins_manager_with_options( tmp.path().to_path_buf(), Some(Product::Codex), Some(AuthMode::ApiKey), ) .read_plugin_for_config(&config, &request) .await .unwrap(); assert_eq!( api_key_outcome.plugin.mcp_server_names, vec!["other-mcp".to_string(), "sample-mcp".to_string()] ); assert!(api_key_outcome.plugin.apps.is_empty()); } #[tokio::test] async fn read_plugin_for_config_uses_marketplace_manifest_fallback_paths_for_local_source() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); let plugin_root = repo_root.join("sample-plugin"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); write_file( &repo_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "sample-plugin", "source": "./sample-plugin", "apps": "./config/custom.app.json", "mcpServers": { "sample-mcp": { "command": "sample-mcp" } } } ] }"#, ); write_file( &plugin_root.join("config/custom.app.json"), r#"{"apps":{"sample-app":{"id":"connector_sample"}}}"#, ); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true "#, ); let config = load_config(tmp.path(), &repo_root).await; let manager = test_plugins_manager(tmp.path().to_path_buf()); 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()] ); } #[tokio::test] async fn agent_plugin_read_and_tool_suggestions_use_portable_capabilities_only() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); let plugin_root = repo_root.join("agent-plugin"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); write_file( &repo_root.join(".agents/plugins/marketplace.json"), r#"{"name":"debug","plugins":[{"name":"agent-plugin","source":"./agent-plugin"}]}"#, ); write_file( &plugin_root.join("plugin.json"), r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"agent.tools"}"#, ); write_file( &plugin_root.join("skills/direct/SKILL.md"), "---\nname: direct\ndescription: Direct skill\n---\n", ); write_file( &plugin_root.join("skills/group/nested/SKILL.md"), "---\nname: nested\ndescription: Nested skill\n---\n", ); write_file( &plugin_root.join("mcp.json"), r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"portable":{"type":"stdio","command":"echo"}}}"#, ); write_file( &plugin_root.join(".codex-plugin/plugin.json"), r#"{"apps":"./.app.json","hooks":"./hooks/hooks.json"}"#, ); write_file( &plugin_root.join(".app.json"), r#"{"apps":{"legacy":{"id":"connector_legacy"}}}"#, ); write_file( &plugin_root.join("hooks/hooks.json"), r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo legacy"}]}]}}"#, ); write_file( &tmp.path().join(CONFIG_TOML_FILE), "[features]\nplugins = true\n", ); let config = load_config(tmp.path(), &repo_root).await; let manager = test_plugins_manager(tmp.path().to_path_buf()); let plugin = manager .list_marketplaces_for_config( &config, &[AbsolutePathBuf::try_from(repo_root).unwrap()], /*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!["agent.tools:direct"] ); assert_eq!(detail.mcp_server_names, vec!["portable"]); assert!(detail.apps.is_empty()); assert!(detail.hooks.is_empty()); assert!(suggestion.has_skills); assert_eq!(suggestion.mcp_server_names, vec!["portable"]); assert!(suggestion.app_connector_ids.is_empty()); } #[tokio::test] async fn read_plugin_for_config_does_not_fallback_from_invalid_plugin_manifest() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); let plugin_root = repo_root.join("sample-plugin"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); write_file( &repo_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "sample-plugin", "source": "./sample-plugin", "description": "Fallback metadata" } ] }"#, ); write_file(&plugin_root.join(".codex-plugin/plugin.json"), "{"); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true "#, ); let config = load_config(tmp.path(), &repo_root).await; let err = test_plugins_manager(tmp.path().to_path_buf()) .read_plugin_for_config( &config, &PluginReadRequest { plugin_name: "sample-plugin".to_string(), marketplace_path: AbsolutePathBuf::try_from( repo_root.join(".agents/plugins/marketplace.json"), ) .unwrap(), }, ) .await .unwrap_err(); assert_eq!(err.to_string(), "missing or invalid plugin.json"); } #[tokio::test] async fn read_plugin_for_config_uses_user_layer_skill_settings_only() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); let plugin_root = repo_root.join("enabled-plugin"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); write_file( &repo_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "enabled-plugin", "source": { "source": "local", "path": "./enabled-plugin" } } ] }"#, ); write_file( &plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"enabled-plugin"}"#, ); write_file( &plugin_root.join("skills/sample-search/SKILL.md"), "---\nname: sample-search\ndescription: search sample data\n---\n", ); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."enabled-plugin@debug"] enabled = true "#, ); write_file( &repo_root.join(".codex/config.toml"), r#"[[skills.config]] name = "enabled-plugin:sample-search" enabled = false "#, ); let config = load_config(tmp.path(), &repo_root).await; let outcome = test_plugins_manager(tmp.path().to_path_buf()) .read_plugin_for_config( &config, &PluginReadRequest { plugin_name: "enabled-plugin".to_string(), marketplace_path: AbsolutePathBuf::try_from( repo_root.join(".agents/plugins/marketplace.json"), ) .unwrap(), }, ) .await .unwrap(); assert!(outcome.plugin.disabled_skill_paths.is_empty()); } #[tokio::test] async fn read_plugin_for_config_uninstalled_git_source_requires_install_without_cloning() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo"); let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo) .unwrap() .to_string(); fs::create_dir_all(repo_root.join(".git")).unwrap(); write_file( &repo_root.join(".agents/plugins/marketplace.json"), &format!( r#"{{ "name": "debug", "plugins": [ {{ "name": "toolkit", "source": {{ "source": "git-subdir", "url": "{missing_remote_repo_url}", "path": "plugins/toolkit" }}, "policy": {{ "installation": "AVAILABLE", "authentication": "ON_INSTALL" }} }} ] }}"# ), ); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true "#, ); let config = load_config(tmp.path(), &repo_root).await; let outcome = test_plugins_manager(tmp.path().to_path_buf()) .read_plugin_for_config( &config, &PluginReadRequest { plugin_name: "toolkit".to_string(), marketplace_path: AbsolutePathBuf::try_from( repo_root.join(".agents/plugins/marketplace.json"), ) .unwrap(), }, ) .await .unwrap(); assert_eq!( outcome.plugin.details_unavailable_reason, Some(PluginDetailsUnavailableReason::InstallRequiredForRemoteSource) ); assert!(!outcome.plugin.installed); let expected_description = format!( "This is a cross-repo plugin. Install it to view more detailed information. The source of the plugin is {missing_remote_repo_url}, path `plugins/toolkit`." ); assert_eq!( outcome.plugin.description.as_deref(), Some(expected_description.as_str()) ); assert!(outcome.plugin.skills.is_empty()); assert!(outcome.plugin.apps.is_empty()); assert!(outcome.plugin.mcp_server_names.is_empty()); assert!( !tmp.path() .join("plugins/.marketplace-plugin-source-staging") .exists() ); } #[tokio::test] async fn read_plugin_for_config_installed_git_source_reads_from_cache_without_cloning() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo"); let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo) .unwrap() .to_string(); fs::create_dir_all(repo_root.join(".git")).unwrap(); write_file( &repo_root.join(".agents/plugins/marketplace.json"), &format!( r#"{{ "name": "debug", "plugins": [ {{ "name": "toolkit", "source": {{ "source": "git-subdir", "url": "{missing_remote_repo_url}", "path": "plugins/toolkit" }}, "category": "Developer Tools" }} ] }}"# ), ); let cached_plugin_root = tmp.path().join("plugins/cache/debug/toolkit/local"); write_file( &cached_plugin_root.join(".codex-plugin/plugin.json"), r#"{ "name": "toolkit", "description": "Cached toolkit plugin", "interface": { "displayName": "Toolkit" } }"#, ); write_file( &cached_plugin_root.join("skills/search/SKILL.md"), "---\nname: search\ndescription: search cached data\n---\n", ); write_file( &cached_plugin_root.join(".app.json"), r#"{ "apps": { "calendar": { "id": "connector_calendar", "category": "First Category" }, "calendar_duplicate": { "id": "connector_calendar", "category": "Second Category" } } }"#, ); write_file( &cached_plugin_root.join(".mcp.json"), r#"{"mcpServers":{"toolkit":{"command":"toolkit-mcp"}}}"#, ); write_file( &cached_plugin_root.join("hooks/hooks.json"), r#"{ "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", "command": "echo startup" } ] } ], "PreToolUse": [ { "hooks": [ { "type": "command", "command": "echo first" }, { "type": "command", "command": "echo second" } ] } ] } }"#, ); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."toolkit@debug"] enabled = true [hooks.state."toolkit@debug:hooks/hooks.json:pre_tool_use:0:0"] enabled = false "#, ); let config = load_config(tmp.path(), &repo_root).await; let outcome = test_plugins_manager(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, None); assert_eq!( outcome.plugin.description.as_deref(), Some("Cached toolkit plugin") ); assert_eq!( outcome.plugin.interface, Some(PluginManifestInterface { display_name: Some("Toolkit".to_string()), category: Some("Developer Tools".to_string()), ..Default::default() }) ); assert!(outcome.plugin.installed); assert_eq!(outcome.plugin.skills.len(), 1); assert_eq!(outcome.plugin.skills[0].name, "toolkit:search"); assert_eq!( outcome.plugin.apps, vec![AppConnectorId("connector_calendar".to_string())] ); assert_eq!( outcome.plugin.app_category_by_id, HashMap::from([( "connector_calendar".to_string(), "First Category".to_string() )]) ); assert_eq!( outcome.plugin.hooks, vec![ PluginHookSummary { key: "toolkit@debug:hooks/hooks.json:pre_tool_use:0:0".to_string(), event_name: HookEventName::PreToolUse, }, PluginHookSummary { key: "toolkit@debug:hooks/hooks.json:pre_tool_use:0:1".to_string(), event_name: HookEventName::PreToolUse, }, PluginHookSummary { key: "toolkit@debug:hooks/hooks.json:session_start:0:0".to_string(), event_name: HookEventName::SessionStart, }, ] ); assert_eq!(outcome.plugin.mcp_server_names, vec!["toolkit".to_string()]); assert!( !tmp.path() .join("plugins/.marketplace-plugin-source-staging") .exists() ); } #[tokio::test] async fn list_marketplaces_installed_git_source_reads_metadata_from_cache_without_cloning() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo"); let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo) .unwrap() .to_string(); fs::create_dir_all(repo_root.join(".git")).unwrap(); write_file( &repo_root.join(".agents/plugins/marketplace.json"), &format!( r#"{{ "name": "debug", "plugins": [ {{ "name": "toolkit", "source": {{ "source": "git-subdir", "url": "{missing_remote_repo_url}", "path": "plugins/toolkit" }}, "category": "Developer Tools" }} ] }}"# ), ); let cached_plugin_root = tmp.path().join("plugins/cache/debug/toolkit/local"); write_file( &cached_plugin_root.join(".codex-plugin/plugin.json"), r##"{ "name": "toolkit", "interface": { "displayName": "Toolkit", "shortDescription": "Search cached data", "category": "Cached Category", "brandColor": "#3B82F6", "composerIcon": "./assets/icon.png", "logo": "./assets/logo.png", "screenshots": ["./assets/screenshot.png"] } }"##, ); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."toolkit@debug"] enabled = true "#, ); let config = load_config(tmp.path(), &repo_root).await; let marketplaces = test_plugins_manager(tmp.path().to_path_buf()) .list_marketplaces_for_config( &config, &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], /*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() ); } #[tokio::test] async fn list_marketplaces_includes_curated_repo_marketplace() { let tmp = tempfile::tempdir().unwrap(); let curated_root = curated_plugins_repo_path(tmp.path()); let plugin_root = curated_root.join("plugins/linear"); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true "#, ); fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap(); fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); fs::write( curated_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "openai-curated", "plugins": [ { "name": "linear", "source": { "source": "local", "path": "./plugins/linear" } } ] }"#, ) .unwrap(); fs::write( plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"linear"}"#, ) .unwrap(); let config = load_config(tmp.path(), tmp.path()).await; let marketplaces = test_plugins_manager_with_options( tmp.path().to_path_buf(), Some(Product::Codex), Some(AuthMode::Chatgpt), ) .list_marketplaces_for_config(&config, &[], /*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, }], } ); } #[tokio::test] async fn list_marketplaces_can_skip_openai_curated_before_loading() { let tmp = tempfile::tempdir().unwrap(); let curated_root = curated_plugins_repo_path(tmp.path()); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true "#, ); write_file( &curated_root.join(".agents/plugins/marketplace.json"), "{not valid json", ); let config = load_config(tmp.path(), tmp.path()).await; let outcome = test_plugins_manager(tmp.path().to_path_buf()) .list_marketplaces_for_config(&config, &[], /*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 ); } #[tokio::test] async fn list_marketplaces_uses_api_curated_manifest_when_selected() { let tmp = tempfile::tempdir().unwrap(); let curated_root = curated_plugins_repo_path(tmp.path()); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true "#, ); write_file( &curated_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "openai-curated", "plugins": [ { "name": "siwc-plugin", "source": { "source": "local", "path": "./plugins/siwc-plugin" } } ] }"#, ); write_file( &curated_root.join(".agents/plugins/api_marketplace.json"), r#"{ "name": "openai-api-curated", "interface": { "displayName": "OpenAI Curated" }, "plugins": [ { "name": "api-plugin", "source": { "source": "local", "path": "./plugins/api-plugin" } } ] }"#, ); let config = load_config(tmp.path(), tmp.path()).await; let manager = test_plugins_manager_with_options( tmp.path().to_path_buf(), Some(Product::Codex), Some(AuthMode::ApiKey), ); let marketplaces = manager .list_marketplaces_for_config(&config, &[], /*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, }], } ); } #[tokio::test] async fn list_marketplaces_selects_curated_catalog_only_from_authentication() { for (configured_provider, resolved_provider, auth_mode, expected_marketplace) in [ ( "openai", AMAZON_BEDROCK_PROVIDER_ID, None, OPENAI_API_CURATED_MARKETPLACE_NAME, ), ( AMAZON_BEDROCK_PROVIDER_ID, "openai", None, OPENAI_API_CURATED_MARKETPLACE_NAME, ), ( "openai", "ollama", None, OPENAI_API_CURATED_MARKETPLACE_NAME, ), ( "openai", "ollama", Some(AuthMode::ApiKey), OPENAI_API_CURATED_MARKETPLACE_NAME, ), ( "openai", "ollama", Some(AuthMode::Chatgpt), OPENAI_CURATED_MARKETPLACE_NAME, ), ] { let tmp = tempfile::tempdir().unwrap(); let curated_root = curated_plugins_repo_path(tmp.path()); write_file( &tmp.path().join(CONFIG_TOML_FILE), &format!( r#"model_provider = "{configured_provider}" [features] plugins = true "# ), ); write_openai_curated_marketplace(&curated_root, &["chatgpt-plugin"]); write_openai_api_curated_marketplace(&curated_root, &["api-plugin"]); let mut config = load_config(tmp.path(), tmp.path()).await; config.model_provider_id = resolved_provider.to_string(); let marketplaces = test_plugins_manager_with_options( tmp.path().to_path_buf(), Some(Product::Codex), auth_mode, ) .list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true) .unwrap() .marketplaces; assert_eq!( marketplaces .iter() .map(|marketplace| marketplace.name.as_str()) .collect::>(), vec![expected_marketplace], "unexpected curated catalog for provider `{resolved_provider}` with auth {auth_mode:?}" ); } } #[tokio::test] async fn list_marketplaces_uses_chatgpt_curated_manifest_for_bedrock_with_chatgpt_auth() { let tmp = tempfile::tempdir().unwrap(); let curated_root = curated_plugins_repo_path(tmp.path()); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"model_provider = "amazon-bedrock" [features] plugins = true "#, ); write_openai_curated_marketplace(&curated_root, &["chatgpt-plugin"]); write_openai_api_curated_marketplace(&curated_root, &["api-plugin"]); let config = load_config(tmp.path(), tmp.path()).await; let manager = test_plugins_manager_with_options( tmp.path().to_path_buf(), Some(Product::Codex), Some(AuthMode::Chatgpt), ); let marketplaces = manager .list_marketplaces_for_config(&config, &[], /*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) ); } #[tokio::test] async fn list_marketplaces_skips_missing_api_curated_manifest() { let tmp = tempfile::tempdir().unwrap(); let curated_root = curated_plugins_repo_path(tmp.path()); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true "#, ); write_file( &curated_root.join(".agents/plugins/marketplace.json"), "{not valid json", ); let config = load_config(tmp.path(), tmp.path()).await; let manager = test_plugins_manager_with_options( tmp.path().to_path_buf(), Some(Product::Codex), Some(AuthMode::BedrockApiKey), ); let outcome = manager .list_marketplaces_for_config(&config, &[], /*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 ); } #[tokio::test] async fn list_marketplaces_includes_installed_marketplace_roots() { let tmp = tempfile::tempdir().unwrap(); let marketplace_root = marketplace_install_root(tmp.path()).join("debug"); let plugin_root = marketplace_root.join("plugins/sample"); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [marketplaces.debug] last_updated = "2026-04-10T12:34:56Z" source_type = "git" source = "/tmp/debug" "#, ); fs::create_dir_all(marketplace_root.join(".agents/plugins")).unwrap(); fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); fs::write( marketplace_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "sample", "source": { "source": "local", "path": "./plugins/sample" } } ] }"#, ) .unwrap(); fs::write( plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample"}"#, ) .unwrap(); let config = load_config(tmp.path(), tmp.path()).await; let marketplaces = test_plugins_manager(tmp.path().to_path_buf()) .list_marketplaces_for_config(&config, &[], /*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(), } ); } #[tokio::test] async fn configured_marketplace_upgrade_invalidates_cached_tool_suggest_metadata() { let tmp = tempfile::tempdir().unwrap(); let remote_repo = tmp.path().join("remote-marketplace"); let remote_repo_url = url::Url::from_directory_path(&remote_repo) .unwrap() .to_string(); write_file( &remote_repo.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "sample", "source": { "source": "local", "path": "./plugins/sample" } } ] }"#, ); write_curated_plugin(&remote_repo, "sample"); write_file( &remote_repo.join("plugins/sample/.codex-plugin/plugin.json"), r#"{"name":"sample","description":"Before upgrade"}"#, ); init_git_repo(&remote_repo); write_file( &tmp.path().join(CONFIG_TOML_FILE), &format!( r#"[features] plugins = true [marketplaces.debug] source_type = "git" source = "{remote_repo_url}" "# ), ); let manager = test_plugins_manager(tmp.path().to_path_buf()); let config = load_config(tmp.path(), tmp.path()).await; let initial_upgrade = manager .upgrade_configured_marketplaces_for_config(&config, /*marketplace_name*/ None) .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")) .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 }] ); } #[tokio::test] async fn list_marketplaces_uses_config_when_known_registry_is_malformed() { let tmp = tempfile::tempdir().unwrap(); let marketplace_root = marketplace_install_root(tmp.path()).join("debug"); let plugin_root = marketplace_root.join("plugins/sample"); let registry_path = tmp.path().join(".tmp/known_marketplaces.json"); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [marketplaces.debug] last_updated = "2026-04-10T12:34:56Z" source_type = "git" source = "/tmp/debug" "#, ); fs::create_dir_all(marketplace_root.join(".agents/plugins")).unwrap(); fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); fs::write( marketplace_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "sample", "source": { "source": "local", "path": "./plugins/sample" } } ] }"#, ) .unwrap(); fs::write( plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample"}"#, ) .unwrap(); fs::create_dir_all(registry_path.parent().unwrap()).unwrap(); fs::write(registry_path, "{not valid json").unwrap(); let config = load_config(tmp.path(), tmp.path()).await; let marketplaces = test_plugins_manager(tmp.path().to_path_buf()) .list_marketplaces_for_config(&config, &[], /*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"); } #[tokio::test] async fn list_marketplaces_ignores_installed_roots_missing_from_config() { let tmp = tempfile::tempdir().unwrap(); let marketplace_root = marketplace_install_root(tmp.path()).join("debug"); let plugin_root = marketplace_root.join("plugins/sample"); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true "#, ); fs::create_dir_all(marketplace_root.join(".agents/plugins")).unwrap(); fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); fs::write( marketplace_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "sample", "source": { "source": "local", "path": "./plugins/sample" } } ] }"#, ) .unwrap(); fs::write( plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample"}"#, ) .unwrap(); let config = load_config(tmp.path(), tmp.path()).await; let marketplaces = test_plugins_manager(tmp.path().to_path_buf()) .list_marketplaces_for_config(&config, &[], /*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" ); } #[tokio::test] async fn list_marketplaces_uses_first_duplicate_plugin_entry() { let tmp = tempfile::tempdir().unwrap(); let repo_a_root = tmp.path().join("repo-a"); let repo_b_root = tmp.path().join("repo-b"); fs::create_dir_all(repo_a_root.join(".git")).unwrap(); fs::create_dir_all(repo_b_root.join(".git")).unwrap(); fs::create_dir_all(repo_a_root.join(".agents/plugins")).unwrap(); fs::create_dir_all(repo_b_root.join(".agents/plugins")).unwrap(); fs::write( repo_a_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "dup-plugin", "source": { "source": "local", "path": "./from-a" } } ] }"#, ) .unwrap(); fs::write( repo_b_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "dup-plugin", "source": { "source": "local", "path": "./from-b" } }, { "name": "b-only-plugin", "source": { "source": "local", "path": "./from-b-only" } } ] }"#, ) .unwrap(); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."dup-plugin@debug"] enabled = true [plugins."b-only-plugin@debug"] enabled = false "#, ); let config = load_config(tmp.path(), &repo_a_root).await; let marketplaces = test_plugins_manager(tmp.path().to_path_buf()) .list_marketplaces_for_config( &config, &[ AbsolutePathBuf::try_from(repo_a_root).unwrap(), AbsolutePathBuf::try_from(repo_b_root).unwrap(), ], /*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); } #[tokio::test] async fn list_marketplaces_marks_configured_plugin_uninstalled_when_cache_is_missing() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); fs::write( repo_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "sample-plugin", "source": { "source": "local", "path": "./sample-plugin" } } ] }"#, ) .unwrap(); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."sample-plugin@debug"] enabled = true "#, ); let config = load_config(tmp.path(), &repo_root).await; let marketplaces = test_plugins_manager(tmp.path().to_path_buf()) .list_marketplaces_for_config( &config, &[AbsolutePathBuf::try_from(repo_root).unwrap()], /*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, }], } ); } #[tokio::test] async fn featured_plugin_ids_for_config_uses_restriction_product_query_param() { let tmp = tempfile::tempdir().unwrap(); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true "#, ); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/backend-api/plugins/featured")) .and(query_param("platform", "chat")) .and(header("authorization", "Bearer Access Token")) .and(header("chatgpt-account-id", "account_id")) .respond_with(ResponseTemplate::new(200).set_body_string(r#"["chat-plugin"]"#)) .mount(&server) .await; let mut config = load_config(tmp.path(), tmp.path()).await; config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); let manager = test_plugins_manager_with_options( tmp.path().to_path_buf(), Some(Product::Chatgpt), /*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()]); } #[tokio::test] async fn featured_plugin_ids_for_config_defaults_query_param_to_codex() { let tmp = tempfile::tempdir().unwrap(); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true "#, ); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/backend-api/plugins/featured")) .and(query_param("platform", "codex")) .respond_with(ResponseTemplate::new(200).set_body_string(r#"["codex-plugin"]"#)) .mount(&server) .await; let mut config = load_config(tmp.path(), tmp.path()).await; config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); let manager = test_plugins_manager_with_options( tmp.path().to_path_buf(), /*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()]); } #[tokio::test] async fn remote_plugin_caches_refresh_warms_recommended_plugins_cache() { let tmp = tempfile::tempdir().unwrap(); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true "#, ); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/ps/plugins/suggested/codex")) .and(query_param("scope", "GLOBAL")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "enabled": true, "plugins": [] }))) .expect(1) .mount(&server) .await; let mut config = load_config(tmp.path(), tmp.path()).await; config.chatgpt_base_url = server.uri(); let manager = std::sync::Arc::new(test_plugins_manager(tmp.path().to_path_buf())); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let cache_key = recommended_plugins_cache_key(&config); manager.maybe_start_remote_plugin_caches_refresh( &config, Some(auth.clone()), /*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); } #[tokio::test] async fn recommended_plugins_mode_deduplicates_concurrent_cache_misses() { let tmp = tempfile::tempdir().unwrap(); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true "#, ); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/ps/plugins/suggested/codex")) .and(query_param("scope", "GLOBAL")) .and(header("authorization", "Bearer Access Token")) .and(header("chatgpt-account-id", "account_id")) .and(header("OAI-Product-Sku", "codex")) .respond_with( ResponseTemplate::new(200) .set_body_json(serde_json::json!({ "enabled": true, "plugins": [ { "id": "plugin_slack", "name": "slack", "display_name": "Slack" }, { "id": "plugin_github", "name": "github", "display_name": "GitHub" } ] })) .set_delay(Duration::from_millis(100)), ) .expect(1) .mount(&server) .await; let mut config = load_config(tmp.path(), tmp.path()).await; config.chatgpt_base_url = server.uri(); let manager = test_plugins_manager(tmp.path().to_path_buf()); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let expected = RecommendedPluginsMode::Endpoint { plugins: vec![ RecommendedPlugin { config_id: "github@openai-curated-remote".to_string(), remote_plugin_id: "plugin_github".to_string(), display_name: "GitHub".to_string(), }, RecommendedPlugin { config_id: "slack@openai-curated-remote".to_string(), remote_plugin_id: "plugin_slack".to_string(), display_name: "Slack".to_string(), }, ], }; let (left, right) = tokio::join!( manager.recommended_plugins_mode_for_config(&config, Some(&auth)), manager.recommended_plugins_mode_for_config(&config, Some(&auth)), ); assert_eq!((left, right), (expected.clone(), expected.clone())); assert_eq!( manager .recommended_plugins_mode_for_config(&config, Some(&auth)) .await, expected ); } #[tokio::test] async fn recommended_plugin_candidates_filter_installed_and_disabled_plugins() { let tmp = tempfile::tempdir().unwrap(); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true "#, ); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/ps/plugins/suggested/codex")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "enabled": true, "plugins": [ { "id": "plugin_linear", "name": "linear", "display_name": "Linear" }, { "id": "plugin_github", "name": "github", "display_name": "GitHub" }, { "id": "plugin_slack", "name": "slack", "display_name": "Slack" } ] }))) .expect(1) .mount(&server) .await; let mut config = load_config(tmp.path(), tmp.path()).await; config.chatgpt_base_url = server.uri(); let manager = test_plugins_manager(tmp.path().to_path_buf()); let mut installed_linear = remote_installed_plugin("linear"); installed_linear.id = "plugin_linear".to_string(); manager.write_remote_installed_plugins_cache(vec![installed_linear]); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let disabled_tools = [ToolSuggestDisabledTool::plugin( "github@openai-curated-remote", )]; let loaded_plugins = manager.plugins_for_config(&config).await; let candidates = manager .recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput { plugins_config: &config, loaded_plugins: &loaded_plugins, auth: Some(&auth), disabled_tools: &disabled_tools, app_server_client_name: None, }) .await; assert_eq!( candidates, Some(vec![DiscoverableTool::from(DiscoverablePluginInfo { id: "slack@openai-curated-remote".to_string(), remote_plugin_id: Some("plugin_slack".to_string()), name: "Slack".to_string(), description: None, has_skills: false, mcp_server_names: Vec::new(), app_connector_ids: Vec::new(), })]) ); } #[tokio::test] async fn recommended_plugins_mode_caches_explicit_false() { let tmp = tempfile::tempdir().unwrap(); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true "#, ); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/ps/plugins/suggested/codex")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "enabled": false, "plugins": [] }))) .expect(1) .mount(&server) .await; let mut config = load_config(tmp.path(), tmp.path()).await; config.chatgpt_base_url = server.uri(); let manager = test_plugins_manager(tmp.path().to_path_buf()); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); assert_eq!( manager .recommended_plugins_mode_for_config(&config, Some(&auth)) .await, RecommendedPluginsMode::Legacy ); assert_eq!( manager .recommended_plugins_mode_for_config(&config, Some(&auth)) .await, RecommendedPluginsMode::Legacy ); } #[tokio::test] async fn recommended_plugins_mode_retries_after_fetch_failure() { let tmp = tempfile::tempdir().unwrap(); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true "#, ); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/ps/plugins/suggested/codex")) .respond_with(ResponseTemplate::new(500).set_body_string("unavailable")) .expect(1) .mount(&server) .await; let mut config = load_config(tmp.path(), tmp.path()).await; config.chatgpt_base_url = server.uri(); let manager = test_plugins_manager(tmp.path().to_path_buf()); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); assert_eq!( manager .recommended_plugins_mode_for_config(&config, Some(&auth)) .await, RecommendedPluginsMode::Legacy ); server.reset().await; Mock::given(method("GET")) .and(path("/ps/plugins/suggested/codex")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "enabled": true, "plugins": [] }))) .expect(1) .mount(&server) .await; assert_eq!( manager .recommended_plugins_mode_for_config(&config, Some(&auth)) .await, RecommendedPluginsMode::Endpoint { plugins: Vec::new() } ); } #[test] fn refresh_curated_plugin_cache_replaces_existing_local_version_with_short_sha_version() { let tmp = tempfile::tempdir().unwrap(); let curated_root = curated_plugins_repo_path(tmp.path()); write_openai_curated_marketplace(&curated_root, &["slack"]); write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); let plugin_id = PluginId::new( "slack".to_string(), OPENAI_CURATED_MARKETPLACE_NAME.to_string(), ) .unwrap(); write_plugin( &tmp.path().join("plugins/cache/openai-curated"), "slack/local", "slack", ); assert!( refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) .expect("cache refresh should succeed") ); assert!( !tmp.path() .join("plugins/cache/openai-curated/slack/local") .exists() ); assert!( tmp.path() .join(format!( "plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}" )) .is_dir() ); } #[test] fn refresh_curated_plugin_cache_reinstalls_missing_configured_plugin_with_current_short_version() { let tmp = tempfile::tempdir().unwrap(); let curated_root = curated_plugins_repo_path(tmp.path()); write_openai_curated_marketplace(&curated_root, &["slack"]); write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); let plugin_id = PluginId::new( "slack".to_string(), OPENAI_CURATED_MARKETPLACE_NAME.to_string(), ) .unwrap(); assert!( refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) .expect("cache refresh should recreate missing configured plugin") ); assert!( tmp.path() .join(format!( "plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}" )) .is_dir() ); } #[test] fn refresh_curated_plugin_cache_reinstalls_missing_api_curated_plugin() { let tmp = tempfile::tempdir().unwrap(); let curated_root = curated_plugins_repo_path(tmp.path()); write_openai_curated_marketplace(&curated_root, &[]); write_openai_api_curated_marketplace(&curated_root, &["api-only"]); write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); let plugin_id = PluginId::new( "api-only".to_string(), OPENAI_API_CURATED_MARKETPLACE_NAME.to_string(), ) .unwrap(); assert!( refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) .expect("cache refresh should recreate missing configured API curated plugin") ); assert!( tmp.path() .join(format!( "plugins/cache/openai-api-curated/api-only/{TEST_CURATED_PLUGIN_CACHE_VERSION}" )) .is_dir() ); } #[test] fn refresh_curated_plugin_cache_leaves_api_curated_plugin_when_api_manifest_missing() { let tmp = tempfile::tempdir().unwrap(); let curated_root = curated_plugins_repo_path(tmp.path()); write_openai_curated_marketplace(&curated_root, &[]); write_cached_plugin(tmp.path(), OPENAI_API_CURATED_MARKETPLACE_NAME, "api-only"); let plugin_id = PluginId::new( "api-only".to_string(), OPENAI_API_CURATED_MARKETPLACE_NAME.to_string(), ) .unwrap(); assert!( !refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) .expect("cache refresh should skip missing API curated manifest") ); assert!( tmp.path() .join("plugins/cache/openai-api-curated/api-only/local") .is_dir() ); } #[test] fn refresh_curated_plugin_cache_removes_cache_for_plugin_removed_from_marketplace() { let tmp = tempfile::tempdir().unwrap(); let curated_root = curated_plugins_repo_path(tmp.path()); write_openai_curated_marketplace(&curated_root, &[]); let plugin_id = PluginId::new( "google-sheets".to_string(), OPENAI_CURATED_MARKETPLACE_NAME.to_string(), ) .unwrap(); let plugin_cache_root = tmp .path() .join("plugins/cache/openai-curated/google-sheets"); write_plugin( &tmp.path().join("plugins/cache/openai-curated"), &format!("google-sheets/{TEST_CURATED_PLUGIN_CACHE_VERSION}"), "google-sheets", ); assert!( refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) .expect("cache refresh should remove stale configured plugin") ); assert!(!plugin_cache_root.exists()); } #[test] fn curated_plugin_ids_from_config_keys_reads_latest_codex_home_user_config() { let tmp = tempfile::tempdir().unwrap(); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."slack@openai-curated"] enabled = true [plugins."api-only@openai-api-curated"] enabled = true [plugins."sample@debug"] enabled = true "#, ); assert_eq!( configured_curated_plugin_ids_from_codex_home(tmp.path()) .into_iter() .map(|plugin_id| plugin_id.as_key()) .collect::>(), vec![ "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::::new() ); } #[test] fn refresh_curated_plugin_cache_returns_false_when_configured_plugins_are_current() { let tmp = tempfile::tempdir().unwrap(); let curated_root = curated_plugins_repo_path(tmp.path()); write_openai_curated_marketplace(&curated_root, &["slack"]); let plugin_id = PluginId::new( "slack".to_string(), OPENAI_CURATED_MARKETPLACE_NAME.to_string(), ) .unwrap(); write_plugin( &tmp.path().join("plugins/cache/openai-curated"), &format!("slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}"), "slack", ); assert!( !refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) .expect("cache refresh should be a no-op when configured plugins are current") ); } #[test] fn refresh_curated_plugin_cache_migrates_full_sha_cache_version_to_short_version() { let tmp = tempfile::tempdir().unwrap(); let curated_root = curated_plugins_repo_path(tmp.path()); write_openai_curated_marketplace(&curated_root, &["slack"]); let plugin_id = PluginId::new( "slack".to_string(), OPENAI_CURATED_MARKETPLACE_NAME.to_string(), ) .unwrap(); write_plugin( &tmp.path().join("plugins/cache/openai-curated"), &format!("slack/{TEST_CURATED_PLUGIN_SHA}"), "slack", ); assert!( refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) .expect("cache refresh should migrate the full sha cache version") ); assert!( !tmp.path() .join(format!( "plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_SHA}" )) .exists() ); assert!( tmp.path() .join(format!( "plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}" )) .is_dir() ); } #[test] fn refresh_non_curated_plugin_cache_replaces_existing_local_version_with_manifest_version() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3")); write_file( &repo_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "sample-plugin", "source": { "source": "local", "path": "./sample-plugin" } } ] }"#, ); write_plugin( &tmp.path().join("plugins/cache/debug"), "sample-plugin/local", "sample-plugin", ); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."sample-plugin@debug"] enabled = true "#, ); assert!( refresh_non_curated_plugin_cache( tmp.path(), &[AbsolutePathBuf::try_from(repo_root).unwrap()], &["sample-plugin@debug".to_string()], ) .expect("cache refresh should succeed") ); assert!( !tmp.path() .join("plugins/cache/debug/sample-plugin/local") .exists() ); assert!( tmp.path() .join("plugins/cache/debug/sample-plugin/1.2.3") .is_dir() ); } #[tokio::test] async fn non_curated_plugin_cache_refresh_preserves_manual_priority() { let codex_home = TempDir::new().unwrap(); let marketplace_root = codex_home.path().join("marketplace"); fs::create_dir_all(marketplace_root.join(".git")).unwrap(); write_plugin_with_version( &marketplace_root, "sample-plugin", "sample-plugin", Some("1.0.0"), ); write_file( &marketplace_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [{ "name": "sample-plugin", "source": { "source": "local", "path": "./sample-plugin" } }] }"#, ); write_file( &codex_home.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."sample-plugin@debug"] enabled = true "#, ); let config = load_plugins_config_input(codex_home.path(), &marketplace_root).await; let roots = [AbsolutePathBuf::try_from(marketplace_root.clone()).unwrap()]; 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; for git_mode in [ PluginGitMode::Automatic, PluginGitMode::Manual, PluginGitMode::Automatic, ] { manager.schedule_non_curated_plugin_cache_refresh( &config, &roots, NonCuratedCacheRefreshMode::IfVersionChanged, git_mode, ); } { 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(&config, &roots); { 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"), ); manager.maybe_start_non_curated_plugin_cache_refresh(&config, &roots); let state = manager .non_curated_cache_refresh_state .read() .expect("refresh state lock"); let request = state.requested.as_ref().expect("pending refresh"); assert!(matches!(request.git_mode, PluginGitMode::Automatic)); assert_eq!( request.configured_plugin_sources[0] .local_version .as_deref(), Some("2.0.0") ); } #[test] fn refresh_non_curated_plugin_cache_reinstalls_missing_configured_plugin_with_manifest_version() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3")); write_file( &repo_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "sample-plugin", "source": { "source": "local", "path": "./sample-plugin" } } ] }"#, ); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."sample-plugin@debug"] enabled = true "#, ); assert!( refresh_non_curated_plugin_cache( tmp.path(), &[AbsolutePathBuf::try_from(repo_root).unwrap()], &["sample-plugin@debug".to_string()], ) .expect("cache refresh should reinstall missing configured plugin") ); assert!( tmp.path() .join("plugins/cache/debug/sample-plugin/1.2.3") .is_dir() ); } #[test] fn refresh_non_curated_plugin_cache_refreshes_configured_git_source() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); let remote_repo = tmp.path().join("remote-plugin-repo"); let remote_repo_url = url::Url::from_directory_path(&remote_repo) .unwrap() .to_string(); fs::create_dir_all(repo_root.join(".git")).unwrap(); write_plugin_with_version( &remote_repo, "plugins/sample-plugin", "sample-plugin", Some("1.2.3"), ); init_git_repo(&remote_repo); write_file( &repo_root.join(".agents/plugins/marketplace.json"), &format!( r#"{{ "name": "debug", "plugins": [ {{ "name": "sample-plugin", "source": {{ "source": "git-subdir", "url": "{remote_repo_url}", "path": "plugins/sample-plugin" }} }} ] }}"# ), ); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."sample-plugin@debug"] enabled = true "#, ); assert!( refresh_non_curated_plugin_cache( tmp.path(), &[AbsolutePathBuf::try_from(repo_root).unwrap()], &["sample-plugin@debug".to_string()], ) .expect("cache refresh should materialize configured Git plugin") ); assert!( tmp.path() .join("plugins/cache/debug/sample-plugin/1.2.3") .is_dir() ); } #[test] fn refresh_non_curated_plugin_cache_returns_false_when_configured_plugins_are_current() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3")); write_file( &repo_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "sample-plugin", "source": { "source": "local", "path": "./sample-plugin" } } ] }"#, ); write_plugin_with_version( &tmp.path().join("plugins/cache/debug"), "sample-plugin/1.2.3", "sample-plugin", Some("1.2.3"), ); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."sample-plugin@debug"] enabled = true "#, ); assert!( !refresh_non_curated_plugin_cache( tmp.path(), &[AbsolutePathBuf::try_from(repo_root).unwrap()], &["sample-plugin@debug".to_string()], ) .expect("cache refresh should be a no-op when configured plugins are current") ); } #[test] fn refresh_non_curated_plugin_cache_force_reinstalls_current_local_version() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); write_plugin(&repo_root, "sample-plugin", "sample-plugin"); fs::write(repo_root.join("sample-plugin/skills/SKILL.md"), "new skill").unwrap(); write_file( &repo_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "sample-plugin", "source": { "source": "local", "path": "./sample-plugin" } } ] }"#, ); write_plugin( &tmp.path().join("plugins/cache/debug"), "sample-plugin/local", "sample-plugin", ); fs::write( tmp.path() .join("plugins/cache/debug/sample-plugin/local/skills/SKILL.md"), "old skill", ) .unwrap(); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."sample-plugin@debug"] enabled = true "#, ); assert!( refresh_non_curated_plugin_cache_force_reinstall( tmp.path(), &[AbsolutePathBuf::try_from(repo_root).unwrap()], &["sample-plugin@debug".to_string()], ) .expect("cache refresh should reinstall unchanged local version") ); assert_eq!( fs::read_to_string( tmp.path() .join("plugins/cache/debug/sample-plugin/local/skills/SKILL.md") ) .unwrap(), "new skill" ); } #[test] fn refresh_non_curated_plugin_cache_ignores_invalid_unconfigured_plugin_versions() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3")); write_plugin_with_version(&repo_root, "broken-plugin", "broken-plugin", Some(" ")); write_file( &repo_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "sample-plugin", "source": { "source": "local", "path": "./sample-plugin" } }, { "name": "broken-plugin", "source": { "source": "local", "path": "./broken-plugin" } } ] }"#, ); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."sample-plugin@debug"] enabled = true "#, ); assert!( refresh_non_curated_plugin_cache( tmp.path(), &[AbsolutePathBuf::try_from(repo_root).unwrap()], &["sample-plugin@debug".to_string()], ) .expect("cache refresh should ignore unrelated invalid plugin manifests") ); assert!( tmp.path() .join("plugins/cache/debug/sample-plugin/1.2.3") .is_dir() ); } #[test] fn refresh_non_curated_plugin_cache_continues_after_plugin_error() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); write_plugin_with_version(&repo_root, "z-good", "z-good", Some("1.2.3")); write_file( &repo_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "a-broken", "source": { "source": "local", "path": "./missing" } }, { "name": "z-good", "source": { "source": "local", "path": "./z-good" } } ] }"#, ); let err = refresh_non_curated_plugin_cache( tmp.path(), &[AbsolutePathBuf::try_from(repo_root).unwrap()], &["a-broken@debug".to_string(), "z-good@debug".to_string()], ) .expect_err("broken plugin should be reported after refreshing the remaining plugins"); assert!(err.contains("a-broken@debug")); assert!(tmp.path().join("plugins/cache/debug/z-good/1.2.3").is_dir()); } #[tokio::test] async fn load_plugins_ignores_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(), ) .await; assert_eq!(plugins, Vec::new()); } #[tokio::test] async fn plugin_hooks_for_layer_stack_loads_configured_plugin_hooks() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() .join("plugins/cache") .join("test/sample/local"); write_plugin( codex_home.path().join("plugins/cache/test").as_path(), "sample/local", "sample", ); write_file( &plugin_root.join("hooks/hooks.json"), r#"{ "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", "command": "echo startup" } ] } ] } }"#, ); write_file( &codex_home.path().join(CONFIG_TOML_FILE), &plugin_config_toml(/*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::::new()); } #[tokio::test] async fn plugin_hooks_for_layer_stack_follow_auth_mode_and_provider() { let codex_home = TempDir::new().unwrap(); write_file( &codex_home.path().join(CONFIG_TOML_FILE), r#"model_provider = "amazon-bedrock" [features] plugins = true remote_plugin = false [plugins."linear@openai-curated"] enabled = true [plugins."linear@openai-api-curated"] enabled = true "#, ); for marketplace_name in [ OPENAI_CURATED_MARKETPLACE_NAME, OPENAI_API_CURATED_MARKETPLACE_NAME, ] { write_cached_plugin(codex_home.path(), marketplace_name, "linear"); write_file( &codex_home .path() .join("plugins/cache") .join(marketplace_name) .join("linear/local/hooks/hooks.json"), r#"{ "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", "command": "echo startup" } ] } ] } }"#, ); } let config = load_config(codex_home.path(), codex_home.path()).await; let auth_manager = test_auth_manager(Some(AuthMode::Chatgpt)); let manager = test_plugins_manager_with_auth_manager( codex_home.path().to_path_buf(), Some(Product::Codex), Arc::clone(&auth_manager), ); let chatgpt_hooks = manager .plugin_hooks_for_layer_stack(&config.config_layer_stack, &config) .await; assert_eq!( chatgpt_hooks .hook_sources .iter() .map(|source| source.plugin_id.as_key()) .collect::>(), vec!["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!["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!["linear@openai-api-curated"] ); } #[test] fn remote_installed_plugins_cache_refresh_coalesces_materializations() { let tmp = TempDir::new().unwrap(); let manager = std::sync::Arc::new(test_plugins_manager(tmp.path().to_path_buf())); let materialization_callback_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let unrelated_callback_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); manager .remote_installed_plugins_cache_refresh_state .write() .expect("refresh state lock") .in_flight = true; let materialization = |name: &str| RemotePluginMaterialization { plugin_id: PluginId::new( name.to_string(), REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(), ) .expect("valid plugin id"), scope: crate::remote::RemotePluginScope::Workspace, discoverability: Some(crate::remote::RemotePluginShareDiscoverability::Listed), authenticated_account_id: Some("account-123".to_string()), }; let change = |name: &str| EffectivePluginsChange { materialized_remote_plugins: vec![materialization(name)], }; let callback = |count: std::sync::Arc| { 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 ); } #[tokio::test] async fn background_remote_installed_bundle_sync_stays_deduped() { let codex_home = TempDir::new().unwrap(); write_file( &codex_home.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true remote_plugin = true "#, ); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/backend-api/ps/plugins/installed")) .and(query_param("includeDownloadUrls", "true")) .respond_with( ResponseTemplate::new(200) .set_delay(Duration::from_millis(200)) .set_body_json(serde_json::json!({ "plugins": [], "pagination": {"next_page_token": null}, })), ) .expect(1) .mount(&server) .await; let mut config = load_config(codex_home.path(), codex_home.path()).await; config.chatgpt_base_url = format!("{}/backend-api", server.uri()); let first_manager = std::sync::Arc::new(test_plugins_manager_with_options( codex_home.path().to_path_buf(), Some(Product::Codex), Some(AuthMode::Chatgpt), )); let second_manager = std::sync::Arc::new(test_plugins_manager_with_options( codex_home.path().to_path_buf(), Some(Product::Codex), Some(AuthMode::Chatgpt), )); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); first_manager.maybe_start_remote_installed_plugin_bundle_sync( &config, Some(auth.clone()), /*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; } #[tokio::test] async fn reconcile_remote_installed_plugins_rejects_incomplete_snapshot_without_cleanup() { let codex_home = TempDir::new().unwrap(); write_file( &codex_home.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true remote_plugin = true "#, ); write_cached_plugin(codex_home.path(), REMOTE_GLOBAL_MARKETPLACE_NAME, "linear"); write_cached_plugin( codex_home.path(), REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME, "malformed", ); write_cached_plugin( codex_home.path(), REMOTE_WORKSPACE_MARKETPLACE_NAME, "renamed-malformed", ); let malformed_remote_plugin_id = "plugins~Plugin_malformed"; let malformed_plugin_id = PluginId::new( "malformed".to_string(), REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME.to_string(), ) .expect("valid malformed plugin id"); PluginStore::new(codex_home.path().to_path_buf()) .write_remote_plugin_id(&malformed_plugin_id, malformed_remote_plugin_id) .expect("persist malformed plugin identity"); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/backend-api/ps/plugins/installed")) .and(query_param("includeDownloadUrls", "true")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "plugins": [ { "id": "plugins~Plugin_linear", "name": "linear", "scope": "GLOBAL", "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "status": "ENABLED", "release": { "version": "local", "display_name": "", "description": "Track work", "interface": {}, }, "enabled": true, }, { "id": malformed_remote_plugin_id, "name": "renamed-malformed", "scope": "WORKSPACE", "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "status": "ENABLED", "release": { "version": "local", "display_name": "Malformed", "description": "Missing discoverability", "interface": {}, }, "enabled": true, }, ], "pagination": {"next_page_token": null}, }))) .expect(1) .mount(&server) .await; let mut config = load_config(codex_home.path(), codex_home.path()).await; config.chatgpt_base_url = format!("{}/backend-api", server.uri()); let auth_manager = test_auth_manager(Some(AuthMode::Chatgpt)); let auth = auth_manager.auth_cached().expect("test ChatGPT auth"); let manager = std::sync::Arc::new(test_plugins_manager_with_auth_manager( codex_home.path().to_path_buf(), Some(Product::Codex), auth_manager, )); let mut previous_malformed = remote_installed_plugin_in_marketplace( "malformed", REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME, ); previous_malformed.id = malformed_remote_plugin_id.to_string(); previous_malformed.version = Some("local".to_string()); previous_malformed.enabled = false; manager.write_remote_installed_plugins_cache(vec![previous_malformed.clone()]); let result = manager .reconcile_remote_installed_plugins(&config, Some(&auth)) .await; assert!(matches!( result, Err(RemoteInstalledPluginBundleSyncError::Catalog( RemotePluginCatalogError::UnexpectedResponse(_) )) )); server.verify().await; assert_eq!( manager .remote_installed_plugins_cache .read() .expect("installed plugin cache lock") .plugins .clone(), Some(vec![previous_malformed]) ); let plugin_cache_root = codex_home.path().join("plugins/cache"); let renamed_malformed_cache = plugin_cache_root .join(REMOTE_WORKSPACE_MARKETPLACE_NAME) .join("renamed-malformed"); let previous_malformed_cache = plugin_cache_root .join(REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME) .join("malformed"); let linear_metadata = plugin_cache_root .join(REMOTE_GLOBAL_MARKETPLACE_NAME) .join("linear") .join(".codex-remote-plugin-install.json"); assert!(renamed_malformed_cache.exists()); assert!(previous_malformed_cache.exists()); assert!(!linear_metadata.exists()); } #[test] fn reconciliation_fences_refresh_publication_and_recovers_after_cancellation() { let codex_home = TempDir::new().unwrap(); let manager = test_plugins_manager(codex_home.path().to_path_buf()); let reconcile_generation = manager .begin_remote_installed_plugins_reconcile(/*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) ); } #[test] fn plugin_install_error_preserves_store_io_sub_error_type() { let error = PluginInstallError::Store(PluginStoreError::Io { context: "failed to copy plugin file", source: std::io::Error::other("copy failed"), }); assert_eq!( error.sub_error_type(), Some("failed_to_copy_plugin_file".to_string()) ); }