diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index ae622db8a8..3b17be8696 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -281,6 +281,62 @@ enabled = true Ok(()) } +#[tokio::test] +async fn plugin_installed_omits_global_remote_installs_when_remote_plugin_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +remote_plugin = false +plugin_sharing = false +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + mount_remote_installed_plugins( + &server, + "GLOBAL", + &remote_installed_plugin_body("", "1.2.3", /*enabled*/ true), + ) + .await; + mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) + .await; + + let mut app_server = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??; + + let request_id = app_server + .send_plugin_installed_request(PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstalledResponse = to_response(response)?; + + assert_eq!(response.marketplaces, Vec::::new()); + assert_eq!(response.marketplace_load_errors, Vec::new()); + Ok(()) +} + #[tokio::test] async fn plugin_installed_ignores_local_cache_without_catalog() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 08f668d130..1c62e35b81 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -491,7 +491,7 @@ impl PluginsManager { let outcome = load_plugins_from_layer_stack( &config.config_layer_stack, - self.remote_installed_plugin_configs(), + self.remote_installed_plugin_configs_for_config(config), &self.store, self.restriction_product, config.remote_plugin_enabled, @@ -537,7 +537,7 @@ impl PluginsManager { } load_plugins_from_layer_stack( config_layer_stack, - self.remote_installed_plugin_configs(), + self.remote_installed_plugin_configs_for_config(config), &self.store, self.restriction_product, config.remote_plugin_enabled, @@ -556,7 +556,7 @@ impl PluginsManager { } load_plugin_hooks_from_layer_stack( config_layer_stack, - self.remote_installed_plugin_configs(), + self.remote_installed_plugin_configs_for_config(config), &self.store, config.remote_plugin_enabled, ) @@ -588,7 +588,14 @@ impl PluginsManager { } } - fn remote_installed_plugin_configs(&self) -> HashMap { + fn remote_installed_plugin_configs_for_config( + &self, + config: &PluginsConfigInput, + ) -> HashMap { + if !config.remote_plugin_enabled { + return HashMap::new(); + } + let cache = match self.remote_installed_plugins_cache.read() { Ok(cache) => cache, Err(err) => err.into_inner(), @@ -724,6 +731,9 @@ impl PluginsManager { if !config.plugins_enabled { return; } + if !config.remote_plugin_enabled { + return; + } self.schedule_remote_installed_plugins_cache_refresh( RemoteInstalledPluginsCacheRefreshRequest { @@ -744,6 +754,9 @@ impl PluginsManager { if !config.plugins_enabled { return; } + if !config.remote_plugin_enabled { + return; + } let manager = Arc::clone(self); let config_for_refresh = config.clone(); diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index d6c13b1fe0..8ecd9ae7d7 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -162,6 +162,32 @@ fn write_cached_plugin(codex_home: &Path, marketplace_name: &str, plugin_name: & ); } +fn write_cached_plugin_mcp( + codex_home: &Path, + marketplace_name: &str, + plugin_name: &str, + server_name: &str, +) { + let plugin_root = codex_home + .join("plugins/cache") + .join(marketplace_name) + .join(plugin_name) + .join("local"); + write_file( + &plugin_root.join(".mcp.json"), + &format!( + r#"{{ + "mcpServers": {{ + "{server_name}": {{ + "type": "http", + "url": "https://{server_name}.example/mcp" + }} + }} +}}"# + ), + ); +} + #[tokio::test] async fn load_plugins_loads_default_skills_and_mcp_servers() { let codex_home = TempDir::new().unwrap(); @@ -369,7 +395,7 @@ remote_plugin = true } #[tokio::test] -async fn remote_installed_cache_prefers_local_curated_conflicts_when_remote_plugin_disabled() { +async fn remote_installed_cache_ignores_remote_plugins_when_remote_plugin_disabled() { let codex_home = TempDir::new().unwrap(); write_file( &codex_home.path().join(CONFIG_TOML_FILE), @@ -406,11 +432,77 @@ enabled = true vec![ "calendar@openai-curated".to_string(), "linear@openai-curated".to_string(), - "remote-only@openai-curated-remote".to_string(), ] ); } +#[tokio::test] +async fn remote_installed_cache_does_not_expose_cached_mcp_when_remote_plugin_absent() { + let codex_home = TempDir::new().unwrap(); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); + write_cached_plugin(codex_home.path(), "openai-curated-remote", "data-analytics"); + write_cached_plugin_mcp( + codex_home.path(), + "openai-curated-remote", + "data-analytics", + "datascienceWidgets", + ); + + let config = load_config(codex_home.path(), codex_home.path()).await; + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("data-analytics")]); + + let outcome = manager.plugins_for_config(&config).await; + assert_eq!(outcome, PluginLoadOutcome::default()); + assert!(outcome.effective_mcp_servers().is_empty()); +} + +#[tokio::test] +async fn remote_installed_cache_exposes_cached_mcp_when_remote_plugin_enabled() { + let codex_home = TempDir::new().unwrap(); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = true +"#, + ); + write_cached_plugin(codex_home.path(), "openai-curated-remote", "data-analytics"); + write_cached_plugin_mcp( + codex_home.path(), + "openai-curated-remote", + "data-analytics", + "datascienceWidgets", + ); + + let config = load_config(codex_home.path(), codex_home.path()).await; + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("data-analytics")]); + + let outcome = manager.plugins_for_config(&config).await; + assert_eq!( + outcome + .plugins() + .iter() + .map(|plugin| plugin.config_name.clone()) + .collect::>(), + vec!["data-analytics@openai-curated-remote".to_string()] + ); + assert_eq!( + outcome + .effective_mcp_servers() + .keys() + .cloned() + .collect::>(), + vec!["datascienceWidgets".to_string()] + ); +} + #[tokio::test] async fn remote_installed_cache_prefers_remote_curated_conflicts_when_remote_plugin_enabled() { let codex_home = TempDir::new().unwrap(); diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 588eb7ac1b..08ee55130f 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -62,9 +62,11 @@ use codex_config::types::TuiPetAnchor; use codex_config::types::WindowsSandboxModeToml; use codex_config::types::WindowsToml; use codex_core_plugins::PluginsManager; +use codex_core_plugins::remote::RemotePluginScope; use codex_exec_server::LOCAL_FS; use codex_features::Feature; use codex_features::FeaturesToml; +use codex_login::CodexAuth; use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::WireApi; @@ -4614,6 +4616,141 @@ enabled = true Ok(()) } +#[tokio::test] +async fn to_mcp_config_gates_cached_remote_plugin_mcp_by_remote_plugin_feature() +-> anyhow::Result<()> { + let without_feature = cached_remote_plugin_mcp_server_names(/*remote_plugin*/ None).await?; + assert_eq!(without_feature, Vec::::new()); + + let disabled = cached_remote_plugin_mcp_server_names(Some(false)).await?; + assert_eq!(disabled, Vec::::new()); + + let enabled = cached_remote_plugin_mcp_server_names(Some(true)).await?; + assert_eq!(enabled, vec!["datascienceWidgets".to_string()]); + + Ok(()) +} + +async fn cached_remote_plugin_mcp_server_names( + remote_plugin: Option, +) -> anyhow::Result> { + use serde_json::json; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + use wiremock::matchers::query_param; + + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + let remote_plugin_feature = remote_plugin + .map(|enabled| format!("remote_plugin = {enabled}\n")) + .unwrap_or_default(); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + format!( + r#"chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +{remote_plugin_feature} +"#, + server.uri() + ), + )?; + write_cached_remote_plugin_with_mcp(codex_home.path())?; + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "GLOBAL")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [ + { + "id": "plugins~Plugin_data_analytics", + "name": "data-analytics", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "AVAILABLE", + "release": { + "version": "local", + "display_name": "Data Analytics", + "description": "Analyze data", + "bundle_download_url": "", + "app_ids": [], + "interface": {}, + "skills": [], + "mcp_servers": [{ "key": "datascienceWidgets" }] + }, + "enabled": true, + "disabled_skill_names": [] + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "WORKSPACE")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } + }))) + .mount(&server) + .await; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await?; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + plugins_manager + .build_and_cache_remote_installed_plugin_marketplaces( + &config.plugins_config_input(), + Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), + &[RemotePluginScope::Global], + /*on_effective_plugins_changed*/ None, + ) + .await?; + + let mcp_config = config.to_mcp_config(&plugins_manager).await; + let mut server_names = mcp_config + .configured_mcp_servers + .keys() + .cloned() + .collect::>(); + server_names.sort(); + Ok(server_names) +} + +fn write_cached_remote_plugin_with_mcp(codex_home: &Path) -> std::io::Result<()> { + let plugin_root = codex_home.join("plugins/cache/openai-curated-remote/data-analytics/local"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"data-analytics","version":"local"}"#, + )?; + std::fs::write( + plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "datascienceWidgets": { + "type": "http", + "url": "https://datascience.example/mcp" + } + } +}"#, + ) +} + #[tokio::test] async fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result<()> { let temp_dir = TempDir::new()?;