diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9a1e0b30e..d7bb37928 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2741,6 +2741,8 @@ dependencies = [ "tokio", "toml 0.9.11+spec-1.1.0", "tracing", + "tracing-subscriber", + "tracing-test", "url", "wiremock", "zip 2.4.2", diff --git a/codex-rs/core-plugins/Cargo.toml b/codex-rs/core-plugins/Cargo.toml index 72064054e..1d361559a 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -51,4 +51,6 @@ zip = { workspace = true } libc = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } +tracing-subscriber = { workspace = true } +tracing-test = { workspace = true, features = ["no-env-filter"] } wiremock = { workspace = true } diff --git a/codex-rs/core-plugins/src/discoverable.rs b/codex-rs/core-plugins/src/discoverable.rs index a630877f5..81586bd28 100644 --- a/codex-rs/core-plugins/src/discoverable.rs +++ b/codex-rs/core-plugins/src/discoverable.rs @@ -4,12 +4,8 @@ use codex_app_server_protocol::PluginInstallPolicy; use codex_login::CodexAuth; use codex_plugin::PluginCapabilitySummary; use std::collections::HashSet; -use std::path::Component; -use std::path::Path; use tracing::warn; -use crate::OPENAI_BUNDLED_MARKETPLACE_NAME; -use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::PluginsConfigInput; use crate::PluginsManager; use crate::marketplace::MarketplacePluginInstallPolicy; @@ -48,15 +44,6 @@ const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[ "computer-use@openai-bundled", ]; -const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[ - OPENAI_BUNDLED_MARKETPLACE_NAME, - OPENAI_CURATED_MARKETPLACE_NAME, - REMOTE_GLOBAL_MARKETPLACE_NAME, -]; - -const OPENAI_CURATED_MARKETPLACE_PATH_SUFFIX: &str = - ".tmp/plugins/.agents/plugins/marketplace.json"; - #[derive(Debug, Clone)] pub struct ToolSuggestPluginDiscoveryInput { pub plugins: PluginsConfigInput, @@ -94,15 +81,6 @@ impl PluginsManager { ) .context("failed to list plugin marketplaces for tool suggestions")? .marketplaces; - let mut installed_app_connector_ids = self - .plugins_for_config(&input.plugins) - .await - .capability_summaries() - .iter() - .flat_map(|plugin| plugin.app_connector_ids.iter()) - .map(|connector_id| connector_id.0.clone()) - .collect::>(); - installed_app_connector_ids.extend(input.loaded_plugin_app_connector_ids.iter().cloned()); let remote_installed_marketplaces = if input.plugins.remote_plugin_enabled { self.build_remote_installed_plugin_marketplaces_from_cache(&[ REMOTE_GLOBAL_MARKETPLACE_NAME, @@ -114,12 +92,6 @@ impl PluginsManager { let mut discoverable_plugins = Vec::::new(); for marketplace in marketplaces { let marketplace_name = marketplace.name; - let use_legacy_local_curated_filter = should_use_legacy_local_curated_discovery_filter( - &marketplace_name, - marketplace.path.as_path(), - ); - let is_allowlisted_marketplace = TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST - .contains(&marketplace_name.as_str()); for plugin in marketplace.plugins { let is_configured_plugin = input.configured_plugin_ids.contains(plugin.id.as_str()); @@ -128,18 +100,11 @@ impl PluginsManager { if plugin.installed || plugin.policy.installation == MarketplacePluginInstallPolicy::NotAvailable || input.disabled_plugin_ids.contains(plugin.id.as_str()) - || (!is_allowlisted_marketplace && !is_configured_plugin) + || (!is_configured_plugin && !is_fallback_plugin) { continue; } - // On Windows-backed WSL mounts, keep local curated discovery bounded to the - // legacy fallback/configured set instead of reading every plugin detail for app - // ids. Remote curated has cached app ids and still expands by installed apps. - if use_legacy_local_curated_filter && !is_configured_plugin && !is_fallback_plugin { - continue; - } - let plugin_id = plugin.id.clone(); match self @@ -152,14 +117,6 @@ impl PluginsManager { { Ok(plugin) => { let plugin: PluginCapabilitySummary = plugin.into(); - let matches_installed_app = - plugin.app_connector_ids.iter().any(|connector_id| { - installed_app_connector_ids.contains(connector_id.0.as_str()) - }); - if !is_configured_plugin && !is_fallback_plugin && !matches_installed_app { - continue; - } - discoverable_plugins.push(ToolSuggestDiscoverablePlugin { id: plugin.config_name, remote_plugin_id: None, @@ -181,6 +138,16 @@ impl PluginsManager { } } if let Some(remote_installed_marketplaces) = remote_installed_marketplaces.as_ref() { + let mut installed_app_connector_ids = self + .plugins_for_config(&input.plugins) + .await + .capability_summaries() + .iter() + .flat_map(|plugin| plugin.app_connector_ids.iter()) + .map(|connector_id| connector_id.0.clone()) + .collect::>(); + installed_app_connector_ids + .extend(input.loaded_plugin_app_connector_ids.iter().cloned()); let installed_remote_plugin_ids = remote_installed_marketplaces .iter() .flat_map(|marketplace| marketplace.plugins.iter()) @@ -236,29 +203,6 @@ impl PluginsManager { } } -fn should_use_legacy_local_curated_discovery_filter( - marketplace_name: &str, - marketplace_path: &Path, -) -> bool { - marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME - && is_wsl_windows_drive_path(marketplace_path) - && marketplace_path.ends_with(Path::new(OPENAI_CURATED_MARKETPLACE_PATH_SUFFIX)) -} - -fn is_wsl_windows_drive_path(path: &Path) -> bool { - let mut components = path.components(); - if components.next() != Some(Component::RootDir) { - return false; - } - if components.next().and_then(|part| part.as_os_str().to_str()) != Some("mnt") { - return false; - } - let Some(drive) = components.next().and_then(|part| part.as_os_str().to_str()) else { - return false; - }; - drive.len() == 1 && drive.as_bytes()[0].is_ascii_alphabetic() -} - #[cfg(test)] #[path = "discoverable_tests.rs"] mod tests; diff --git a/codex-rs/core-plugins/src/discoverable_tests.rs b/codex-rs/core-plugins/src/discoverable_tests.rs index 240fd86f1..4fa03d94b 100644 --- a/codex-rs/core-plugins/src/discoverable_tests.rs +++ b/codex-rs/core-plugins/src/discoverable_tests.rs @@ -1,61 +1,733 @@ -use std::path::Path; - -use super::is_wsl_windows_drive_path; -use super::should_use_legacy_local_curated_discovery_filter; +use super::ToolSuggestDiscoverablePlugin; +use super::ToolSuggestPluginDiscoveryInput; use crate::OPENAI_BUNDLED_MARKETPLACE_NAME; -use crate::OPENAI_CURATED_MARKETPLACE_NAME; +use crate::PluginInstallRequest; +use crate::PluginsConfigInput; +use crate::PluginsManager; +use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; +use crate::remote::RemotePluginServiceConfig; +use crate::remote::fetch_and_cache_global_remote_plugin_catalog; +use crate::startup_sync::curated_plugins_repo_path; +use crate::test_support::TEST_CURATED_PLUGIN_SHA; +use crate::test_support::load_plugins_config; +use crate::test_support::write_curated_plugin; +use crate::test_support::write_curated_plugin_sha_with; +use crate::test_support::write_file; +use crate::test_support::write_openai_curated_marketplace; +use codex_config::CONFIG_TOML_FILE; +use codex_login::CodexAuth; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::HashSet; +use std::path::Path; +use tempfile::tempdir; +use tracing::Level; +use tracing_subscriber::fmt::format::FmtSpan; +use tracing_test::internal::MockWriter; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::query_param; -#[test] -fn legacy_local_curated_filter_matches_wsl_windows_backed_curated_checkout() { - let marketplace_path = - Path::new("/mnt/c/Users/user/.codex/.tmp/plugins/.agents/plugins/marketplace.json"); +#[tokio::test] +async fn returns_fallback_plugins_without_installed_apps() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["sample", "slack", "openai-developers"]); - assert!(should_use_legacy_local_curated_discovery_filter( - OPENAI_CURATED_MARKETPLACE_NAME, - marketplace_path, - )); -} + let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let discoverable_plugins = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[], &[], &[]), + /*auth*/ None, + ) + .await; -#[test] -fn legacy_local_curated_filter_does_not_match_native_wsl_curated_checkout() { - let marketplace_path = - Path::new("/home/user/.codex/.tmp/plugins/.agents/plugins/marketplace.json"); - - assert!(!should_use_legacy_local_curated_discovery_filter( - OPENAI_CURATED_MARKETPLACE_NAME, - marketplace_path, - )); -} - -#[test] -fn legacy_local_curated_filter_does_not_match_other_wsl_marketplaces() { - let other_marketplace_path = Path::new( - "/mnt/c/Users/user/.codex/.tmp/marketplaces/other/.agents/plugins/marketplace.json", + assert_eq!( + discoverable_plugins + .into_iter() + .map(|plugin| plugin.id) + .collect::>(), + vec![ + "openai-developers@openai-curated".to_string(), + "slack@openai-curated".to_string(), + ] ); - let local_curated_marketplace_path = - Path::new("/mnt/c/Users/user/.codex/.tmp/plugins/.agents/plugins/marketplace.json"); - - assert!(!should_use_legacy_local_curated_discovery_filter( - OPENAI_CURATED_MARKETPLACE_NAME, - other_marketplace_path, - )); - assert!(!should_use_legacy_local_curated_discovery_filter( - OPENAI_BUNDLED_MARKETPLACE_NAME, - local_curated_marketplace_path, - )); } -#[test] -fn wsl_windows_drive_path_matches_only_mnt_drive_paths() { - assert!(is_wsl_windows_drive_path(Path::new( - "/mnt/c/Users/user/.codex/.tmp/plugins", - ))); - assert!(is_wsl_windows_drive_path(Path::new("/mnt/Z/tmp"))); - assert!(!is_wsl_windows_drive_path(Path::new("/home/user/.codex"))); - assert!(!is_wsl_windows_drive_path(Path::new( - "/mnt/codex/Users/user/.codex", - ))); - assert!(!is_wsl_windows_drive_path(Path::new( - "/media/c/Users/user/.codex", - ))); +#[tokio::test] +async fn returns_microsoft_fallback_plugins() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace( + &curated_root, + &["teams", "sharepoint", "outlook-email", "outlook-calendar"], + ); + install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "teams").await; + + let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let discoverable_plugins = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[], &[], &[]), + /*auth*/ None, + ) + .await; + + assert_eq!( + discoverable_plugins + .into_iter() + .map(|plugin| plugin.id) + .collect::>(), + vec![ + "outlook-calendar@openai-curated".to_string(), + "outlook-email@openai-curated".to_string(), + "sharepoint@openai-curated".to_string(), + ] + ); +} + +#[tokio::test] +async fn omits_openai_curated_when_remote_enabled() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + + let bundled_marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME; + let bundled_marketplace_root = codex_home + .path() + .join(format!(".tmp/marketplaces/{bundled_marketplace_name}")); + write_file( + &bundled_marketplace_root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "{bundled_marketplace_name}", + "plugins": [ + {{"name": "chrome", "source": {{"source": "local", "path": "./plugins/chrome"}}}} + ] +}} +"# + ), + ); + write_curated_plugin(&bundled_marketplace_root, "chrome"); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &format!( + r#"[features] +plugins = true +remote_plugin = true + +[marketplaces.{bundled_marketplace_name}] +source_type = "git" +source = "/tmp/{bundled_marketplace_name}" +"# + ), + ); + + let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let discoverable_plugins = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[], &[], &[]), + /*auth*/ None, + ) + .await; + + assert_eq!( + discoverable_plugins + .into_iter() + .map(|plugin| plugin.id) + .collect::>(), + vec!["chrome@openai-bundled".to_string()] + ); +} + +#[tokio::test] +async fn deduplicates_configured_marketplace_plugin() { + let codex_home = tempdir().expect("tempdir should succeed"); + let plugin_name = "sample"; + let marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME; + let plugin_id = format!("{plugin_name}@{marketplace_name}"); + let marketplace_root = codex_home + .path() + .join(format!(".tmp/marketplaces/{marketplace_name}")); + write_file( + &marketplace_root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "{marketplace_name}", + "plugins": [ + {{"name": "{plugin_name}", "source": {{"source": "local", "path": "./plugins/{plugin_name}"}}}} + ] +}} +"# + ), + ); + write_curated_plugin(&marketplace_root, plugin_name); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &format!( + r#"[features] +plugins = true + +[marketplaces.{marketplace_name}] +source_type = "git" +source = "/tmp/{marketplace_name}" +"# + ), + ); + + let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let discoverable_plugins = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[plugin_id.as_str()], &[], &[]), + /*auth*/ None, + ) + .await; + + assert_eq!(discoverable_plugins.len(), 1); + assert_eq!(discoverable_plugins[0].id, plugin_id); +} + +#[tokio::test] +async fn ignores_missing_marketplace_plugin() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["installed", "slack"]); + let marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME; + let marketplace_root = codex_home + .path() + .join(format!(".tmp/marketplaces/{marketplace_name}")); + write_file( + &marketplace_root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "{marketplace_name}", + "plugins": [ + {{"name": "sample", "source": {{"source": "local", "path": "./plugins/sample"}}}} + ] +}} +"# + ), + ); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &format!( + r#"[features] +plugins = true + +[marketplaces.{marketplace_name}] +source_type = "git" +source = "/tmp/{marketplace_name}" +"# + ), + ); + install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await; + + let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let discoverable_plugins = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[], &[], &[]), + /*auth*/ None, + ) + .await; + + assert_eq!(discoverable_plugins.len(), 1); + assert_eq!(discoverable_plugins[0].id, "slack@openai-curated"); +} + +#[tokio::test] +async fn normalizes_description() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["installed", "slack"]); + write_file( + &curated_root.join("plugins/slack/.codex-plugin/plugin.json"), + r#"{ + "name": "slack", + "description": " Plugin\n with extra spacing " +}"#, + ); + install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await; + + let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let discoverable_plugins = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[], &[], &[]), + /*auth*/ None, + ) + .await; + + assert_eq!( + discoverable_plugins, + vec![ToolSuggestDiscoverablePlugin { + id: "slack@openai-curated".to_string(), + remote_plugin_id: None, + name: "slack".to_string(), + description: Some("Plugin with extra spacing".to_string()), + has_skills: true, + mcp_server_names: vec!["sample-docs".to_string()], + app_connector_ids: vec!["connector_calendar".to_string()], + }] + ); +} + +#[tokio::test] +async fn omits_installed_curated_plugins() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await; + + let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let discoverable_plugins = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[], &[], &[]), + /*auth*/ None, + ) + .await; + + assert_eq!(discoverable_plugins, Vec::new()); +} + +#[tokio::test] +async fn omits_not_available_curated_plugins() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_file( + &curated_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "openai-curated", + "plugins": [ + { + "name": "installed", + "source": { + "source": "local", + "path": "./plugins/installed" + } + }, + { + "name": "slack", + "source": { + "source": "local", + "path": "./plugins/slack" + } + }, + { + "name": "gmail", + "source": { + "source": "local", + "path": "./plugins/gmail" + }, + "policy": { + "installation": "NOT_AVAILABLE" + } + } + ] +} +"#, + ); + write_curated_plugin(&curated_root, "installed"); + write_curated_plugin(&curated_root, "slack"); + write_curated_plugin(&curated_root, "gmail"); + install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await; + + let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let discoverable_plugins = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[], &[], &[]), + /*auth*/ None, + ) + .await; + + assert_eq!( + discoverable_plugins + .into_iter() + .map(|plugin| plugin.id) + .collect::>(), + vec!["slack@openai-curated".to_string()] + ); +} + +#[tokio::test] +async fn does_not_reload_marketplace_per_plugin() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["slack", "gmail", "openai-developers"]); + install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await; + + let too_long_prompt = "x".repeat(129); + for plugin_name in ["gmail", "openai-developers"] { + write_file( + &curated_root.join(format!("plugins/{plugin_name}/.codex-plugin/plugin.json")), + &format!( + r#"{{ + "name": "{plugin_name}", + "description": "Plugin that includes skills, MCP servers, and app connectors", + "interface": {{ + "defaultPrompt": "{too_long_prompt}" + }} +}}"# + ), + ); + } + + let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let buffer: &'static std::sync::Mutex> = + Box::leak(Box::new(std::sync::Mutex::new(Vec::new()))); + let subscriber = tracing_subscriber::fmt() + .with_level(true) + .with_ansi(false) + .with_max_level(Level::WARN) + .with_span_events(FmtSpan::NONE) + .with_writer(MockWriter::new(buffer)) + .finish(); + let _guard = tracing::subscriber::set_default(subscriber); + + let discoverable_plugins = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[], &[], &[]), + /*auth*/ None, + ) + .await; + + assert_eq!( + discoverable_plugins + .iter() + .map(|plugin| plugin.id.as_str()) + .collect::>(), + vec!["gmail@openai-curated", "openai-developers@openai-curated"] + ); + + let logs = String::from_utf8(buffer.lock().expect("buffer lock").clone()) + .expect("utf8 logs") + .replace('\\', "/"); + assert_eq!(logs.matches("ignoring interface.defaultPrompt").count(), 8); + assert_eq!(logs.matches("gmail/.codex-plugin/plugin.json").count(), 4); + assert_eq!( + logs.matches("openai-developers/.codex-plugin/plugin.json") + .count(), + 4 + ); +} + +#[tokio::test] +async fn does_not_expand_local_plugins_by_installed_apps() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["sample", "slack", "hubspot"]); + write_plugin_app(&curated_root, "sample", "sample", "connector_sample"); + install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await; + + let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let discoverable_plugins = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[], &[], &[]), + /*auth*/ None, + ) + .await; + + assert_eq!(discoverable_plugins, Vec::new()); +} + +#[tokio::test] +async fn does_not_read_local_plugins_for_loaded_apps() { + let hubspot_app_id = "asdk_app_697acb8e53d88191bf7a79e62012ae14"; + let granola_app_id = "asdk_app_697761cab6f48191b5ed345919a3ce8b"; + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["hubspot", "granola", "sample"]); + write_plugin_app(&curated_root, "hubspot", "hubspot", hubspot_app_id); + write_plugin_app(&curated_root, "granola", "granola", granola_app_id); + write_file( + &curated_root.join("plugins/sample/.app.json"), + "invalid json", + ); + + let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let buffer: &'static std::sync::Mutex> = + Box::leak(Box::new(std::sync::Mutex::new(Vec::new()))); + let subscriber = tracing_subscriber::fmt() + .with_level(true) + .with_ansi(false) + .with_max_level(Level::WARN) + .with_span_events(FmtSpan::NONE) + .with_writer(MockWriter::new(buffer)) + .finish(); + let _guard = tracing::subscriber::set_default(subscriber); + + let discoverable_plugins = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[], &[], &[hubspot_app_id]), + /*auth*/ None, + ) + .await; + + assert_eq!(discoverable_plugins, Vec::new()); + let logs = String::from_utf8(buffer.lock().expect("buffer lock").clone()) + .expect("utf8 logs") + .replace('\\', "/"); + assert_eq!(logs.matches("plugins/sample/.app.json").count(), 0); +} + +#[tokio::test] +async fn does_not_expand_local_sales_apps() { + let hubspot_app_id = "asdk_app_697acb8e53d88191bf7a79e62012ae14"; + let granola_app_id = "asdk_app_697761cab6f48191b5ed345919a3ce8b"; + let test_app_id = "asdk_app_test_source"; + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["hubspot", "granola", "test-source"]); + write_plugin_app(&curated_root, "hubspot", "hubspot", hubspot_app_id); + write_plugin_app(&curated_root, "granola", "granola", granola_app_id); + write_plugin_app(&curated_root, "test-source", "test_source", test_app_id); + + let sales_marketplace_name = "oai-maintained-plugins"; + let sales_marketplace_root = codex_home + .path() + .join(format!(".tmp/marketplaces/{sales_marketplace_name}")); + write_file( + &sales_marketplace_root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "{sales_marketplace_name}", + "plugins": [ + {{"name": "sales", "source": {{"source": "local", "path": "./plugins/sales"}}}} + ] +}} +"# + ), + ); + write_curated_plugin(&sales_marketplace_root, "sales"); + write_file( + &sales_marketplace_root.join("plugins/sales/.app.json"), + &format!( + r#"{{ + "apps": {{ + "hubspot": {{ + "id": "{hubspot_app_id}" + }}, + "granola": {{ + "id": "{granola_app_id}" + }} + }} +}} +"# + ), + ); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &format!( + r#"[features] +plugins = true + +[marketplaces.{sales_marketplace_name}] +source_type = "git" +source = "/tmp/{sales_marketplace_name}" +"# + ), + ); + install_marketplace_plugin(codex_home.path(), sales_marketplace_root.as_path(), "sales").await; + + let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let discoverable_plugins = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[], &[], &[]), + /*auth*/ None, + ) + .await; + + assert_eq!(discoverable_plugins, Vec::new()); +} + +#[tokio::test] +async fn expands_cached_remote_plugins_by_loaded_apps() { + let codex_home = tempdir().expect("tempdir should succeed"); + 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/list")) + .and(query_param("scope", "GLOBAL")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [ + { + "id": "plugins~Plugin_remote_unlisted", + "name": "remote-unlisted", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "AVAILABLE", + "release": { + "display_name": "Remote Unlisted", + "description": "Remote Unlisted long", + "app_ids": ["remote-unlisted-app"], + "interface": { + "short_description": "Remote Unlisted short", + "long_description": null, + "developer_name": null, + "category": null, + "capabilities": [], + "website_url": null, + "privacy_policy_url": null, + "terms_of_service_url": null, + "brand_color": null, + "default_prompt": null, + "composer_icon_url": null, + "logo_url": null, + "screenshot_urls": [] + }, + "skills": [ + { + "name": "remote-unlisted", + "description": "Use unlisted remote plugin", + "interface": null + } + ] + } + } + ], + "pagination": { + "next_page_token": null + } + }))) + .expect(1) + .mount(&server) + .await; + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let mut plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + plugins.chatgpt_base_url = format!("{}/backend-api", server.uri()); + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + fetch_and_cache_global_remote_plugin_catalog( + codex_home.path(), + &RemotePluginServiceConfig { + chatgpt_base_url: plugins.chatgpt_base_url.clone(), + }, + Some(&auth), + ) + .await + .expect("remote plugin catalog cache should write"); + + for scope in ["GLOBAL", "WORKSPACE"] { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", scope)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [], + "pagination": { + "next_page_token": null + } + }))) + .expect(1) + .mount(&server) + .await; + } + plugins_manager + .build_and_cache_remote_installed_plugin_marketplaces( + &plugins, + Some(&auth), + &[REMOTE_GLOBAL_MARKETPLACE_NAME], + /*on_effective_plugins_changed*/ None, + ) + .await + .expect("remote installed plugin cache should write"); + + let discoverable_plugins = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[], &[], &["remote-unlisted-app"]), + Some(&auth), + ) + .await; + + assert_eq!( + discoverable_plugins, + vec![ToolSuggestDiscoverablePlugin { + id: "remote-unlisted@openai-curated-remote".to_string(), + remote_plugin_id: Some("plugins~Plugin_remote_unlisted".to_string()), + name: "Remote Unlisted".to_string(), + description: Some("Remote Unlisted short".to_string()), + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: vec!["remote-unlisted-app".to_string()], + }] + ); +} + +fn discovery_input( + plugins: PluginsConfigInput, + configured_plugin_ids: &[&str], + disabled_plugin_ids: &[&str], + loaded_plugin_app_connector_ids: &[&str], +) -> ToolSuggestPluginDiscoveryInput { + ToolSuggestPluginDiscoveryInput { + plugins, + configured_plugin_ids: string_set(configured_plugin_ids), + disabled_plugin_ids: string_set(disabled_plugin_ids), + loaded_plugin_app_connector_ids: string_set(loaded_plugin_app_connector_ids), + } +} + +async fn list_discoverable_plugins( + plugins_manager: &PluginsManager, + input: ToolSuggestPluginDiscoveryInput, + auth: Option<&CodexAuth>, +) -> Vec { + plugins_manager + .list_tool_suggest_discoverable_plugins(&input, auth) + .await + .expect("discoverable plugins should load") +} + +fn string_set(values: &[&str]) -> HashSet { + values.iter().map(ToString::to_string).collect() +} + +async fn install_marketplace_plugin(codex_home: &Path, marketplace_root: &Path, plugin_name: &str) { + write_curated_plugin_sha_with(codex_home, TEST_CURATED_PLUGIN_SHA); + PluginsManager::new(codex_home.to_path_buf()) + .install_plugin(PluginInstallRequest { + plugin_name: plugin_name.to_string(), + marketplace_path: AbsolutePathBuf::try_from( + marketplace_root.join(".agents/plugins/marketplace.json"), + ) + .expect("marketplace path"), + }) + .await + .expect("plugin should install"); +} + +fn write_plugin_app(root: &Path, plugin_name: &str, app_name: &str, app_id: &str) { + write_file( + &root.join(format!("plugins/{plugin_name}/.app.json")), + &format!( + r#"{{ + "apps": {{ + "{app_name}": {{ + "id": "{app_id}" + }} + }} +}} +"# + ), + ); } diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs index a6da04e77..ef433d37d 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -1,24 +1,14 @@ use crate::plugins::test_support::load_plugins_config; -use crate::plugins::test_support::write_curated_plugin; -use crate::plugins::test_support::write_curated_plugin_sha; use crate::plugins::test_support::write_file; use crate::plugins::test_support::write_openai_curated_marketplace; -use crate::plugins::test_support::write_plugins_feature_config; -use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME; -use codex_core_plugins::PluginInstallRequest; use codex_core_plugins::PluginsManager; use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use codex_core_plugins::remote::RemotePluginServiceConfig; use codex_core_plugins::remote::fetch_and_cache_global_remote_plugin_catalog; use codex_core_plugins::startup_sync::curated_plugins_repo_path; use codex_tools::DiscoverablePluginInfo; -use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; -use std::path::Path; use tempfile::tempdir; -use tracing::Level; -use tracing_subscriber::fmt::format::FmtSpan; -use tracing_test::internal::MockWriter; async fn list_discoverable_plugins( config: &crate::config::Config, @@ -58,101 +48,6 @@ async fn list_discoverable_plugins_with_manager_and_auth( .await } -#[tokio::test] -async fn list_tool_suggest_discoverable_plugins_returns_fallback_plugins_without_installed_apps() { - let codex_home = tempdir().expect("tempdir should succeed"); - let curated_root = curated_plugins_repo_path(codex_home.path()); - write_openai_curated_marketplace(&curated_root, &["sample", "slack", "openai-developers"]); - write_plugins_feature_config(codex_home.path()); - - let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); - - assert_eq!( - discoverable_plugins - .into_iter() - .map(|plugin| plugin.id) - .collect::>(), - vec![ - "openai-developers@openai-curated".to_string(), - "slack@openai-curated".to_string(), - ] - ); -} - -#[tokio::test] -async fn list_tool_suggest_discoverable_plugins_filters_non_fallback_by_installed_apps() { - let codex_home = tempdir().expect("tempdir should succeed"); - let curated_root = curated_plugins_repo_path(codex_home.path()); - write_openai_curated_marketplace(&curated_root, &["sample", "slack", "hubspot"]); - write_plugin_app(&curated_root, "sample", "sample", "connector_sample"); - write_plugins_feature_config(codex_home.path()); - install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await; - - let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); - - assert_eq!( - discoverable_plugins - .into_iter() - .map(|plugin| plugin.id) - .collect::>(), - vec!["hubspot@openai-curated".to_string()] - ); -} - -#[tokio::test] -async fn list_tool_suggest_discoverable_plugins_filters_by_loaded_plugin_apps() { - let hubspot_app_id = "asdk_app_697acb8e53d88191bf7a79e62012ae14"; - let granola_app_id = "asdk_app_697761cab6f48191b5ed345919a3ce8b"; - let codex_home = tempdir().expect("tempdir should succeed"); - let curated_root = curated_plugins_repo_path(codex_home.path()); - write_openai_curated_marketplace(&curated_root, &["hubspot", "granola"]); - write_plugin_app(&curated_root, "hubspot", "hubspot", hubspot_app_id); - write_plugin_app(&curated_root, "granola", "granola", granola_app_id); - write_plugins_feature_config(codex_home.path()); - - let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_discoverable_plugins(&config, &[hubspot_app_id.to_string()]) - .await - .unwrap(); - - assert_eq!( - discoverable_plugins - .into_iter() - .map(|plugin| plugin.id) - .collect::>(), - vec!["hubspot@openai-curated".to_string()] - ); -} - -#[tokio::test] -async fn list_tool_suggest_discoverable_plugins_filters_microsoft_by_installed_apps() { - let codex_home = tempdir().expect("tempdir should succeed"); - let curated_root = curated_plugins_repo_path(codex_home.path()); - write_openai_curated_marketplace( - &curated_root, - &["teams", "sharepoint", "outlook-email", "outlook-calendar"], - ); - write_plugins_feature_config(codex_home.path()); - install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "teams").await; - - let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); - - assert_eq!( - discoverable_plugins - .into_iter() - .map(|plugin| plugin.id) - .collect::>(), - vec![ - "outlook-calendar@openai-curated".to_string(), - "outlook-email@openai-curated".to_string(), - "sharepoint@openai-curated".to_string(), - ] - ); -} - #[tokio::test] async fn list_tool_suggest_discoverable_plugins_includes_cached_remote_global_plugins() { use codex_login::CodexAuth; @@ -431,216 +326,6 @@ disabled_tools = [ ); } -#[tokio::test] -async fn list_tool_suggest_discoverable_plugins_filters_sales_apps_by_marketplace() { - let hubspot_app_id = "asdk_app_697acb8e53d88191bf7a79e62012ae14"; - let granola_app_id = "asdk_app_697761cab6f48191b5ed345919a3ce8b"; - let test_app_id = "asdk_app_test_source"; - let codex_home = tempdir().expect("tempdir should succeed"); - let curated_root = curated_plugins_repo_path(codex_home.path()); - write_openai_curated_marketplace(&curated_root, &["hubspot", "granola", "test-source"]); - write_plugin_app(&curated_root, "hubspot", "hubspot", hubspot_app_id); - write_plugin_app(&curated_root, "granola", "granola", granola_app_id); - write_plugin_app(&curated_root, "test-source", "test_source", test_app_id); - - let sales_marketplace_name = "oai-maintained-plugins"; - let sales_marketplace_root = codex_home - .path() - .join(format!(".tmp/marketplaces/{sales_marketplace_name}")); - write_file( - &sales_marketplace_root.join(".agents/plugins/marketplace.json"), - &format!( - r#"{{ - "name": "{sales_marketplace_name}", - "plugins": [ - {{"name": "sales", "source": {{"source": "local", "path": "./plugins/sales"}}}} - ] -}} -"# - ), - ); - write_curated_plugin(&sales_marketplace_root, "sales"); - write_file( - &sales_marketplace_root.join("plugins/sales/.app.json"), - &format!( - r#"{{ - "apps": {{ - "hubspot": {{ - "id": "{hubspot_app_id}" - }}, - "granola": {{ - "id": "{granola_app_id}" - }} - }} -}} -"# - ), - ); - write_file( - &codex_home.path().join(crate::config::CONFIG_TOML_FILE), - &format!( - r#"[features] -plugins = true - -[marketplaces.{sales_marketplace_name}] -source_type = "git" -source = "/tmp/{sales_marketplace_name}" -"# - ), - ); - install_marketplace_plugin(codex_home.path(), sales_marketplace_root.as_path(), "sales").await; - - let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); - - assert_eq!( - discoverable_plugins - .into_iter() - .map(|plugin| plugin.id) - .collect::>(), - vec![ - "granola@openai-curated".to_string(), - "hubspot@openai-curated".to_string(), - ] - ); -} - -#[tokio::test] -async fn list_tool_suggest_discoverable_plugins_omits_openai_curated_when_remote_enabled() { - let codex_home = tempdir().expect("tempdir should succeed"); - let curated_root = curated_plugins_repo_path(codex_home.path()); - write_openai_curated_marketplace(&curated_root, &["slack"]); - - let bundled_marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME; - let bundled_marketplace_root = codex_home - .path() - .join(format!(".tmp/marketplaces/{bundled_marketplace_name}")); - write_file( - &bundled_marketplace_root.join(".agents/plugins/marketplace.json"), - &format!( - r#"{{ - "name": "{bundled_marketplace_name}", - "plugins": [ - {{"name": "chrome", "source": {{"source": "local", "path": "./plugins/chrome"}}}} - ] -}} -"# - ), - ); - write_curated_plugin(&bundled_marketplace_root, "chrome"); - write_file( - &codex_home.path().join(crate::config::CONFIG_TOML_FILE), - &format!( - r#"[features] -plugins = true -remote_plugin = true - -[marketplaces.{bundled_marketplace_name}] -source_type = "git" -source = "/tmp/{bundled_marketplace_name}" -"# - ), - ); - - let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); - - assert_eq!( - discoverable_plugins - .into_iter() - .map(|plugin| plugin.id) - .collect::>(), - vec!["chrome@openai-bundled".to_string()] - ); -} - -#[tokio::test] -async fn list_tool_suggest_discoverable_plugins_deduplicates_configured_marketplace_plugin() { - let codex_home = tempdir().expect("tempdir should succeed"); - let plugin_name = "sample"; - let marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME; - let plugin_id = format!("{plugin_name}@{marketplace_name}"); - let marketplace_root = codex_home - .path() - .join(format!(".tmp/marketplaces/{marketplace_name}")); - write_file( - &marketplace_root.join(".agents/plugins/marketplace.json"), - &format!( - r#"{{ - "name": "{marketplace_name}", - "plugins": [ - {{"name": "{plugin_name}", "source": {{"source": "local", "path": "./plugins/{plugin_name}"}}}} - ] -}} -"# - ), - ); - write_curated_plugin(&marketplace_root, plugin_name); - write_file( - &codex_home.path().join(crate::config::CONFIG_TOML_FILE), - &format!( - r#"[features] -plugins = true - -[marketplaces.{marketplace_name}] -source_type = "git" -source = "/tmp/{marketplace_name}" - -[tool_suggest] -discoverables = [{{ type = "plugin", id = "{plugin_id}" }}] -"# - ), - ); - - let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); - - assert_eq!(discoverable_plugins.len(), 1); - assert_eq!(discoverable_plugins[0].id, plugin_id.as_str()); -} - -#[tokio::test] -async fn list_tool_suggest_discoverable_plugins_ignores_missing_marketplace_plugin() { - let codex_home = tempdir().expect("tempdir should succeed"); - let curated_root = curated_plugins_repo_path(codex_home.path()); - write_openai_curated_marketplace(&curated_root, &["installed", "slack"]); - let marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME; - let marketplace_root = codex_home - .path() - .join(format!(".tmp/marketplaces/{marketplace_name}")); - write_file( - &marketplace_root.join(".agents/plugins/marketplace.json"), - &format!( - r#"{{ - "name": "{marketplace_name}", - "plugins": [ - {{"name": "sample", "source": {{"source": "local", "path": "./plugins/sample"}}}} - ] -}} -"# - ), - ); - write_file( - &codex_home.path().join(crate::config::CONFIG_TOML_FILE), - &format!( - r#"[features] -plugins = true - -[marketplaces.{marketplace_name}] -source_type = "git" -source = "/tmp/{marketplace_name}" -"# - ), - ); - install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await; - - let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); - - assert_eq!(discoverable_plugins.len(), 1); - assert_eq!(discoverable_plugins[0].id, "slack@openai-curated"); -} - #[tokio::test] async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_feature_disabled() { let codex_home = tempdir().expect("tempdir should succeed"); @@ -659,65 +344,6 @@ plugins = false assert_eq!(discoverable_plugins, Vec::::new()); } -#[tokio::test] -async fn list_tool_suggest_discoverable_plugins_normalizes_description() { - let codex_home = tempdir().expect("tempdir should succeed"); - let curated_root = curated_plugins_repo_path(codex_home.path()); - write_openai_curated_marketplace(&curated_root, &["installed", "slack"]); - write_plugins_feature_config(codex_home.path()); - write_file( - &curated_root.join("plugins/slack/.codex-plugin/plugin.json"), - r#"{ - "name": "slack", - "description": " Plugin\n with extra spacing " -}"#, - ); - install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await; - - let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); - - assert_eq!( - discoverable_plugins, - vec![DiscoverablePluginInfo { - id: "slack@openai-curated".to_string(), - remote_plugin_id: None, - name: "slack".to_string(), - description: Some("Plugin with extra spacing".to_string()), - has_skills: true, - mcp_server_names: vec!["sample-docs".to_string()], - app_connector_ids: vec!["connector_calendar".to_string()], - }] - ); -} - -#[tokio::test] -async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins() { - let codex_home = tempdir().expect("tempdir should succeed"); - let curated_root = curated_plugins_repo_path(codex_home.path()); - write_openai_curated_marketplace(&curated_root, &["slack"]); - write_curated_plugin_sha(codex_home.path()); - write_plugins_feature_config(codex_home.path()); - - PluginsManager::new(codex_home.path().to_path_buf()) - .install_plugin(PluginInstallRequest { - plugin_name: "slack".to_string(), - marketplace_path: AbsolutePathBuf::try_from( - curated_root.join(".agents/plugins/marketplace.json"), - ) - .expect("marketplace path"), - }) - .await - .expect("plugin should install"); - - let refreshed_config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_discoverable_plugins(&refreshed_config, &[]) - .await - .unwrap(); - - assert_eq!(discoverable_plugins, Vec::::new()); -} - #[tokio::test] async fn list_tool_suggest_discoverable_plugins_omits_disabled_tool_suggestions() { let codex_home = tempdir().expect("tempdir should succeed"); @@ -741,61 +367,6 @@ disabled_tools = [ assert_eq!(discoverable_plugins, Vec::::new()); } -#[tokio::test] -async fn list_tool_suggest_discoverable_plugins_omits_not_available_curated_plugins() { - let codex_home = tempdir().expect("tempdir should succeed"); - let curated_root = curated_plugins_repo_path(codex_home.path()); - write_file( - &curated_root.join(".agents/plugins/marketplace.json"), - r#"{ - "name": "openai-curated", - "plugins": [ - { - "name": "installed", - "source": { - "source": "local", - "path": "./plugins/installed" - } - }, - { - "name": "slack", - "source": { - "source": "local", - "path": "./plugins/slack" - } - }, - { - "name": "gmail", - "source": { - "source": "local", - "path": "./plugins/gmail" - }, - "policy": { - "installation": "NOT_AVAILABLE" - } - } - ] -} -"#, - ); - write_curated_plugin(&curated_root, "installed"); - write_curated_plugin(&curated_root, "slack"); - write_curated_plugin(&curated_root, "gmail"); - write_plugins_feature_config(codex_home.path()); - install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await; - - let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); - - assert_eq!( - discoverable_plugins - .into_iter() - .map(|plugin| plugin.id) - .collect::>(), - vec!["slack@openai-curated".to_string()] - ); -} - #[tokio::test] async fn list_tool_suggest_discoverable_plugins_includes_configured_plugin_ids() { let codex_home = tempdir().expect("tempdir should succeed"); @@ -829,98 +400,3 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }] }] ); } - -#[tokio::test] -async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_plugin() { - let codex_home = tempdir().expect("tempdir should succeed"); - let curated_root = curated_plugins_repo_path(codex_home.path()); - write_openai_curated_marketplace(&curated_root, &["slack", "gmail", "openai-developers"]); - write_plugins_feature_config(codex_home.path()); - install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await; - - let too_long_prompt = "x".repeat(129); - for plugin_name in ["gmail", "openai-developers"] { - write_file( - &curated_root.join(format!("plugins/{plugin_name}/.codex-plugin/plugin.json")), - &format!( - r#"{{ - "name": "{plugin_name}", - "description": "Plugin that includes skills, MCP servers, and app connectors", - "interface": {{ - "defaultPrompt": "{too_long_prompt}" - }} -}}"# - ), - ); - } - - let config = load_plugins_config(codex_home.path()).await; - let buffer: &'static std::sync::Mutex> = - Box::leak(Box::new(std::sync::Mutex::new(Vec::new()))); - let subscriber = tracing_subscriber::fmt() - .with_level(true) - .with_ansi(false) - .with_max_level(Level::WARN) - .with_span_events(FmtSpan::NONE) - .with_writer(MockWriter::new(buffer)) - .finish(); - let _guard = tracing::subscriber::set_default(subscriber); - - let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); - - assert_eq!( - discoverable_plugins - .iter() - .map(|plugin| plugin.id.as_str()) - .collect::>(), - vec!["gmail@openai-curated", "openai-developers@openai-curated"] - ); - - let logs = String::from_utf8(buffer.lock().expect("buffer lock").clone()) - .expect("utf8 logs") - .replace('\\', "/"); - assert_eq!(logs.matches("ignoring interface.defaultPrompt").count(), 8); - let normalized_logs = logs.replace('\\', "/"); - assert_eq!( - normalized_logs - .matches("gmail/.codex-plugin/plugin.json") - .count(), - 4 - ); - assert_eq!( - normalized_logs - .matches("openai-developers/.codex-plugin/plugin.json") - .count(), - 4 - ); -} - -async fn install_marketplace_plugin(codex_home: &Path, marketplace_root: &Path, plugin_name: &str) { - write_curated_plugin_sha(codex_home); - PluginsManager::new(codex_home.to_path_buf()) - .install_plugin(PluginInstallRequest { - plugin_name: plugin_name.to_string(), - marketplace_path: AbsolutePathBuf::try_from( - marketplace_root.join(".agents/plugins/marketplace.json"), - ) - .expect("marketplace path"), - }) - .await - .expect("plugin should install"); -} - -fn write_plugin_app(root: &Path, plugin_name: &str, app_name: &str, app_id: &str) { - write_file( - &root.join(format!("plugins/{plugin_name}/.app.json")), - &format!( - r#"{{ - "apps": {{ - "{app_name}": {{ - "id": "{app_id}" - }} - }} -}} -"# - ), - ); -}