From 044c1420a180207b1bc04fac82963233921ca2dd Mon Sep 17 00:00:00 2001 From: xl-openai Date: Fri, 12 Jun 2026 17:51:09 -0700 Subject: [PATCH] [codex] Limit app-based plugin suggestions to remote catalogs (#27988) ## Summary - Keep local plugin suggestions bounded to fallback and explicitly configured plugins. - Preserve app-overlap recommendations for remote plugins using cached catalog metadata. - Remove the WSL-specific local discovery exception and move manager-owned discovery tests into `codex-core-plugins`. ## Why Local curated marketplaces were allowlisted before plugin detail loading, so every uninstalled candidate could be deep-read before its app IDs were checked. That caused per-turn reads of candidate plugin manifests, skills, app configs, hooks, and MCP configs, which is especially expensive on slow disks. Remote discovery does not need those local candidate reads because app IDs are already available in the cached remote catalog. Installed local plugins are still loaded when needed to determine the user's installed app IDs. ## Validation - `just fmt` - `just test -p codex-core-plugins discoverable::tests` (13 passed) - `just test -p codex-core plugins::discoverable::tests` (4 passed) - `just bazel-lock-update` - `just bazel-lock-check` - `git diff --check` --- codex-rs/Cargo.lock | 2 + codex-rs/core-plugins/Cargo.toml | 2 + codex-rs/core-plugins/src/discoverable.rs | 78 +- .../core-plugins/src/discoverable_tests.rs | 778 ++++++++++++++++-- .../core/src/plugins/discoverable_tests.rs | 524 ------------ 5 files changed, 740 insertions(+), 644 deletions(-) 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}" - }} - }} -}} -"# - ), - ); -}