use super::ToolSuggestDiscoverablePlugin;use super::ToolSuggestPluginDiscoveryInput;use crate::OPENAI_BUNDLED_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_api_curated_marketplace;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;#[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"]); 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<_>>(), vec![ "openai-developers@openai-curated".to_string(), "slack@openai-curated".to_string(), ] );}#[tokio::test]async fn returns_api_curated_fallback_plugins_for_direct_provider_auth() { let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = curated_plugins_repo_path(codex_home.path()); write_openai_api_curated_marketplace(&curated_root, &["sample", "slack", "openai-developers"]); let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); let auth = CodexAuth::from_api_key("test-api-key"); let discoverable_plugins = list_discoverable_plugins( &plugins_manager, discovery_input(plugins, &[], &[], &[]), Some(&auth), ) .await; assert_eq!( discoverable_plugins .into_iter() .map(|plugin| plugin.id) .collect::<Vec<_>>(), vec![ "openai-developers@openai-api-curated".to_string(), "slack@openai-api-curated".to_string(), ] );}#[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<_>>(), vec![ "outlook-calendar@openai-curated".to_string(), "outlook-email@openai-curated".to_string(), "sharepoint@openai-curated".to_string(), ] );}#[tokio::test]async fn includes_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 = trueremote_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<_>>(), vec![ "chrome@openai-bundled".to_string(), "slack@openai-curated".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<_>>(), 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<Vec<u8>> = 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<_>>(), 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<Vec<u8>> = 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 = trueremote_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", "USER", "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<ToolSuggestDiscoverablePlugin> { plugins_manager .list_tool_suggest_discoverable_plugins(&input, auth) .await .expect("discoverable plugins should load")}fn string_set(values: &[&str]) -> HashSet<String> { 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}" }} }}}}"# ), );}