From 44ac67a88cf3dc62da4f4d99b77180cd4d80c1be Mon Sep 17 00:00:00 2001 From: xli-oai Date: Wed, 29 Apr 2026 19:27:12 -0700 Subject: [PATCH] Move plugin manager out of core --- codex-rs/Cargo.lock | 5 + .../app-server/src/codex_message_processor.rs | 13 +- .../src/codex_message_processor/plugins.rs | 60 +- .../src/config/external_agent_config.rs | 21 +- codex-rs/app-server/src/message_processor.rs | 7 +- .../app-server/tests/suite/v2/plugin_list.rs | 99 --- codex-rs/chatgpt/Cargo.toml | 1 + codex-rs/chatgpt/src/connectors.rs | 8 +- codex-rs/cli/src/marketplace_cmd.rs | 2 +- codex-rs/core-plugins/Cargo.toml | 6 +- .../src}/discoverable.rs | 40 +- .../src}/discoverable_tests.rs | 269 +++++-- codex-rs/core-plugins/src/lib.rs | 7 + .../plugins => core-plugins/src}/manager.rs | 678 +++++------------- codex-rs/core/src/agent/role_tests.rs | 10 +- codex-rs/core/src/config/edit.rs | 19 + codex-rs/core/src/config/edit_tests.rs | 33 + codex-rs/core/src/config/mod.rs | 9 +- codex-rs/core/src/connectors.rs | 22 +- codex-rs/core/src/lib.rs | 2 + codex-rs/core/src/mcp_tool_call.rs | 14 +- codex-rs/core/src/mcp_tool_call_tests.rs | 8 +- codex-rs/core/src/plugins/mod.rs | 37 +- codex-rs/core/src/plugins/startup_sync.rs | 100 --- .../core/src/plugins/startup_sync_tests.rs | 90 --- ...ager_tests.rs => plugins_manager_tests.rs} | 618 ++++------------ codex-rs/core/src/session/handlers.rs | 8 +- codex-rs/core/src/session/mod.rs | 25 +- codex-rs/core/src/session/tests.rs | 14 +- codex-rs/core/src/session/turn.rs | 14 +- codex-rs/core/src/session/turn_context.rs | 7 +- codex-rs/core/src/skills_watcher.rs | 11 +- .../core/src/tools/handlers/tool_suggest.rs | 7 +- .../src/tools/handlers/tool_suggest_tests.rs | 6 + codex-rs/tui/src/app/background_requests.rs | 7 +- 35 files changed, 864 insertions(+), 1413 deletions(-) rename codex-rs/{core/src/plugins => core-plugins/src}/discoverable.rs (75%) rename codex-rs/{core/src/plugins => core-plugins/src}/discoverable_tests.rs (67%) rename codex-rs/{core/src/plugins => core-plugins/src}/manager.rs (71%) delete mode 100644 codex-rs/core/src/plugins/startup_sync.rs delete mode 100644 codex-rs/core/src/plugins/startup_sync_tests.rs rename codex-rs/core/src/{plugins/manager_tests.rs => plugins_manager_tests.rs} (84%) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d8744c4373..e1a97216bd 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2100,6 +2100,7 @@ dependencies = [ "codex-app-server-protocol", "codex-connectors", "codex-core", + "codex-features", "codex-git-utils", "codex-login", "codex-model-provider", @@ -2503,6 +2504,7 @@ version = "0.0.0" dependencies = [ "anyhow", "chrono", + "codex-analytics", "codex-app-server-protocol", "codex-config", "codex-core-skills", @@ -2513,6 +2515,7 @@ dependencies = [ "codex-otel", "codex-plugin", "codex-protocol", + "codex-tools", "codex-utils-absolute-path", "codex-utils-plugins", "dirs", @@ -2528,6 +2531,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/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 16de9ba7a5..d78d30afab 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6304,7 +6304,12 @@ impl CodexMessageProcessor { .map_or(&[][..], std::vec::Vec::as_slice); let effective_skill_roots = if workspace_codex_plugins_enabled { plugins_manager - .effective_skill_roots_for_layer_stack(&config_layer_stack, &config) + .effective_skill_roots_for_layer_stack( + &config_layer_stack, + config.features.enabled(Feature::Plugins), + config.features.enabled(Feature::RemotePlugin), + config.features.enabled(Feature::PluginHooks), + ) .await } else { Vec::new() @@ -6389,7 +6394,8 @@ impl CodexMessageProcessor { plugins_manager .plugins_for_layer_stack( &config.config_layer_stack, - &config, + plugins_enabled, + config.features.enabled(Feature::RemotePlugin), /*plugin_hooks_feature_enabled*/ true, ) .await @@ -6452,10 +6458,11 @@ impl CodexMessageProcessor { let config = self.load_latest_config(/*fallback_cwd*/ None).await?; let plugins_manager = self.thread_manager.plugins_manager(); let MarketplaceUpgradeParams { marketplace_name } = params; + let config_layer_stack = config.config_layer_stack.clone(); let outcome = tokio::task::spawn_blocking(move || { plugins_manager - .upgrade_configured_marketplaces_for_config(&config, marketplace_name.as_deref()) + .upgrade_configured_marketplaces(&config_layer_stack, marketplace_name.as_deref()) }) .await .map_err(|err| internal_error(format!("failed to upgrade marketplaces: {err}")))? diff --git a/codex-rs/app-server/src/codex_message_processor/plugins.rs b/codex-rs/app-server/src/codex_message_processor/plugins.rs index 654b942c3d..edc0119f1b 100644 --- a/codex-rs/app-server/src/codex_message_processor/plugins.rs +++ b/codex-rs/app-server/src/codex_message_processor/plugins.rs @@ -2,6 +2,7 @@ use super::*; use crate::error_code::internal_error; use crate::error_code::invalid_request; use codex_app_server_protocol::PluginInstallPolicy; +use codex_core::config::edit::ConfigEditsBuilder; impl CodexMessageProcessor { pub(super) async fn plugin_list( @@ -37,18 +38,24 @@ impl CodexMessageProcessor { { return Ok(empty_response()); } - plugins_manager.maybe_start_plugin_list_background_tasks_for_config( - &config, + plugins_manager.maybe_start_plugin_list_background_tasks( + config.features.enabled(Feature::Plugins), + config.features.enabled(Feature::RemotePlugin), + config.chatgpt_base_url.clone(), auth.clone(), &roots, Some(self.effective_plugins_changed_callback(config.clone())), ); - let config_for_marketplace_listing = config.clone(); + let config_layer_stack_for_marketplace_listing = config.config_layer_stack.clone(); + let plugins_enabled_for_marketplace_listing = config.features.enabled(Feature::Plugins); let plugins_manager_for_marketplace_listing = plugins_manager.clone(); let (mut data, marketplace_load_errors) = match tokio::task::spawn_blocking(move || { - let outcome = plugins_manager_for_marketplace_listing - .list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?; + let outcome = plugins_manager_for_marketplace_listing.list_marketplaces_for_config( + &config_layer_stack_for_marketplace_listing, + plugins_enabled_for_marketplace_listing, + &roots, + )?; Ok::< ( Vec, @@ -145,7 +152,11 @@ impl CodexMessageProcessor { .any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) { match plugins_manager - .featured_plugin_ids_for_config(&config, auth.as_ref()) + .featured_plugin_ids( + config.features.enabled(Feature::Plugins), + &config.chatgpt_base_url, + auth.as_ref(), + ) .await { Ok(featured_plugin_ids) => featured_plugin_ids, @@ -209,7 +220,11 @@ impl CodexMessageProcessor { marketplace_path, }; let outcome = plugins_manager - .read_plugin_for_config(&config, &request) + .read_plugin_for_config( + &config.config_layer_stack, + config.features.enabled(Feature::Plugins), + &request, + ) .await .map_err(|err| Self::marketplace_error(err, "read plugin details"))?; let environment_manager = self.thread_manager.environment_manager(); @@ -465,6 +480,14 @@ impl CodexMessageProcessor { .install_plugin(request) .await .map_err(Self::plugin_install_error)?; + let installed_plugin_id = result.plugin_id.as_key(); + ConfigEditsBuilder::new(config.codex_home.as_path()) + .set_plugin_enabled(&installed_plugin_id, /*enabled*/ true) + .apply() + .await + .map_err(|err| { + internal_error(format!("failed to persist installed plugin config: {err}")) + })?; let config = match self.load_latest_config(config_cwd).await { Ok(config) => config, Err(err) => { @@ -489,7 +512,7 @@ impl CodexMessageProcessor { .plugin_apps_needing_auth_for_install( &config, auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth), - &result.plugin_id.as_key(), + &installed_plugin_id, &plugin_apps, ) .await; @@ -570,7 +593,9 @@ impl CodexMessageProcessor { self.thread_manager .plugins_manager() .maybe_start_remote_installed_plugins_cache_refresh_after_mutation( - &config, + config.features.enabled(Feature::Plugins), + config.features.enabled(Feature::RemotePlugin), + config.chatgpt_base_url.clone(), auth.clone(), Some(self.effective_plugins_changed_callback(config.clone())), ); @@ -688,9 +713,14 @@ impl CodexMessageProcessor { let plugins_manager = self.thread_manager.plugins_manager(); plugins_manager - .uninstall_plugin(plugin_id) + .uninstall_plugin(plugin_id.clone()) .await .map_err(Self::plugin_uninstall_error)?; + ConfigEditsBuilder::new(self.config.codex_home.as_path()) + .clear_plugin(&plugin_id) + .apply() + .await + .map_err(|err| internal_error(format!("failed to clear plugin config: {err}")))?; match self.load_latest_config(/*fallback_cwd*/ None).await { Ok(config) => self.on_effective_plugins_changed(config), Err(err) => { @@ -712,9 +742,6 @@ impl CodexMessageProcessor { CorePluginInstallError::Marketplace(err) => { Self::marketplace_error(err, "install plugin") } - CorePluginInstallError::Config(err) => { - internal_error(format!("failed to persist installed plugin config: {err}")) - } CorePluginInstallError::Remote(err) => { internal_error(format!("failed to enable remote plugin: {err}")) } @@ -733,9 +760,6 @@ impl CodexMessageProcessor { } match err { - CorePluginUninstallError::Config(err) => { - internal_error(format!("failed to clear plugin config: {err}")) - } CorePluginUninstallError::Remote(err) => { internal_error(format!("failed to uninstall remote plugin: {err}")) } @@ -798,7 +822,9 @@ impl CodexMessageProcessor { self.on_effective_plugins_changed(config.clone()); } plugins_manager.maybe_start_remote_installed_plugins_cache_refresh_after_mutation( - &config, + config.features.enabled(Feature::Plugins), + config.features.enabled(Feature::RemotePlugin), + config.chatgpt_base_url.clone(), auth.clone(), Some(self.effective_plugins_changed_callback(config.clone())), ); diff --git a/codex-rs/app-server/src/config/external_agent_config.rs b/codex-rs/app-server/src/config/external_agent_config.rs index 20030dfe21..8d41632429 100644 --- a/codex-rs/app-server/src/config/external_agent_config.rs +++ b/codex-rs/app-server/src/config/external_agent_config.rs @@ -1,6 +1,7 @@ use codex_config::types::PluginConfig; use codex_core::config::Config; use codex_core::config::ConfigBuilder; +use codex_core::config::edit::ConfigEditsBuilder; use codex_core::plugins::PluginId; use codex_core::plugins::PluginInstallRequest; use codex_core::plugins::PluginsManager; @@ -20,6 +21,7 @@ use codex_external_agent_migration::missing_command_names; use codex_external_agent_migration::missing_subagent_names; use codex_external_agent_sessions::ExternalAgentSessionMigration; use codex_external_agent_sessions::detect_recent_sessions; +use codex_features::Feature; use codex_protocol::protocol::Product; use serde_json::Value as JsonValue; use std::collections::BTreeMap; @@ -757,9 +759,16 @@ impl ExternalAgentConfigService { }) .await { - Ok(_) => outcome - .succeeded_plugin_ids - .push(format!("{plugin_name}@{marketplace_name}")), + Ok(result) => match ConfigEditsBuilder::new(self.codex_home.as_path()) + .set_plugin_enabled(&result.plugin_id.as_key(), /*enabled*/ true) + .apply() + .await + { + Ok(()) => outcome.succeeded_plugin_ids.push(result.plugin_id.as_key()), + Err(_) => outcome + .failed_plugin_ids + .push(format!("{plugin_name}@{marketplace_name}")), + }, Err(_) => outcome .failed_plugin_ids .push(format!("{plugin_name}@{marketplace_name}")), @@ -1147,7 +1156,11 @@ fn configured_marketplace_plugins( plugins_manager: &PluginsManager, ) -> io::Result>> { let marketplaces = plugins_manager - .list_marketplaces_for_config(config, &[]) + .list_marketplaces_for_config( + &config.config_layer_stack, + config.features.enabled(Feature::Plugins), + &[], + ) .map_err(|err| { invalid_data_error(format!("failed to list configured marketplaces: {err}")) })?; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index aec4ec5091..c915e5a356 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -313,8 +313,11 @@ impl MessageProcessor { codex_message_processor.effective_plugins_changed_callback((*config).clone()); thread_manager .plugins_manager() - .maybe_start_plugin_startup_tasks_for_config( - &config, + .maybe_start_plugin_startup_tasks( + config.config_layer_stack.clone(), + config.features.enabled(Feature::Plugins), + config.features.enabled(Feature::RemotePlugin), + config.chatgpt_base_url.clone(), auth_manager.clone(), Some(on_effective_plugins_changed), ); 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 09dd59f620..faad72a11a 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -32,7 +32,6 @@ use wiremock::matchers::query_param; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; -const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1"; const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json"; const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json"; @@ -1044,91 +1043,6 @@ async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> { Ok(()) } -#[tokio::test] -async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { - let codex_home = TempDir::new()?; - let server = MockServer::start().await; - write_plugin_sync_config(codex_home.path(), &format!("{}/backend-api/", 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, - )?; - write_openai_curated_marketplace(codex_home.path(), &["linear"])?; - - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/featured")) - .and(query_param("platform", "codex")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#)) - .mount(&server) - .await; - - let marker_path = codex_home - .path() - .join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE); - - { - let mut mcp = McpProcess::new_with_plugin_startup_tasks(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - - wait_for_path_exists(&marker_path).await?; - wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1) - .await?; - let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) - .await?; - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let response: PluginListResponse = to_response(response)?; - let curated_marketplace = response - .marketplaces - .into_iter() - .find(|marketplace| marketplace.name == "openai-curated") - .expect("expected openai-curated marketplace entry"); - assert_eq!( - curated_marketplace - .plugins - .into_iter() - .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) - .collect::>(), - vec![("linear@openai-curated".to_string(), true, true)] - ); - wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1) - .await?; - } - - let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - - { - let mut mcp = McpProcess::new_with_plugin_startup_tasks(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - } - - tokio::time::sleep(Duration::from_millis(250)).await; - wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1).await?; - Ok(()) -} - #[tokio::test] async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -> Result<()> { let codex_home = TempDir::new()?; @@ -1579,19 +1493,6 @@ async fn wait_for_remote_plugin_request_count( Ok(()) } -async fn wait_for_path_exists(path: &std::path::Path) -> Result<()> { - timeout(DEFAULT_TIMEOUT, async { - loop { - if path.exists() { - return Ok::<(), anyhow::Error>(()); - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await??; - Ok(()) -} - fn write_installed_plugin( codex_home: &TempDir, marketplace_name: &str, diff --git a/codex-rs/chatgpt/Cargo.toml b/codex-rs/chatgpt/Cargo.toml index ce9aa627d4..74f9d0eae2 100644 --- a/codex-rs/chatgpt/Cargo.toml +++ b/codex-rs/chatgpt/Cargo.toml @@ -13,6 +13,7 @@ clap = { workspace = true, features = ["derive"] } codex-app-server-protocol = { workspace = true } codex-connectors = { workspace = true } codex-core = { workspace = true } +codex-features = { workspace = true } codex-git-utils = { workspace = true } codex-login = { workspace = true } codex-model-provider = { workspace = true } diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 9dba71ce3a..d345b22722 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -18,6 +18,7 @@ pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools pub use codex_core::connectors::with_app_enabled_state; use codex_core::plugins::AppConnectorId; use codex_core::plugins::PluginsManager; +use codex_features::Feature; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::default_client::originator; @@ -137,7 +138,12 @@ fn all_connectors_cache_key(config: &Config, auth: &CodexAuth) -> AllConnectorsC async fn plugin_apps_for_config(config: &Config) -> Vec { PluginsManager::new(config.codex_home.to_path_buf()) - .plugins_for_config(config) + .plugins_for_config( + &config.config_layer_stack, + config.features.enabled(Feature::Plugins), + config.features.enabled(Feature::RemotePlugin), + config.features.enabled(Feature::PluginHooks), + ) .await .effective_apps() } diff --git a/codex-rs/cli/src/marketplace_cmd.rs b/codex-rs/cli/src/marketplace_cmd.rs index d8756c263b..9724d8a57f 100644 --- a/codex-rs/cli/src/marketplace_cmd.rs +++ b/codex-rs/cli/src/marketplace_cmd.rs @@ -129,7 +129,7 @@ async fn run_upgrade( let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; let manager = PluginsManager::new(codex_home.to_path_buf()); let outcome = manager - .upgrade_configured_marketplaces_for_config(&config, marketplace_name.as_deref()) + .upgrade_configured_marketplaces(&config.config_layer_stack, marketplace_name.as_deref()) .map_err(anyhow::Error::msg)?; print_upgrade_outcome(&outcome, marketplace_name.as_deref()) } diff --git a/codex-rs/core-plugins/Cargo.toml b/codex-rs/core-plugins/Cargo.toml index ee477a381f..be304a4f85 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -13,6 +13,8 @@ path = "src/lib.rs" workspace = true [dependencies] +anyhow = { workspace = true } +codex-analytics = { workspace = true } codex-app-server-protocol = { workspace = true } codex-config = { workspace = true } codex-core-skills = { workspace = true } @@ -23,6 +25,7 @@ codex-model-provider = { workspace = true } codex-otel = { workspace = true } codex-plugin = { workspace = true } codex-protocol = { workspace = true } +codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-plugins = { workspace = true } chrono = { workspace = true } @@ -41,8 +44,9 @@ url = { workspace = true } zip = { workspace = true } [dev-dependencies] -anyhow = { workspace = true } libc = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } +tracing-subscriber = { workspace = true } +tracing-test = { workspace = true } wiremock = { workspace = true } diff --git a/codex-rs/core/src/plugins/discoverable.rs b/codex-rs/core-plugins/src/discoverable.rs similarity index 75% rename from codex-rs/core/src/plugins/discoverable.rs rename to codex-rs/core-plugins/src/discoverable.rs index ba14f87040..1aa3a7c796 100644 --- a/codex-rs/core/src/plugins/discoverable.rs +++ b/codex-rs/core-plugins/src/discoverable.rs @@ -1,15 +1,16 @@ use anyhow::Context; use std::collections::HashSet; +use std::path::Path; use tracing::warn; -use super::PluginCapabilitySummary; -use super::PluginsManager; -use crate::config::Config; +use crate::OPENAI_BUNDLED_MARKETPLACE_NAME; +use crate::OPENAI_CURATED_MARKETPLACE_NAME; +use crate::TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST; +use crate::manager::PluginsManager; +use codex_config::ConfigLayerStack; +use codex_config::types::ToolSuggestConfig; use codex_config::types::ToolSuggestDiscoverableType; -use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME; -use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; -use codex_core_plugins::TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST; -use codex_features::Feature; +use codex_plugin::PluginCapabilitySummary; use codex_tools::DiscoverablePluginInfo; const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[ @@ -17,30 +18,31 @@ const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[ OPENAI_CURATED_MARKETPLACE_NAME, ]; -pub(crate) async fn list_tool_suggest_discoverable_plugins( - config: &Config, +pub async fn list_tool_suggest_discoverable_plugins( + codex_home: &Path, + config_layer_stack: &ConfigLayerStack, + plugins_enabled: bool, + tool_suggest: &ToolSuggestConfig, ) -> anyhow::Result> { - if !config.features.enabled(Feature::Plugins) { + if !plugins_enabled { return Ok(Vec::new()); } - let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf()); - let configured_plugin_ids = config - .tool_suggest + let plugins_manager = PluginsManager::new(codex_home.to_path_buf()); + let configured_plugin_ids = tool_suggest .discoverables .iter() .filter(|discoverable| discoverable.kind == ToolSuggestDiscoverableType::Plugin) .map(|discoverable| discoverable.id.as_str()) .collect::>(); - let disabled_plugin_ids = config - .tool_suggest + let disabled_plugin_ids = tool_suggest .disabled_tools .iter() .filter(|disabled_tool| disabled_tool.kind == ToolSuggestDiscoverableType::Plugin) .map(|disabled_tool| disabled_tool.id.as_str()) .collect::>(); let marketplaces = plugins_manager - .list_marketplaces_for_config(config, &[]) + .list_marketplaces_for_config(config_layer_stack, plugins_enabled, &[]) .context("failed to list plugin marketplaces for tool suggestions")? .marketplaces; let mut discoverable_plugins = Vec::::new(); @@ -62,7 +64,11 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins( let plugin_id = plugin.id.clone(); match plugins_manager - .read_plugin_detail_for_marketplace_plugin(config, &marketplace_name, plugin) + .read_plugin_detail_for_marketplace_plugin( + config_layer_stack, + &marketplace_name, + plugin, + ) .await { Ok(plugin) => { diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core-plugins/src/discoverable_tests.rs similarity index 67% rename from codex-rs/core/src/plugins/discoverable_tests.rs rename to codex-rs/core-plugins/src/discoverable_tests.rs index f9e385fa8e..aa53fd2894 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core-plugins/src/discoverable_tests.rs @@ -1,15 +1,19 @@ use super::*; -use crate::plugins::PluginInstallRequest; -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::startup_sync::curated_plugins_repo_path; +use crate::manager::PluginInstallRequest; +use crate::startup_sync::curated_plugins_repo_path; +use codex_app_server_protocol::ConfigLayerSource; +use codex_config::CONFIG_TOML_FILE; +use codex_config::ConfigLayerEntry; +use codex_config::ConfigLayerStack; +use codex_config::ConfigRequirements; +use codex_config::ConfigRequirementsToml; +use codex_config::config_toml::ConfigToml; +use codex_config::types::ToolSuggestConfig; use codex_tools::DiscoverablePluginInfo; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; +use std::fs; +use std::path::Path; use tempfile::tempdir; use tracing::Level; use tracing_subscriber::fmt::format::FmtSpan; @@ -22,10 +26,12 @@ async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plug write_openai_curated_marketplace(&curated_root, &["sample", "slack"]); write_plugins_feature_config(codex_home.path()); - let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) - .await - .unwrap(); + let discoverable_plugins = list_tool_suggest_discoverable_plugins_for_test( + codex_home.path(), + /*plugins_enabled*/ true, + ) + .await + .unwrap(); assert_eq!( discoverable_plugins, @@ -105,7 +111,7 @@ async fn list_tool_suggest_discoverable_plugins_deduplicates_allowlisted_configu ); write_curated_plugin(&marketplace_root, plugin_name); write_file( - &codex_home.path().join(crate::config::CONFIG_TOML_FILE), + &codex_home.path().join(CONFIG_TOML_FILE), &format!( r#"[features] plugins = true @@ -120,10 +126,12 @@ discoverables = [{{ type = "plugin", id = "{plugin_id}" }}] ), ); - let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) - .await - .unwrap(); + let discoverable_plugins = list_tool_suggest_discoverable_plugins_for_test( + codex_home.path(), + /*plugins_enabled*/ true, + ) + .await + .unwrap(); assert_eq!(discoverable_plugins.len(), 1); assert_eq!(discoverable_plugins[0].id, plugin_id); @@ -159,7 +167,7 @@ async fn list_tool_suggest_discoverable_plugins_ignores_missing_allowlisted_plug ), ); write_file( - &codex_home.path().join(crate::config::CONFIG_TOML_FILE), + &codex_home.path().join(CONFIG_TOML_FILE), &format!( r#"[features] plugins = true @@ -171,10 +179,12 @@ source = "/tmp/{marketplace_name}" ), ); - let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) - .await - .unwrap(); + let discoverable_plugins = list_tool_suggest_discoverable_plugins_for_test( + codex_home.path(), + /*plugins_enabled*/ true, + ) + .await + .unwrap(); assert_eq!(discoverable_plugins.len(), 1); assert_eq!(discoverable_plugins[0].id, "slack@openai-curated"); @@ -186,16 +196,18 @@ async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_featu let curated_root = curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace(&curated_root, &["slack"]); write_file( - &codex_home.path().join(crate::config::CONFIG_TOML_FILE), + &codex_home.path().join(CONFIG_TOML_FILE), r#"[features] plugins = false "#, ); - let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) - .await - .unwrap(); + let discoverable_plugins = list_tool_suggest_discoverable_plugins_for_test( + codex_home.path(), + /*plugins_enabled*/ false, + ) + .await + .unwrap(); assert_eq!(discoverable_plugins, Vec::::new()); } @@ -214,10 +226,12 @@ async fn list_tool_suggest_discoverable_plugins_normalizes_description() { }"#, ); - let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) - .await - .unwrap(); + let discoverable_plugins = list_tool_suggest_discoverable_plugins_for_test( + codex_home.path(), + /*plugins_enabled*/ true, + ) + .await + .unwrap(); assert_eq!( discoverable_plugins, @@ -240,7 +254,7 @@ async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins( write_curated_plugin_sha(codex_home.path()); write_plugins_feature_config(codex_home.path()); - PluginsManager::new(codex_home.path().to_path_buf()) + crate::manager::PluginsManager::new(codex_home.path().to_path_buf()) .install_plugin(PluginInstallRequest { plugin_name: "slack".to_string(), marketplace_path: AbsolutePathBuf::try_from( @@ -250,11 +264,22 @@ async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins( }) .await .expect("plugin should install"); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true - let refreshed_config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config) - .await - .unwrap(); +[plugins."slack@openai-curated"] +enabled = true +"#, + ); + + let discoverable_plugins = list_tool_suggest_discoverable_plugins_for_test( + codex_home.path(), + /*plugins_enabled*/ true, + ) + .await + .unwrap(); assert_eq!(discoverable_plugins, Vec::::new()); } @@ -265,7 +290,7 @@ async fn list_tool_suggest_discoverable_plugins_omits_disabled_tool_suggestions( let curated_root = curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace(&curated_root, &["slack"]); write_file( - &codex_home.path().join(crate::config::CONFIG_TOML_FILE), + &codex_home.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true @@ -276,10 +301,12 @@ disabled_tools = [ "#, ); - let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) - .await - .unwrap(); + let discoverable_plugins = list_tool_suggest_discoverable_plugins_for_test( + codex_home.path(), + /*plugins_enabled*/ true, + ) + .await + .unwrap(); assert_eq!(discoverable_plugins, Vec::::new()); } @@ -290,7 +317,7 @@ async fn list_tool_suggest_discoverable_plugins_includes_configured_plugin_ids() let curated_root = curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace(&curated_root, &["sample"]); write_file( - &codex_home.path().join(crate::config::CONFIG_TOML_FILE), + &codex_home.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true @@ -299,10 +326,12 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }] "#, ); - let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) - .await - .unwrap(); + let discoverable_plugins = list_tool_suggest_discoverable_plugins_for_test( + codex_home.path(), + /*plugins_enabled*/ true, + ) + .await + .unwrap(); assert_eq!( discoverable_plugins, @@ -345,7 +374,6 @@ async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_ ); } - 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() @@ -357,9 +385,12 @@ async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_ .finish(); let _guard = tracing::subscriber::set_default(subscriber); - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) - .await - .unwrap(); + let discoverable_plugins = list_tool_suggest_discoverable_plugins_for_test( + codex_home.path(), + /*plugins_enabled*/ true, + ) + .await + .unwrap(); assert_eq!(discoverable_plugins.len(), 1); assert_eq!(discoverable_plugins[0].id, "slack@openai-curated"); @@ -382,3 +413,141 @@ async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_ 1 ); } + +async fn list_tool_suggest_discoverable_plugins_for_test( + codex_home: &Path, + plugins_enabled: bool, +) -> anyhow::Result> { + let config_layer_stack = config_layer_stack(codex_home); + let tool_suggest = tool_suggest_config(codex_home); + list_tool_suggest_discoverable_plugins( + codex_home, + &config_layer_stack, + plugins_enabled, + &tool_suggest, + ) + .await +} + +fn config_layer_stack(codex_home: &Path) -> ConfigLayerStack { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let raw = fs::read_to_string(&config_path).unwrap_or_default(); + let config = if raw.is_empty() { + toml::Value::Table(toml::map::Map::new()) + } else { + toml::from_str(&raw).expect("test config should parse") + }; + ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::User { + file: AbsolutePathBuf::try_from(config_path).expect("absolute config path"), + }, + config, + )], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("config layer stack should load") +} + +fn tool_suggest_config(codex_home: &Path) -> ToolSuggestConfig { + let raw = fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).unwrap_or_default(); + if raw.is_empty() { + return ToolSuggestConfig::default(); + } + toml::from_str::(&raw) + .expect("test config should parse") + .tool_suggest + .unwrap_or_default() +} + +fn write_file(path: &Path, contents: &str) { + fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); + fs::write(path, contents).unwrap(); +} + +fn write_curated_plugin(root: &Path, plugin_name: &str) { + let plugin_root = root.join("plugins").join(plugin_name); + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + &format!( + r#"{{ + "name": "{plugin_name}", + "description": "Plugin that includes skills, MCP servers, and app connectors" +}}"# + ), + ); + write_file( + &plugin_root.join("skills/SKILL.md"), + "---\nname: sample\ndescription: sample\n---\n", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample-docs": { + "type": "http", + "url": "https://sample.example/mcp" + } + } +}"#, + ); + write_file( + &plugin_root.join(".app.json"), + r#"{ + "apps": { + "calendar": { + "id": "connector_calendar" + } + } +}"#, + ); +} + +fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) { + let plugins = plugin_names + .iter() + .map(|plugin_name| { + format!( + r#"{{ + "name": "{plugin_name}", + "source": {{ + "source": "local", + "path": "./plugins/{plugin_name}" + }} + }}"# + ) + }) + .collect::>() + .join(",\n"); + write_file( + &root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "{OPENAI_CURATED_MARKETPLACE_NAME}", + "plugins": [ +{plugins} + ] +}}"# + ), + ); + for plugin_name in plugin_names { + write_curated_plugin(root, plugin_name); + } +} + +fn write_curated_plugin_sha(codex_home: &Path) { + write_file( + &codex_home.join(".tmp/plugins.sha"), + "0123456789abcdef0123456789abcdef01234567\n", + ); +} + +fn write_plugins_feature_config(codex_home: &Path) { + write_file( + &codex_home.join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); +} diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 82cb54c7fe..869422adbb 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -1,5 +1,7 @@ +pub mod discoverable; pub mod installed_marketplaces; pub mod loader; +pub mod manager; pub mod manifest; pub mod marketplace; pub mod marketplace_add; @@ -12,6 +14,8 @@ pub mod startup_sync; pub mod store; pub mod toggles; +use codex_config::types::McpServerConfig; + pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated"; pub const OPENAI_BUNDLED_MARKETPLACE_NAME: &str = "openai-bundled"; @@ -30,3 +34,6 @@ pub const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[ "figma@openai-curated", "computer-use@openai-bundled", ]; + +pub type LoadedPlugin = codex_plugin::LoadedPlugin; +pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome; diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core-plugins/src/manager.rs similarity index 71% rename from codex-rs/core/src/plugins/manager.rs rename to codex-rs/core-plugins/src/manager.rs index 595378637a..b0dfc7ade3 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -1,59 +1,53 @@ -use super::PluginLoadOutcome; -use super::startup_sync::start_startup_remote_plugin_sync_once; -use crate::SkillMetadata; -use crate::config::Config; -use crate::config::edit::ConfigEdit; -use crate::config::edit::ConfigEditsBuilder; +use crate::OPENAI_CURATED_MARKETPLACE_NAME; +use crate::PluginLoadOutcome; +use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack; +use crate::loader::configured_curated_plugin_ids_from_codex_home; +use crate::loader::curated_plugin_cache_version; +use crate::loader::installed_plugin_telemetry_metadata; +use crate::loader::load_plugin_apps; +use crate::loader::load_plugin_mcp_servers; +use crate::loader::load_plugin_skills; +use crate::loader::load_plugins_from_layer_stack; +use crate::loader::log_plugin_load_errors; +use crate::loader::materialize_marketplace_plugin_source; +use crate::loader::plugin_telemetry_metadata_from_root; +use crate::loader::refresh_curated_plugin_cache; +use crate::loader::refresh_non_curated_plugin_cache; +use crate::loader::refresh_non_curated_plugin_cache_force_reinstall; +use crate::loader::remote_installed_plugins_to_config; +use crate::manifest::PluginManifestInterface; +use crate::manifest::load_plugin_manifest; +use crate::marketplace::MarketplaceError; +use crate::marketplace::MarketplaceInterface; +use crate::marketplace::MarketplaceListError; +use crate::marketplace::MarketplacePluginAuthPolicy; +use crate::marketplace::MarketplacePluginPolicy; +use crate::marketplace::MarketplacePluginSource; +use crate::marketplace::ResolvedMarketplacePlugin; +use crate::marketplace::find_installable_marketplace_plugin; +use crate::marketplace::find_marketplace_plugin; +use crate::marketplace::list_marketplaces; +use crate::marketplace::plugin_interface_with_marketplace_category; +use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeError; +use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome; +use crate::marketplace_upgrade::configured_git_marketplace_names; +use crate::marketplace_upgrade::upgrade_configured_git_marketplaces; +use crate::remote::RemoteInstalledPlugin; +use crate::remote::RemotePluginCatalogError; +use crate::remote::RemotePluginServiceConfig; +use crate::remote_legacy::RemotePluginFetchError; +use crate::remote_legacy::RemotePluginMutationError; +use crate::startup_sync::curated_plugins_repo_path; +use crate::startup_sync::read_curated_plugins_sha; +use crate::startup_sync::sync_openai_plugins_repo; +use crate::store::PluginInstallResult as StorePluginInstallResult; +use crate::store::PluginStore; +use crate::store::PluginStoreError; use codex_analytics::AnalyticsEventsClient; use codex_config::ConfigLayerStack; use codex_config::types::PluginConfig; use codex_config::version_for_toml; -use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; -use codex_core_plugins::installed_marketplaces::installed_marketplace_roots_from_layer_stack; -use codex_core_plugins::loader::configured_curated_plugin_ids_from_codex_home; -use codex_core_plugins::loader::curated_plugin_cache_version; -use codex_core_plugins::loader::installed_plugin_telemetry_metadata; -use codex_core_plugins::loader::load_plugin_apps; -use codex_core_plugins::loader::load_plugin_mcp_servers; -use codex_core_plugins::loader::load_plugin_skills; -use codex_core_plugins::loader::load_plugins_from_layer_stack; -use codex_core_plugins::loader::log_plugin_load_errors; -use codex_core_plugins::loader::materialize_marketplace_plugin_source; -use codex_core_plugins::loader::plugin_telemetry_metadata_from_root; -use codex_core_plugins::loader::refresh_curated_plugin_cache; -use codex_core_plugins::loader::refresh_non_curated_plugin_cache; -use codex_core_plugins::loader::refresh_non_curated_plugin_cache_force_reinstall; -use codex_core_plugins::loader::remote_installed_plugins_to_config; -use codex_core_plugins::manifest::PluginManifestInterface; -use codex_core_plugins::manifest::load_plugin_manifest; -use codex_core_plugins::marketplace::MarketplaceError; -use codex_core_plugins::marketplace::MarketplaceInterface; -use codex_core_plugins::marketplace::MarketplaceListError; -use codex_core_plugins::marketplace::MarketplacePluginAuthPolicy; -use codex_core_plugins::marketplace::MarketplacePluginPolicy; -use codex_core_plugins::marketplace::MarketplacePluginSource; -use codex_core_plugins::marketplace::ResolvedMarketplacePlugin; -use codex_core_plugins::marketplace::find_installable_marketplace_plugin; -use codex_core_plugins::marketplace::find_marketplace_plugin; -use codex_core_plugins::marketplace::list_marketplaces; -use codex_core_plugins::marketplace::load_marketplace; -use codex_core_plugins::marketplace::plugin_interface_with_marketplace_category; -use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeError; -use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome; -use codex_core_plugins::marketplace_upgrade::configured_git_marketplace_names; -use codex_core_plugins::marketplace_upgrade::upgrade_configured_git_marketplaces; -use codex_core_plugins::remote::RemoteInstalledPlugin; -use codex_core_plugins::remote::RemotePluginCatalogError; -use codex_core_plugins::remote::RemotePluginServiceConfig; -use codex_core_plugins::remote_legacy::RemotePluginFetchError; -use codex_core_plugins::remote_legacy::RemotePluginMutationError; -use codex_core_plugins::startup_sync::curated_plugins_repo_path; -use codex_core_plugins::startup_sync::read_curated_plugins_sha; -use codex_core_plugins::startup_sync::sync_openai_plugins_repo; -use codex_core_plugins::store::PluginInstallResult as StorePluginInstallResult; -use codex_core_plugins::store::PluginStore; -use codex_core_plugins::store::PluginStoreError; -use codex_features::Feature; +use codex_core_skills::SkillMetadata; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_plugin::AppConnectorId; @@ -71,9 +65,6 @@ use std::sync::RwLock; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Instant; -use tokio::sync::Semaphore; -use toml_edit::value; -use tracing::info; use tracing::warn; static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false); @@ -143,18 +134,18 @@ struct ConfiguredMarketplaceUpgradeState { in_flight: bool, } -fn remote_plugin_service_config(config: &Config) -> RemotePluginServiceConfig { +fn remote_plugin_service_config(chatgpt_base_url: &str) -> RemotePluginServiceConfig { RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), + chatgpt_base_url: chatgpt_base_url.to_string(), } } fn featured_plugin_ids_cache_key( - config: &Config, + chatgpt_base_url: &str, auth: Option<&CodexAuth>, ) -> FeaturedPluginIdsCacheKey { FeaturedPluginIdsCacheKey { - chatgpt_base_url: config.chatgpt_base_url.clone(), + chatgpt_base_url: chatgpt_base_url.to_string(), account_id: auth.and_then(CodexAuth::get_account_id), chatgpt_user_id: auth.and_then(CodexAuth::get_chatgpt_user_id), is_workspace_account: auth.is_some_and(CodexAuth::is_workspace_account), @@ -253,107 +244,6 @@ impl From for PluginCapabilitySummary { } } -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct RemotePluginSyncResult { - /// Plugin ids newly installed into the local plugin cache. - pub installed_plugin_ids: Vec, - /// Plugin ids whose local config was changed to enabled. - pub enabled_plugin_ids: Vec, - /// Plugin ids whose local config was changed to disabled. - /// This is not populated by `sync_plugins_from_remote`. - pub disabled_plugin_ids: Vec, - /// Plugin ids removed from local cache or plugin config. - pub uninstalled_plugin_ids: Vec, -} - -#[derive(Debug, thiserror::Error)] -pub enum PluginRemoteSyncError { - #[error("chatgpt authentication required to sync remote plugins")] - AuthRequired, - - #[error( - "chatgpt authentication required to sync remote plugins; api key auth is not supported" - )] - UnsupportedAuthMode, - - #[error("failed to read auth token for remote plugin sync: {0}")] - AuthToken(#[source] std::io::Error), - - #[error("failed to send remote plugin sync request to {url}: {source}")] - Request { - url: String, - #[source] - source: reqwest::Error, - }, - - #[error("remote plugin sync request to {url} failed with status {status}: {body}")] - UnexpectedStatus { - url: String, - status: reqwest::StatusCode, - body: String, - }, - - #[error("failed to parse remote plugin sync response from {url}: {source}")] - Decode { - url: String, - #[source] - source: serde_json::Error, - }, - - #[error("local curated marketplace is not available")] - LocalMarketplaceNotFound, - - #[error("remote marketplace `{marketplace_name}` is not available locally")] - UnknownRemoteMarketplace { marketplace_name: String }, - - #[error("duplicate remote plugin `{plugin_name}` in sync response")] - DuplicateRemotePlugin { plugin_name: String }, - - #[error( - "remote plugin `{plugin_name}` was not found in local marketplace `{marketplace_name}`" - )] - UnknownRemotePlugin { - plugin_name: String, - marketplace_name: String, - }, - - #[error("{0}")] - InvalidPluginId(#[from] PluginIdError), - - #[error("{0}")] - Marketplace(#[from] MarketplaceError), - - #[error("{0}")] - Store(#[from] PluginStoreError), - - #[error("{0}")] - Config(#[from] anyhow::Error), - - #[error("failed to join remote plugin sync task: {0}")] - Join(#[from] tokio::task::JoinError), -} - -impl PluginRemoteSyncError { - fn join(source: tokio::task::JoinError) -> Self { - Self::Join(source) - } -} - -impl From for PluginRemoteSyncError { - fn from(value: RemotePluginFetchError) -> Self { - match value { - RemotePluginFetchError::AuthRequired => Self::AuthRequired, - RemotePluginFetchError::UnsupportedAuthMode => Self::UnsupportedAuthMode, - RemotePluginFetchError::AuthToken(source) => Self::AuthToken(source), - RemotePluginFetchError::Request { url, source } => Self::Request { url, source }, - RemotePluginFetchError::UnexpectedStatus { url, status, body } => { - Self::UnexpectedStatus { url, status, body } - } - RemotePluginFetchError::Decode { url, source } => Self::Decode { url, source }, - } - } -} - pub struct PluginsManager { codex_home: PathBuf, store: PluginStore, @@ -365,7 +255,6 @@ pub struct PluginsManager { // remote installed state cannot remain effective for a different account. remote_installed_plugins_cache: RwLock>>, remote_installed_plugins_cache_refresh_state: RwLock, - remote_sync_lock: Semaphore, restriction_product: Option, analytics_events_client: RwLock>, } @@ -406,7 +295,6 @@ impl PluginsManager { remote_installed_plugins_cache_refresh_state: RwLock::new( RemoteInstalledPluginsCacheRefreshState::default(), ), - remote_sync_lock: Semaphore::new(/*permits*/ 1), restriction_product, analytics_events_client: RwLock::new(None), } @@ -430,22 +318,36 @@ impl PluginsManager { } } - pub async fn plugins_for_config(&self, config: &Config) -> PluginLoadOutcome { - self.plugins_for_config_with_force_reload(config, /*force_reload*/ false) - .await + pub async fn plugins_for_config( + &self, + config_layer_stack: &ConfigLayerStack, + plugins_enabled: bool, + remote_plugins_enabled: bool, + plugin_hooks_enabled: bool, + ) -> PluginLoadOutcome { + self.plugins_for_config_with_force_reload( + config_layer_stack, + plugins_enabled, + remote_plugins_enabled, + plugin_hooks_enabled, + /*force_reload*/ false, + ) + .await } pub(crate) async fn plugins_for_config_with_force_reload( &self, - config: &Config, + config_layer_stack: &ConfigLayerStack, + plugins_enabled: bool, + remote_plugins_enabled: bool, + plugin_hooks_enabled: bool, force_reload: bool, ) -> PluginLoadOutcome { - if !config.features.enabled(Feature::Plugins) { + if !plugins_enabled { return PluginLoadOutcome::default(); } - let plugin_hooks_enabled = config.features.enabled(Feature::PluginHooks); - let config_version = version_for_toml(&config.config_layer_stack.effective_config()); + let config_version = version_for_toml(&config_layer_stack.effective_config()); if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&config_version, plugin_hooks_enabled) @@ -454,8 +356,8 @@ impl PluginsManager { } let outcome = load_plugins_from_layer_stack( - &config.config_layer_stack, - self.remote_installed_plugin_configs(config), + config_layer_stack, + self.remote_installed_plugin_configs(remote_plugins_enabled), &self.store, self.restriction_product, plugin_hooks_enabled, @@ -495,15 +397,16 @@ impl PluginsManager { pub async fn plugins_for_layer_stack( &self, config_layer_stack: &ConfigLayerStack, - config: &Config, + plugins_enabled: bool, + remote_plugins_enabled: bool, plugin_hooks_feature_enabled: bool, ) -> PluginLoadOutcome { - if !config.features.enabled(Feature::Plugins) { + if !plugins_enabled { return PluginLoadOutcome::default(); } load_plugins_from_layer_stack( config_layer_stack, - self.remote_installed_plugin_configs(config), + self.remote_installed_plugin_configs(remote_plugins_enabled), &self.store, self.restriction_product, plugin_hooks_feature_enabled, @@ -515,12 +418,15 @@ impl PluginsManager { pub async fn effective_skill_roots_for_layer_stack( &self, config_layer_stack: &ConfigLayerStack, - config: &Config, + plugins_enabled: bool, + remote_plugins_enabled: bool, + plugin_hooks_enabled: bool, ) -> Vec { self.plugins_for_layer_stack( config_layer_stack, - config, - config.features.enabled(Feature::PluginHooks), + plugins_enabled, + remote_plugins_enabled, + plugin_hooks_enabled, ) .await .effective_skill_roots() @@ -550,8 +456,11 @@ impl PluginsManager { } } - fn remote_installed_plugin_configs(&self, config: &Config) -> HashMap { - if !config.features.enabled(Feature::RemotePlugin) { + fn remote_installed_plugin_configs( + &self, + remote_plugins_enabled: bool, + ) -> HashMap { + if !remote_plugins_enabled { return HashMap::new(); } @@ -566,7 +475,11 @@ impl PluginsManager { remote_installed_plugins_to_config(plugins, &self.store) } - fn write_remote_installed_plugins_cache(&self, plugins: Vec) -> bool { + #[doc(hidden)] + pub fn write_remote_installed_plugins_cache( + &self, + plugins: Vec, + ) -> bool { let mut cache = match self.remote_installed_plugins_cache.write() { Ok(cache) => cache, Err(err) => err.into_inner(), @@ -596,12 +509,16 @@ impl PluginsManager { pub fn maybe_start_remote_installed_plugins_cache_refresh( self: &Arc, - config: &Config, + plugins_enabled: bool, + remote_plugins_enabled: bool, + chatgpt_base_url: String, auth: Option, on_effective_plugins_changed: Option>, ) { self.maybe_start_remote_installed_plugins_cache_refresh_with_notify( - config, + plugins_enabled, + remote_plugins_enabled, + chatgpt_base_url, auth, RemoteInstalledPluginsCacheRefreshNotify::IfCacheChanged, on_effective_plugins_changed, @@ -610,12 +527,16 @@ impl PluginsManager { pub fn maybe_start_remote_installed_plugins_cache_refresh_after_mutation( self: &Arc, - config: &Config, + plugins_enabled: bool, + remote_plugins_enabled: bool, + chatgpt_base_url: String, auth: Option, on_effective_plugins_changed: Option>, ) { self.maybe_start_remote_installed_plugins_cache_refresh_with_notify( - config, + plugins_enabled, + remote_plugins_enabled, + chatgpt_base_url, auth, RemoteInstalledPluginsCacheRefreshNotify::AfterSuccessfulRefresh, on_effective_plugins_changed, @@ -624,20 +545,20 @@ impl PluginsManager { fn maybe_start_remote_installed_plugins_cache_refresh_with_notify( self: &Arc, - config: &Config, + plugins_enabled: bool, + remote_plugins_enabled: bool, + chatgpt_base_url: String, auth: Option, notify: RemoteInstalledPluginsCacheRefreshNotify, on_effective_plugins_changed: Option>, ) { - if !config.features.enabled(Feature::Plugins) - || !config.features.enabled(Feature::RemotePlugin) - { + if !plugins_enabled || !remote_plugins_enabled { return; } self.schedule_remote_installed_plugins_cache_refresh( RemoteInstalledPluginsCacheRefreshRequest { - service_config: remote_plugin_service_config(config), + service_config: remote_plugin_service_config(&chatgpt_base_url), auth, notify, on_effective_plugins_changed, @@ -645,16 +566,20 @@ impl PluginsManager { ); } - pub fn maybe_start_plugin_list_background_tasks_for_config( + pub fn maybe_start_plugin_list_background_tasks( self: &Arc, - config: &Config, + plugins_enabled: bool, + remote_plugins_enabled: bool, + chatgpt_base_url: String, auth: Option, roots: &[AbsolutePathBuf], on_effective_plugins_changed: Option>, ) { self.maybe_start_non_curated_plugin_cache_refresh(roots); self.maybe_start_remote_installed_plugins_cache_refresh( - config, + plugins_enabled, + remote_plugins_enabled, + chatgpt_base_url, auth, on_effective_plugins_changed, ); @@ -708,26 +633,26 @@ impl PluginsManager { }); } - pub async fn featured_plugin_ids_for_config( + pub async fn featured_plugin_ids( &self, - config: &Config, + plugins_enabled: bool, + chatgpt_base_url: &str, auth: Option<&CodexAuth>, ) -> Result, RemotePluginFetchError> { - if !config.features.enabled(Feature::Plugins) { + if !plugins_enabled { return Ok(Vec::new()); } - let cache_key = featured_plugin_ids_cache_key(config, auth); + let cache_key = featured_plugin_ids_cache_key(chatgpt_base_url, auth); if let Some(featured_plugin_ids) = self.cached_featured_plugin_ids(&cache_key) { return Ok(featured_plugin_ids); } - let featured_plugin_ids = - codex_core_plugins::remote_legacy::fetch_remote_featured_plugin_ids( - &remote_plugin_service_config(config), - auth, - self.restriction_product, - ) - .await?; + let featured_plugin_ids = crate::remote_legacy::fetch_remote_featured_plugin_ids( + &remote_plugin_service_config(chatgpt_base_url), + auth, + self.restriction_product, + ) + .await?; self.write_featured_plugin_ids_cache(cache_key, &featured_plugin_ids); Ok(featured_plugin_ids) } @@ -746,7 +671,7 @@ impl PluginsManager { pub async fn install_plugin_with_remote_sync( &self, - config: &Config, + chatgpt_base_url: &str, auth: Option<&CodexAuth>, request: PluginInstallRequest, ) -> Result { @@ -757,8 +682,8 @@ impl PluginsManager { )?; let plugin_id = resolved.plugin_id.as_key(); // This only forwards the backend mutation before the local install flow. - codex_core_plugins::remote_legacy::enable_remote_plugin( - &remote_plugin_service_config(config), + crate::remote_legacy::enable_remote_plugin( + &remote_plugin_service_config(chatgpt_base_url), auth, &plugin_id, ) @@ -800,19 +725,6 @@ impl PluginsManager { .await .map_err(PluginInstallError::join)??; - ConfigEditsBuilder::new(&self.codex_home) - .with_edits([ConfigEdit::SetPath { - segments: vec![ - "plugins".to_string(), - result.plugin_id.as_key(), - "enabled".to_string(), - ], - value: value(true), - }]) - .apply() - .await - .map_err(PluginInstallError::from)?; - let analytics_events_client = match self.analytics_events_client.read() { Ok(client) => client.clone(), Err(err) => err.into_inner().clone(), @@ -839,7 +751,7 @@ impl PluginsManager { pub async fn uninstall_plugin_with_remote_sync( &self, - config: &Config, + chatgpt_base_url: &str, auth: Option<&CodexAuth>, plugin_id: String, ) -> Result<(), PluginUninstallError> { @@ -848,8 +760,8 @@ impl PluginsManager { let plugin_id = PluginId::parse(&plugin_id)?; let plugin_key = plugin_id.as_key(); // This only forwards the backend mutation before the local uninstall flow. - codex_core_plugins::remote_legacy::uninstall_remote_plugin( - &remote_plugin_service_config(config), + crate::remote_legacy::uninstall_remote_plugin( + &remote_plugin_service_config(chatgpt_base_url), auth, &plugin_key, ) @@ -870,13 +782,6 @@ impl PluginsManager { .await .map_err(PluginUninstallError::join)??; - ConfigEditsBuilder::new(&self.codex_home) - .with_edits([ConfigEdit::ClearPath { - segments: vec!["plugins".to_string(), plugin_id.as_key()], - }]) - .apply() - .await?; - let analytics_events_client = match self.analytics_events_client.read() { Ok(client) => client.clone(), Err(err) => err.into_inner().clone(), @@ -890,241 +795,20 @@ impl PluginsManager { Ok(()) } - pub async fn sync_plugins_from_remote( - &self, - config: &Config, - auth: Option<&CodexAuth>, - additive_only: bool, - ) -> Result { - let _remote_sync_guard = self.remote_sync_lock.acquire().await.map_err(|_| { - PluginRemoteSyncError::Config(anyhow::anyhow!("remote plugin sync semaphore closed")) - })?; - - if !config.features.enabled(Feature::Plugins) { - return Ok(RemotePluginSyncResult::default()); - } - - info!("starting remote plugin sync"); - let remote_plugins = codex_core_plugins::remote_legacy::fetch_remote_plugin_status( - &remote_plugin_service_config(config), - auth, - ) - .await - .map_err(PluginRemoteSyncError::from)?; - let configured_plugins = configured_plugins_from_stack(&config.config_layer_stack); - let curated_marketplace_root = curated_plugins_repo_path(self.codex_home.as_path()); - let curated_marketplace_path = AbsolutePathBuf::try_from( - curated_marketplace_root.join(".agents/plugins/marketplace.json"), - ) - .map_err(|_| PluginRemoteSyncError::LocalMarketplaceNotFound)?; - let curated_marketplace = match load_marketplace(&curated_marketplace_path) { - Ok(marketplace) => marketplace, - Err(MarketplaceError::MarketplaceNotFound { .. }) => { - return Err(PluginRemoteSyncError::LocalMarketplaceNotFound); - } - Err(err) => return Err(err.into()), - }; - - let marketplace_name = curated_marketplace.name.clone(); - let curated_plugin_version = read_curated_plugins_sha(self.codex_home.as_path()) - .ok_or_else(|| { - PluginStoreError::Invalid( - "local curated marketplace sha is not available".to_string(), - ) - })?; - let cache_plugin_version = curated_plugin_cache_version(&curated_plugin_version); - let mut local_plugins = Vec::<( - String, - PluginId, - AbsolutePathBuf, - Option, - Option, - bool, - )>::new(); - let mut local_plugin_names = HashSet::new(); - for plugin in curated_marketplace.plugins { - let plugin_name = plugin.name; - if !local_plugin_names.insert(plugin_name.clone()) { - warn!( - plugin = plugin_name, - marketplace = %marketplace_name, - "ignoring duplicate local plugin entry during remote sync" - ); - continue; - } - - let plugin_id = PluginId::new(plugin_name.clone(), marketplace_name.clone())?; - let plugin_key = plugin_id.as_key(); - let source_path = match plugin.source { - MarketplacePluginSource::Local { path } => path, - MarketplacePluginSource::Git { .. } => { - warn!( - plugin = plugin_name, - marketplace = %marketplace_name, - "skipping remote plugin source during remote sync" - ); - continue; - } - }; - let current_enabled = configured_plugins - .get(&plugin_key) - .map(|plugin| plugin.enabled); - let installed_version = self.store.active_plugin_version(&plugin_id); - let product_allowed = - self.restriction_product_matches(plugin.policy.products.as_deref()); - local_plugins.push(( - plugin_name, - plugin_id, - source_path, - current_enabled, - installed_version, - product_allowed, - )); - } - - let mut missing_remote_plugins = Vec::::new(); - let mut remote_installed_plugin_names = HashSet::::new(); - for plugin in remote_plugins { - if plugin.marketplace_name != marketplace_name { - return Err(PluginRemoteSyncError::UnknownRemoteMarketplace { - marketplace_name: plugin.marketplace_name, - }); - } - if !local_plugin_names.contains(&plugin.name) { - missing_remote_plugins.push(plugin.name); - continue; - } - // For now, sync treats remote `enabled = false` as uninstall rather than a distinct - // disabled state. - // TODO: Switch sync to `plugins/installed` so install and enable states stay distinct. - if !plugin.enabled { - continue; - } - if !remote_installed_plugin_names.insert(plugin.name.clone()) { - return Err(PluginRemoteSyncError::DuplicateRemotePlugin { - plugin_name: plugin.name, - }); - } - } - - let mut config_edits = Vec::new(); - let mut installs = Vec::new(); - let mut uninstalls = Vec::new(); - let mut result = RemotePluginSyncResult::default(); - let remote_plugin_count = remote_installed_plugin_names.len(); - let local_plugin_count = local_plugins.len(); - if !missing_remote_plugins.is_empty() { - let sample_missing_plugins = missing_remote_plugins - .iter() - .take(10) - .cloned() - .collect::>(); - warn!( - marketplace = %marketplace_name, - missing_remote_plugin_count = missing_remote_plugins.len(), - missing_remote_plugin_examples = ?sample_missing_plugins, - "ignoring remote plugins missing from local marketplace during sync" - ); - } - - for ( - plugin_name, - plugin_id, - source_path, - current_enabled, - installed_version, - product_allowed, - ) in local_plugins - { - let plugin_key = plugin_id.as_key(); - let is_installed = installed_version.is_some(); - if !product_allowed { - continue; - } - if remote_installed_plugin_names.contains(&plugin_name) { - if !is_installed { - installs.push((source_path, plugin_id.clone(), cache_plugin_version.clone())); - } - if !is_installed { - result.installed_plugin_ids.push(plugin_key.clone()); - } - - if current_enabled != Some(true) { - result.enabled_plugin_ids.push(plugin_key.clone()); - config_edits.push(ConfigEdit::SetPath { - segments: vec!["plugins".to_string(), plugin_key, "enabled".to_string()], - value: value(true), - }); - } - } else if !additive_only { - if is_installed { - uninstalls.push(plugin_id); - } - if is_installed || current_enabled.is_some() { - result.uninstalled_plugin_ids.push(plugin_key.clone()); - } - if current_enabled.is_some() { - config_edits.push(ConfigEdit::ClearPath { - segments: vec!["plugins".to_string(), plugin_key], - }); - } - } - } - - let store = self.store.clone(); - let store_result = tokio::task::spawn_blocking(move || { - for (source_path, plugin_id, plugin_version) in installs { - store.install_with_version(source_path, plugin_id, plugin_version)?; - } - for plugin_id in uninstalls { - store.uninstall(&plugin_id)?; - } - Ok::<(), PluginStoreError>(()) - }) - .await - .map_err(PluginRemoteSyncError::join)?; - if let Err(err) = store_result { - self.clear_cache(); - return Err(err.into()); - } - - let config_result = if config_edits.is_empty() { - Ok(()) - } else { - ConfigEditsBuilder::new(&self.codex_home) - .with_edits(config_edits) - .apply() - .await - }; - self.clear_cache(); - config_result?; - - info!( - marketplace = %marketplace_name, - remote_plugin_count, - local_plugin_count, - installed_plugin_ids = ?result.installed_plugin_ids, - enabled_plugin_ids = ?result.enabled_plugin_ids, - disabled_plugin_ids = ?result.disabled_plugin_ids, - uninstalled_plugin_ids = ?result.uninstalled_plugin_ids, - "completed remote plugin sync" - ); - - Ok(result) - } - pub fn list_marketplaces_for_config( &self, - config: &Config, + config_layer_stack: &ConfigLayerStack, + plugins_enabled: bool, additional_roots: &[AbsolutePathBuf], ) -> Result { - if !config.features.enabled(Feature::Plugins) { + if !plugins_enabled { return Ok(ConfiguredMarketplaceListOutcome::default()); } - let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config); + let (installed_plugins, enabled_plugins) = + self.configured_plugin_states(config_layer_stack); let marketplace_outcome = - list_marketplaces(&self.marketplace_roots(config, additional_roots))?; + list_marketplaces(&self.marketplace_roots(config_layer_stack, additional_roots))?; let mut seen_plugin_keys = HashSet::new(); let marketplaces = marketplace_outcome .marketplaces @@ -1175,10 +859,11 @@ impl PluginsManager { pub async fn read_plugin_for_config( &self, - config: &Config, + config_layer_stack: &ConfigLayerStack, + plugins_enabled: bool, request: &PluginReadRequest, ) -> Result { - if !config.features.enabled(Feature::Plugins) { + if !plugins_enabled { return Err(MarketplaceError::PluginsDisabled); } @@ -1192,10 +877,11 @@ impl PluginsManager { let marketplace_name = plugin.plugin_id.marketplace_name.clone(); let plugin_key = plugin.plugin_id.as_key(); - let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config); + let (installed_plugins, enabled_plugins) = + self.configured_plugin_states(config_layer_stack); let plugin = self .read_plugin_detail_for_marketplace_plugin( - config, + config_layer_stack, &marketplace_name, ConfiguredMarketplacePlugin { id: plugin_key.clone(), @@ -1216,9 +902,9 @@ impl PluginsManager { }) } - pub(crate) async fn read_plugin_detail_for_marketplace_plugin( + pub async fn read_plugin_detail_for_marketplace_plugin( &self, - config: &Config, + config_layer_stack: &ConfigLayerStack, marketplace_name: &str, plugin: ConfiguredMarketplacePlugin, ) -> Result { @@ -1300,9 +986,7 @@ impl PluginsManager { &source_path, &manifest.paths, self.restriction_product, - &codex_core_skills::config_rules::skill_config_rules_from_stack( - &config.config_layer_stack, - ), + &codex_core_skills::config_rules::skill_config_rules_from_stack(config_layer_stack), ) .await; let apps = load_plugin_apps(source_path.as_path()).await; @@ -1330,13 +1014,16 @@ impl PluginsManager { }) } - pub fn maybe_start_plugin_startup_tasks_for_config( + pub fn maybe_start_plugin_startup_tasks( self: &Arc, - config: &Config, + config_layer_stack: ConfigLayerStack, + plugins_enabled: bool, + remote_plugins_enabled: bool, + chatgpt_base_url: String, auth_manager: Arc, on_effective_plugins_changed: Option>, ) { - if config.features.enabled(Feature::Plugins) { + if plugins_enabled { self.start_curated_repo_sync(); let should_spawn_marketplace_auto_upgrade = { let mut state = match self.configured_marketplace_upgrade_state.write() { @@ -1352,12 +1039,13 @@ impl PluginsManager { }; if should_spawn_marketplace_auto_upgrade { let manager = Arc::clone(self); - let config = config.clone(); + let config_layer_stack_for_upgrade = config_layer_stack; if let Err(err) = std::thread::Builder::new() .name("plugins-marketplace-auto-upgrade".to_string()) .spawn(move || { - let outcome = manager.upgrade_configured_marketplaces_for_config( - &config, /*marketplace_name*/ None, + let outcome = manager.upgrade_configured_marketplaces( + &config_layer_stack_for_upgrade, + /*marketplace_name*/ None, ); match outcome { Ok(outcome) => { @@ -1389,34 +1077,29 @@ impl PluginsManager { warn!("failed to start configured marketplace auto-upgrade task: {err}"); } } - start_startup_remote_plugin_sync_once( - Arc::clone(self), - self.codex_home.clone(), - config.clone(), - auth_manager.clone(), - ); - if config.features.enabled(Feature::RemotePlugin) { - let config = config.clone(); + if remote_plugins_enabled { let manager = Arc::clone(self); let auth_manager = auth_manager.clone(); let on_effective_plugins_changed = on_effective_plugins_changed.clone(); + let chatgpt_base_url = chatgpt_base_url.clone(); tokio::spawn(async move { let auth = auth_manager.auth().await; manager.maybe_start_remote_installed_plugins_cache_refresh( - &config, + plugins_enabled, + remote_plugins_enabled, + chatgpt_base_url, auth, on_effective_plugins_changed, ); }); } - let config = config.clone(); let manager = Arc::clone(self); tokio::spawn(async move { let auth = auth_manager.auth().await; if let Err(err) = manager - .featured_plugin_ids_for_config(&config, auth.as_ref()) + .featured_plugin_ids(plugins_enabled, &chatgpt_base_url, auth.as_ref()) .await { warn!( @@ -1428,13 +1111,13 @@ impl PluginsManager { } } - pub fn upgrade_configured_marketplaces_for_config( + pub fn upgrade_configured_marketplaces( &self, - config: &Config, + config_layer_stack: &ConfigLayerStack, marketplace_name: Option<&str>, ) -> Result { if let Some(marketplace_name) = marketplace_name - && !configured_git_marketplace_names(&config.config_layer_stack) + && !configured_git_marketplace_names(config_layer_stack) .iter() .any(|name| name == marketplace_name) { @@ -1445,7 +1128,7 @@ impl PluginsManager { let mut outcome = upgrade_configured_git_marketplaces( self.codex_home.as_path(), - &config.config_layer_stack, + config_layer_stack, marketplace_name, ); if !outcome.upgraded_roots.is_empty() { @@ -1648,7 +1331,7 @@ impl PluginsManager { } }; - let installed_plugins = codex_core_plugins::remote::fetch_remote_installed_plugins( + let installed_plugins = crate::remote::fetch_remote_installed_plugins( &request.service_config, request.auth.as_ref(), ) @@ -1751,8 +1434,11 @@ impl PluginsManager { } } - fn configured_plugin_states(&self, config: &Config) -> (HashSet, HashSet) { - let configured_plugins = configured_plugins_from_stack(&config.config_layer_stack); + fn configured_plugin_states( + &self, + config_layer_stack: &ConfigLayerStack, + ) -> (HashSet, HashSet) { + let configured_plugins = configured_plugins_from_stack(config_layer_stack); let installed_plugins = configured_plugins .keys() .filter(|plugin_key| { @@ -1771,14 +1457,14 @@ impl PluginsManager { fn marketplace_roots( &self, - config: &Config, + config_layer_stack: &ConfigLayerStack, additional_roots: &[AbsolutePathBuf], ) -> Vec { // Treat the curated catalog as an extra marketplace root so plugin listing can surface it // without requiring every caller to know where it is stored. let mut roots = additional_roots.to_vec(); roots.extend(installed_marketplace_roots_from_layer_stack( - &config.config_layer_stack, + config_layer_stack, self.codex_home.as_path(), )); let curated_repo_root = curated_plugins_repo_path(self.codex_home.as_path()); @@ -1832,9 +1518,6 @@ pub enum PluginInstallError { #[error("{0}")] Store(#[from] PluginStoreError), - #[error("{0}")] - Config(#[from] anyhow::Error), - #[error("failed to join plugin install task: {0}")] Join(#[from] tokio::task::JoinError), } @@ -1869,9 +1552,6 @@ pub enum PluginUninstallError { #[error("{0}")] Store(#[from] PluginStoreError), - #[error("{0}")] - Config(#[from] anyhow::Error), - #[error("failed to join plugin uninstall task: {0}")] Join(#[from] tokio::task::JoinError), } @@ -1910,7 +1590,3 @@ fn configured_plugins_from_user_config_value( } } } - -#[cfg(test)] -#[path = "manager_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index eceaaa9200..9d4b7a7ea0 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -5,6 +5,7 @@ use crate::config::ConfigBuilder; use crate::plugins::PluginsManager; use crate::skills_load_input_from_config; use codex_config::ConfigLayerStackOrdering; +use codex_features::Feature; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Verbosity; use codex_protocol::openai_models::ReasoningEffort; @@ -655,7 +656,14 @@ enabled = false let plugins_manager = Arc::new(PluginsManager::new(home.path().to_path_buf())); let skills_manager = SkillsManager::new(home.path().abs(), /*bundled_skills_enabled*/ true); - let plugin_outcome = plugins_manager.plugins_for_config(&config).await; + let plugin_outcome = plugins_manager + .plugins_for_config( + &config.config_layer_stack, + config.features.enabled(Feature::Plugins), + config.features.enabled(Feature::RemotePlugin), + config.features.enabled(Feature::PluginHooks), + ) + .await; let effective_skill_roots = plugin_outcome.effective_skill_roots(); let skills_input = skills_load_input_from_config(&config, effective_skill_roots); let outcome = skills_manager diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 80b54aeaf0..61410920a6 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -1201,6 +1201,25 @@ impl ConfigEditsBuilder { self } + pub fn set_plugin_enabled(mut self, plugin_id: &str, enabled: bool) -> Self { + self.edits.push(ConfigEdit::SetPath { + segments: vec![ + "plugins".to_string(), + plugin_id.to_string(), + "enabled".to_string(), + ], + value: value(enabled), + }); + self + } + + pub fn clear_plugin(mut self, plugin_id: &str) -> Self { + self.edits.push(ConfigEdit::ClearPath { + segments: vec!["plugins".to_string(), plugin_id.to_string()], + }); + self + } + /// Enable or disable a feature flag by key under the `[features]` table. /// /// Disabling a default-false feature clears the root-scoped key instead of diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 376632a93a..1b5bcd552e 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -1313,6 +1313,39 @@ model_reasoning_effort = "high" assert_eq!(contents, initial_expected); } +#[test] +fn blocking_builder_sets_and_clears_plugin_config() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .set_plugin_enabled("sample@test", /*enabled*/ true) + .set_plugin_enabled("other@test", /*enabled*/ false) + .apply_blocking() + .expect("persist plugin config"); + + ConfigEditsBuilder::new(codex_home) + .clear_plugin("sample@test") + .apply_blocking() + .expect("clear plugin config"); + + let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let config: TomlValue = toml::from_str(&raw).expect("parse config"); + let plugins = config + .get("plugins") + .and_then(TomlValue::as_table) + .expect("plugins table should exist"); + assert!(!plugins.contains_key("sample@test")); + assert_eq!( + plugins + .get("other@test") + .and_then(TomlValue::as_table) + .and_then(|plugin| plugin.get("enabled")) + .and_then(TomlValue::as_bool), + Some(false) + ); +} + #[tokio::test] async fn blocking_set_asynchronous_helpers_available() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index c5965d389e..9213fef8fd 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1000,7 +1000,14 @@ impl Config { &self, plugins_manager: &crate::plugins::PluginsManager, ) -> McpConfig { - let loaded_plugins = plugins_manager.plugins_for_config(self).await; + let loaded_plugins = plugins_manager + .plugins_for_config( + &self.config_layer_stack, + self.features.enabled(Feature::Plugins), + self.features.enabled(Feature::RemotePlugin), + self.features.enabled(Feature::PluginHooks), + ) + .await; let mut configured_mcp_servers = self.mcp_servers.get().clone(); for plugin in loaded_plugins .plugins() diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index fff7b4c356..e7aaf7a46c 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -14,6 +14,7 @@ pub use codex_app_server_protocol::AppInfo; pub use codex_app_server_protocol::AppMetadata; use codex_connectors::AllConnectorsCacheKey; use codex_connectors::DirectoryListResponse; +use codex_core_plugins::discoverable::list_tool_suggest_discoverable_plugins; use codex_exec_server::EnvironmentManager; use codex_exec_server::EnvironmentManagerArgs; use codex_exec_server::ExecServerRuntimePaths; @@ -27,7 +28,6 @@ use tracing::warn; use crate::config::Config; use crate::mcp::McpManager; use crate::plugins::PluginsManager; -use crate::plugins::list_tool_suggest_discoverable_plugins; use crate::session::INITIAL_SUBMIT_ID; use codex_config::AppsRequirementsToml; use codex_config::types::AppToolApproval; @@ -131,10 +131,15 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( ) .into_iter() .map(DiscoverableTool::from); - let discoverable_plugins = list_tool_suggest_discoverable_plugins(config) - .await? - .into_iter() - .map(DiscoverableTool::from); + let discoverable_plugins = list_tool_suggest_discoverable_plugins( + config.codex_home.as_path(), + &config.config_layer_stack, + config.features.enabled(Feature::Plugins), + &config.tool_suggest, + ) + .await? + .into_iter() + .map(DiscoverableTool::from); Ok(discoverable_connectors .chain(discoverable_plugins) .collect()) @@ -404,7 +409,12 @@ fn write_cached_accessible_connectors( async fn tool_suggest_connector_ids(config: &Config) -> HashSet { let mut connector_ids = PluginsManager::new(config.codex_home.to_path_buf()) - .plugins_for_config(config) + .plugins_for_config( + &config.config_layer_stack, + config.features.enabled(Feature::Plugins), + config.features.enabled(Feature::RemotePlugin), + config.features.enabled(Feature::PluginHooks), + ) .await .capability_summaries() .iter() diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 414a587a23..e1d01323c3 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -68,6 +68,8 @@ pub use message_history::lookup as lookup_message_history_entry; pub use utils::path_utils; pub mod personality_migration; pub mod plugins; +#[cfg(test)] +mod plugins_manager_tests; #[doc(hidden)] pub(crate) mod prompt_debug; #[doc(hidden)] diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 1619946545..84a7dd025b 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -756,7 +756,12 @@ async fn custom_mcp_tool_approval_mode( sess.services .plugins_manager - .plugins_for_config(turn_context.config.as_ref()) + .plugins_for_config( + &turn_context.config.config_layer_stack, + turn_context.config.features.enabled(Feature::Plugins), + turn_context.config.features.enabled(Feature::RemotePlugin), + turn_context.config.features.enabled(Feature::PluginHooks), + ) .await .plugins() .iter() @@ -1805,7 +1810,12 @@ async fn persist_non_app_mcp_tool_approval( let plugin_config_name = sess .services .plugins_manager - .plugins_for_config(config) + .plugins_for_config( + &config.config_layer_stack, + config.features.enabled(Feature::Plugins), + config.features.enabled(Feature::RemotePlugin), + config.features.enabled(Feature::PluginHooks), + ) .await .plugins() .iter() diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 37df6dc6ec..eabe76a920 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -13,6 +13,7 @@ use codex_config::types::ApprovalsReviewer; use codex_config::types::AppsConfigToml; use codex_config::types::McpServerConfig; use codex_config::types::McpServerToolConfig; +use codex_features::Feature; use codex_hooks::Hooks; use codex_hooks::HooksConfig; use codex_model_provider::create_model_provider; @@ -1594,7 +1595,12 @@ enabled = true session .services .plugins_manager - .plugins_for_config(&initial_config) + .plugins_for_config( + &initial_config.config_layer_stack, + initial_config.features.enabled(Feature::Plugins), + initial_config.features.enabled(Feature::RemotePlugin), + initial_config.features.enabled(Feature::PluginHooks), + ) .await; std::fs::write( codex_home.join(CONFIG_TOML_FILE), diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index ff3183557a..84e9306e64 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -1,14 +1,23 @@ -use codex_config::types::McpServerConfig; - -mod discoverable; mod injection; -mod manager; mod mentions; mod render; -mod startup_sync; #[cfg(test)] pub(crate) mod test_support; +pub use codex_core_plugins::LoadedPlugin; +pub use codex_core_plugins::PluginLoadOutcome; +pub use codex_core_plugins::manager::ConfiguredMarketplace; +pub use codex_core_plugins::manager::ConfiguredMarketplaceListOutcome; +pub use codex_core_plugins::manager::ConfiguredMarketplacePlugin; +pub use codex_core_plugins::manager::PluginDetail; +pub use codex_core_plugins::manager::PluginDetailsUnavailableReason; +pub use codex_core_plugins::manager::PluginInstallError; +pub use codex_core_plugins::manager::PluginInstallOutcome; +pub use codex_core_plugins::manager::PluginInstallRequest; +pub use codex_core_plugins::manager::PluginReadOutcome; +pub use codex_core_plugins::manager::PluginReadRequest; +pub use codex_core_plugins::manager::PluginUninstallError; +pub use codex_core_plugins::manager::PluginsManager; pub use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError; pub use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome; pub use codex_plugin::AppConnectorId; @@ -19,25 +28,7 @@ pub use codex_plugin::PluginIdError; pub use codex_plugin::PluginTelemetryMetadata; pub use codex_plugin::validate_plugin_segment; -pub type LoadedPlugin = codex_plugin::LoadedPlugin; -pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome; - -pub(crate) use discoverable::list_tool_suggest_discoverable_plugins; pub(crate) use injection::build_plugin_injections; -pub use manager::ConfiguredMarketplace; -pub use manager::ConfiguredMarketplaceListOutcome; -pub use manager::ConfiguredMarketplacePlugin; -pub use manager::PluginDetail; -pub use manager::PluginDetailsUnavailableReason; -pub use manager::PluginInstallError; -pub use manager::PluginInstallOutcome; -pub use manager::PluginInstallRequest; -pub use manager::PluginReadOutcome; -pub use manager::PluginReadRequest; -pub use manager::PluginRemoteSyncError; -pub use manager::PluginUninstallError; -pub use manager::PluginsManager; -pub use manager::RemotePluginSyncResult; pub(crate) use render::render_explicit_plugin_instructions; pub(crate) use mentions::build_connector_slug_counts; diff --git a/codex-rs/core/src/plugins/startup_sync.rs b/codex-rs/core/src/plugins/startup_sync.rs deleted file mode 100644 index 31cf4c75e2..0000000000 --- a/codex-rs/core/src/plugins/startup_sync.rs +++ /dev/null @@ -1,100 +0,0 @@ -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; - -use crate::config::Config; -use crate::plugins::PluginsManager; -use codex_core_plugins::startup_sync::has_local_curated_plugins_snapshot; -use codex_login::AuthManager; -use tracing::info; -use tracing::warn; - -const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1"; -const STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT: Duration = Duration::from_secs(10); - -pub(crate) fn start_startup_remote_plugin_sync_once( - manager: Arc, - codex_home: PathBuf, - config: Config, - auth_manager: Arc, -) { - let marker_path = startup_remote_plugin_sync_marker_path(codex_home.as_path()); - if marker_path.is_file() { - return; - } - - tokio::spawn(async move { - if marker_path.is_file() { - return; - } - - if !wait_for_startup_remote_plugin_sync_prerequisites(codex_home.as_path()).await { - warn!( - codex_home = %codex_home.display(), - "skipping startup remote plugin sync because curated marketplace is not ready" - ); - return; - } - - let auth = auth_manager.auth().await; - match manager - .sync_plugins_from_remote(&config, auth.as_ref(), /*additive_only*/ true) - .await - { - Ok(sync_result) => { - info!( - installed_plugin_ids = ?sync_result.installed_plugin_ids, - enabled_plugin_ids = ?sync_result.enabled_plugin_ids, - disabled_plugin_ids = ?sync_result.disabled_plugin_ids, - uninstalled_plugin_ids = ?sync_result.uninstalled_plugin_ids, - "completed startup remote plugin sync" - ); - if let Err(err) = - write_startup_remote_plugin_sync_marker(codex_home.as_path()).await - { - warn!( - error = %err, - path = %marker_path.display(), - "failed to persist startup remote plugin sync marker" - ); - } - } - Err(err) => { - warn!( - error = %err, - "startup remote plugin sync failed; will retry on next app-server start" - ); - } - } - }); -} - -fn startup_remote_plugin_sync_marker_path(codex_home: &Path) -> PathBuf { - codex_home.join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE) -} - -async fn wait_for_startup_remote_plugin_sync_prerequisites(codex_home: &Path) -> bool { - let deadline = tokio::time::Instant::now() + STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT; - loop { - if has_local_curated_plugins_snapshot(codex_home) { - return true; - } - if tokio::time::Instant::now() >= deadline { - return false; - } - tokio::time::sleep(Duration::from_millis(50)).await; - } -} - -async fn write_startup_remote_plugin_sync_marker(codex_home: &Path) -> std::io::Result<()> { - let marker_path = startup_remote_plugin_sync_marker_path(codex_home); - if let Some(parent) = marker_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - tokio::fs::write(marker_path, b"ok\n").await -} - -#[cfg(test)] -#[path = "startup_sync_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/plugins/startup_sync_tests.rs b/codex-rs/core/src/plugins/startup_sync_tests.rs deleted file mode 100644 index fb79d65ae3..0000000000 --- a/codex-rs/core/src/plugins/startup_sync_tests.rs +++ /dev/null @@ -1,90 +0,0 @@ -use super::*; -use crate::config::CONFIG_TOML_FILE; -use crate::plugins::PluginsManager; -use crate::plugins::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION; -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 codex_core_plugins::startup_sync::curated_plugins_repo_path; -use codex_login::AuthManager; -use codex_login::CodexAuth; -use pretty_assertions::assert_eq; -use std::sync::Arc; -use std::time::Duration; -use tempfile::tempdir; -use wiremock::Mock; -use wiremock::MockServer; -use wiremock::ResponseTemplate; -use wiremock::matchers::header; -use wiremock::matchers::method; -use wiremock::matchers::path; - -#[tokio::test] -async fn startup_remote_plugin_sync_writes_marker_and_reconciles_state() { - let tmp = tempdir().expect("tempdir"); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear"]); - write_curated_plugin_sha(tmp.path()); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .and(header("authorization", "Bearer Access Token")) - .and(header("chatgpt-account-id", "account_id")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - - let mut config = crate::plugins::test_support::load_plugins_config(tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = Arc::new(PluginsManager::new(tmp.path().to_path_buf())); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - - start_startup_remote_plugin_sync_once( - Arc::clone(&manager), - tmp.path().to_path_buf(), - config, - auth_manager, - ); - - let marker_path = tmp.path().join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE); - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if marker_path.is_file() { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("marker should be written"); - - assert!( - tmp.path() - .join(format!( - "plugins/cache/openai-curated/linear/{TEST_CURATED_PLUGIN_CACHE_VERSION}" - )) - .is_dir() - ); - let config = - std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("config should exist"); - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(config.contains("enabled = true")); - - let marker_contents = std::fs::read_to_string(marker_path).expect("marker should be readable"); - assert_eq!(marker_contents, "ok\n"); -} diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins_manager_tests.rs similarity index 84% rename from codex-rs/core/src/plugins/manager_tests.rs rename to codex-rs/core/src/plugins_manager_tests.rs index 73a77e69cb..a5a91f30df 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins_manager_tests.rs @@ -1,4 +1,3 @@ -use super::*; use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::plugins::LoadedPlugin; @@ -8,6 +7,7 @@ use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA; use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha; use crate::plugins::test_support::write_file; use crate::plugins::test_support::write_openai_curated_marketplace; +use crate::plugins::*; use codex_app_server_protocol::ConfigLayerSource; use codex_config::AppToolApproval; use codex_config::ConfigLayerEntry; @@ -17,16 +17,31 @@ use codex_config::ConfigRequirementsToml; use codex_config::McpServerConfig; use codex_config::McpServerToolConfig; use codex_config::types::McpServerTransportConfig; +use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core_plugins::installed_marketplaces::marketplace_install_root; +use codex_core_plugins::loader::configured_curated_plugin_ids_from_codex_home; use codex_core_plugins::loader::load_plugins_from_layer_stack; +use codex_core_plugins::loader::plugin_telemetry_metadata_from_root; +use codex_core_plugins::loader::refresh_curated_plugin_cache; use codex_core_plugins::loader::refresh_non_curated_plugin_cache; use codex_core_plugins::loader::refresh_non_curated_plugin_cache_force_reinstall; +use codex_core_plugins::manifest::PluginManifestInterface; +use codex_core_plugins::marketplace::MarketplaceError; +use codex_core_plugins::marketplace::MarketplacePluginAuthPolicy; use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy; +use codex_core_plugins::marketplace::MarketplacePluginPolicy; +use codex_core_plugins::marketplace::MarketplacePluginSource; +use codex_core_plugins::remote_legacy::RemotePluginFetchError; use codex_core_plugins::startup_sync::curated_plugins_repo_path; +use codex_core_plugins::store::PluginStore; +use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::protocol::Product; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::test_support::PathBufExt; use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::collections::HashSet; use std::fs; use std::path::Path; use tempfile::TempDir; @@ -136,7 +151,7 @@ async fn load_plugins_from_config(config_toml: &str, codex_home: &Path) -> Plugi write_file(&codex_home.join(CONFIG_TOML_FILE), config_toml); let config = load_config(codex_home, codex_home).await; PluginsManager::new(codex_home.to_path_buf()) - .plugins_for_config(&config) + .plugins_for_test_config(&config) .await } @@ -149,6 +164,83 @@ async fn load_config(codex_home: &Path, cwd: &Path) -> crate::config::Config { .expect("config should load") } +#[allow(async_fn_in_trait)] +trait PluginsManagerTestConfigExt { + async fn plugins_for_test_config(&self, config: &crate::config::Config) -> PluginLoadOutcome; + + fn list_marketplaces_for_test_config( + &self, + config: &crate::config::Config, + additional_roots: &[AbsolutePathBuf], + ) -> Result; + + async fn read_plugin_for_test_config( + &self, + config: &crate::config::Config, + request: &PluginReadRequest, + ) -> Result; + + async fn featured_plugin_ids_for_test_config( + &self, + config: &crate::config::Config, + auth: Option<&CodexAuth>, + ) -> Result, RemotePluginFetchError>; +} + +impl PluginsManagerTestConfigExt for PluginsManager { + async fn plugins_for_test_config(&self, config: &crate::config::Config) -> PluginLoadOutcome { + PluginsManager::plugins_for_config( + self, + &config.config_layer_stack, + config.features.enabled(Feature::Plugins), + config.features.enabled(Feature::RemotePlugin), + config.features.enabled(Feature::PluginHooks), + ) + .await + } + + fn list_marketplaces_for_test_config( + &self, + config: &crate::config::Config, + additional_roots: &[AbsolutePathBuf], + ) -> Result { + PluginsManager::list_marketplaces_for_config( + self, + &config.config_layer_stack, + config.features.enabled(Feature::Plugins), + additional_roots, + ) + } + + async fn read_plugin_for_test_config( + &self, + config: &crate::config::Config, + request: &PluginReadRequest, + ) -> Result { + PluginsManager::read_plugin_for_config( + self, + &config.config_layer_stack, + config.features.enabled(Feature::Plugins), + request, + ) + .await + } + + async fn featured_plugin_ids_for_test_config( + &self, + config: &crate::config::Config, + auth: Option<&CodexAuth>, + ) -> Result, RemotePluginFetchError> { + PluginsManager::featured_plugin_ids( + self, + config.features.enabled(Feature::Plugins), + &config.chatgpt_base_url, + auth, + ) + .await + } +} + #[tokio::test] async fn load_plugins_loads_default_skills_and_mcp_servers() { let codex_home = TempDir::new().unwrap(); @@ -359,7 +451,7 @@ remote_plugin = true }, ]); - let outcome = manager.plugins_for_config(&config).await; + let outcome = manager.plugins_for_test_config(&config).await; assert_eq!( outcome.effective_skill_roots(), vec![AbsolutePathBuf::try_from(plugin_base.join("local/skills")).unwrap()] @@ -390,7 +482,7 @@ remote_plugin = true }, ]); - let outcome = manager.plugins_for_config(&config).await; + let outcome = manager.plugins_for_test_config(&config).await; assert_eq!(outcome, PluginLoadOutcome::default()); } @@ -1077,14 +1169,14 @@ async fn load_plugins_returns_empty_when_feature_disabled() { let config = load_config(codex_home.path(), codex_home.path()).await; let outcome = PluginsManager::new(codex_home.path().to_path_buf()) - .plugins_for_config(&config) + .plugins_for_test_config(&config) .await; assert_eq!(outcome, PluginLoadOutcome::default()); } #[tokio::test] -async fn plugins_for_config_reloads_when_plugin_hooks_enablement_changes() { +async fn plugins_for_test_config_reloads_when_plugin_hooks_enablement_changes() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() @@ -1118,7 +1210,7 @@ async fn plugins_for_config_reloads_when_plugin_hooks_enablement_changes() { ); let config_without_plugin_hooks = load_config(codex_home.path(), codex_home.path()).await; let without_plugin_hooks = manager - .plugins_for_config(&config_without_plugin_hooks) + .plugins_for_test_config(&config_without_plugin_hooks) .await; assert!( without_plugin_hooks @@ -1134,7 +1226,9 @@ async fn plugins_for_config_reloads_when_plugin_hooks_enablement_changes() { ), ); let config_with_plugin_hooks = load_config(codex_home.path(), codex_home.path()).await; - let with_plugin_hooks = manager.plugins_for_config(&config_with_plugin_hooks).await; + let with_plugin_hooks = manager + .plugins_for_test_config(&config_with_plugin_hooks) + .await; assert_eq!(with_plugin_hooks.effective_plugin_hook_sources().len(), 1); } @@ -1179,7 +1273,7 @@ async fn load_plugins_rejects_invalid_plugin_keys() { } #[tokio::test] -async fn install_plugin_updates_config_with_relative_path_and_plugin_key() { +async fn install_plugin_materializes_relative_path_plugin() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).unwrap(); @@ -1226,10 +1320,6 @@ async fn install_plugin_updates_config_with_relative_path_and_plugin_key() { auth_policy: MarketplacePluginAuthPolicy::OnUse, } ); - - let config = fs::read_to_string(tmp.path().join("config.toml")).unwrap(); - assert!(config.contains(r#"[plugins."sample-plugin@debug"]"#)); - assert!(config.contains("enabled = true")); } #[tokio::test] @@ -1430,7 +1520,7 @@ async fn install_plugin_supports_relative_git_subdir_marketplace_sources() { } #[tokio::test] -async fn uninstall_plugin_removes_cache_and_config_entry() { +async fn uninstall_plugin_removes_cache() { let tmp = tempfile::tempdir().unwrap(); write_plugin( &tmp.path().join("plugins/cache/debug"), @@ -1462,8 +1552,6 @@ enabled = true .join("plugins/cache/debug/sample-plugin") .exists() ); - let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#)); } #[tokio::test] @@ -1520,7 +1608,10 @@ enabled = false let config = load_config(tmp.path(), &repo_root).await; let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) - .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) + .list_marketplaces_for_test_config( + &config, + &[AbsolutePathBuf::try_from(repo_root).unwrap()], + ) .unwrap() .marketplaces; @@ -1616,7 +1707,10 @@ enabled = true let config = load_config(tmp.path(), &repo_root).await; let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) - .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) + .list_marketplaces_for_test_config( + &config, + &[AbsolutePathBuf::try_from(repo_root).unwrap()], + ) .unwrap() .marketplaces; @@ -1664,7 +1758,10 @@ plugins = true let config = load_config(tmp.path(), &repo_root).await; let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) - .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) + .list_marketplaces_for_test_config( + &config, + &[AbsolutePathBuf::try_from(repo_root).unwrap()], + ) .unwrap() .marketplaces; @@ -1699,7 +1796,7 @@ plugins = true } #[tokio::test] -async fn read_plugin_for_config_returns_plugins_disabled_when_feature_disabled() { +async fn read_plugin_for_test_config_returns_plugins_disabled_when_feature_disabled() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).unwrap(); @@ -1734,7 +1831,7 @@ enabled = true let config = load_config(tmp.path(), &repo_root).await; let err = PluginsManager::new(tmp.path().to_path_buf()) - .read_plugin_for_config( + .read_plugin_for_test_config( &config, &PluginReadRequest { plugin_name: "enabled-plugin".to_string(), @@ -1748,7 +1845,7 @@ enabled = true } #[tokio::test] -async fn read_plugin_for_config_uses_user_layer_skill_settings_only() { +async fn read_plugin_for_test_config_uses_user_layer_skill_settings_only() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); let plugin_root = repo_root.join("enabled-plugin"); @@ -1796,7 +1893,7 @@ enabled = false let config = load_config(tmp.path(), &repo_root).await; let outcome = PluginsManager::new(tmp.path().to_path_buf()) - .read_plugin_for_config( + .read_plugin_for_test_config( &config, &PluginReadRequest { plugin_name: "enabled-plugin".to_string(), @@ -1813,7 +1910,7 @@ enabled = false } #[tokio::test] -async fn read_plugin_for_config_uninstalled_git_source_requires_install_without_cloning() { +async fn read_plugin_for_test_config_uninstalled_git_source_requires_install_without_cloning() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo"); @@ -1852,7 +1949,7 @@ plugins = true let config = load_config(tmp.path(), &repo_root).await; let outcome = PluginsManager::new(tmp.path().to_path_buf()) - .read_plugin_for_config( + .read_plugin_for_test_config( &config, &PluginReadRequest { plugin_name: "toolkit".to_string(), @@ -1888,7 +1985,7 @@ plugins = true } #[tokio::test] -async fn read_plugin_for_config_installed_git_source_reads_from_cache_without_cloning() { +async fn read_plugin_for_test_config_installed_git_source_reads_from_cache_without_cloning() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo"); @@ -1950,7 +2047,7 @@ enabled = true let config = load_config(tmp.path(), &repo_root).await; let outcome = PluginsManager::new(tmp.path().to_path_buf()) - .read_plugin_for_config( + .read_plugin_for_test_config( &config, &PluginReadRequest { plugin_name: "toolkit".to_string(), @@ -1991,25 +2088,6 @@ enabled = true ); } -#[tokio::test] -async fn sync_plugins_from_remote_returns_default_when_feature_disabled() { - let tmp = tempfile::tempdir().unwrap(); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = false -"#, - ); - - let config = load_config(tmp.path(), tmp.path()).await; - let outcome = PluginsManager::new(tmp.path().to_path_buf()) - .sync_plugins_from_remote(&config, /*auth*/ None, /*additive_only*/ false) - .await - .unwrap(); - - assert_eq!(outcome, RemotePluginSyncResult::default()); -} - #[tokio::test] async fn list_marketplaces_includes_curated_repo_marketplace() { let tmp = tempfile::tempdir().unwrap(); @@ -2048,7 +2126,7 @@ plugins = true let config = load_config(tmp.path(), tmp.path()).await; let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) - .list_marketplaces_for_config(&config, &[]) + .list_marketplaces_for_test_config(&config, &[]) .unwrap() .marketplaces; @@ -2125,7 +2203,7 @@ source = "/tmp/debug" .unwrap(); let config = load_config(tmp.path(), tmp.path()).await; let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) - .list_marketplaces_for_config(&config, &[]) + .list_marketplaces_for_test_config(&config, &[]) .unwrap() .marketplaces; @@ -2201,7 +2279,7 @@ source = "/tmp/debug" let config = load_config(tmp.path(), tmp.path()).await; let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) - .list_marketplaces_for_config(&config, &[]) + .list_marketplaces_for_test_config(&config, &[]) .unwrap() .marketplaces; @@ -2256,7 +2334,7 @@ plugins = true .unwrap(); let config = load_config(tmp.path(), tmp.path()).await; let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) - .list_marketplaces_for_config(&config, &[]) + .list_marketplaces_for_test_config(&config, &[]) .unwrap() .marketplaces; @@ -2335,7 +2413,7 @@ enabled = false let config = load_config(tmp.path(), &repo_a_root).await; let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) - .list_marketplaces_for_config( + .list_marketplaces_for_test_config( &config, &[ AbsolutePathBuf::try_from(repo_a_root).unwrap(), @@ -2445,7 +2523,10 @@ enabled = true let config = load_config(tmp.path(), &repo_root).await; let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) - .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) + .list_marketplaces_for_test_config( + &config, + &[AbsolutePathBuf::try_from(repo_root).unwrap()], + ) .unwrap() .marketplaces; @@ -2489,432 +2570,7 @@ enabled = true } #[tokio::test] -async fn sync_plugins_from_remote_reconciles_cache_and_config() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear", "gmail", "calendar"]); - write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "linear/local", - "linear", - ); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "gmail/local", - "gmail", - ); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "calendar/local", - "calendar", - ); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false - -[plugins."gmail@openai-curated"] -enabled = false - -[plugins."calendar@openai-curated"] -enabled = true -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .and(header("authorization", "Bearer Access Token")) - .and(header("chatgpt-account-id", "account_id")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}, - {"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let result = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - /*additive_only*/ false, - ) - .await - .unwrap(); - - assert_eq!( - result, - RemotePluginSyncResult { - installed_plugin_ids: Vec::new(), - enabled_plugin_ids: vec!["linear@openai-curated".to_string()], - disabled_plugin_ids: Vec::new(), - uninstalled_plugin_ids: vec![ - "gmail@openai-curated".to_string(), - "calendar@openai-curated".to_string(), - ], - } - ); - - assert!( - tmp.path() - .join("plugins/cache/openai-curated/linear/local") - .is_dir() - ); - assert!( - !tmp.path() - .join("plugins/cache/openai-curated/gmail") - .exists() - ); - assert!( - !tmp.path() - .join("plugins/cache/openai-curated/calendar") - .exists() - ); - - let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(config.contains("enabled = true")); - assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#)); - assert!(!config.contains(r#"[plugins."calendar@openai-curated"]"#)); - - let synced_config = load_config(tmp.path(), tmp.path()).await; - let curated_marketplace = manager - .list_marketplaces_for_config(&synced_config, &[]) - .unwrap() - .marketplaces - .into_iter() - .find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) - .unwrap(); - assert_eq!( - curated_marketplace - .plugins - .into_iter() - .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) - .collect::>(), - vec![ - ("linear@openai-curated".to_string(), true, true), - ("gmail@openai-curated".to_string(), false, false), - ("calendar@openai-curated".to_string(), false, false), - ] - ); -} - -#[tokio::test] -async fn sync_plugins_from_remote_additive_only_keeps_existing_plugins() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear", "gmail", "calendar"]); - write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "linear/local", - "linear", - ); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "gmail/local", - "gmail", - ); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "calendar/local", - "calendar", - ); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false - -[plugins."gmail@openai-curated"] -enabled = false - -[plugins."calendar@openai-curated"] -enabled = true -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .and(header("authorization", "Bearer Access Token")) - .and(header("chatgpt-account-id", "account_id")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}, - {"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let result = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - /*additive_only*/ true, - ) - .await - .unwrap(); - - assert_eq!( - result, - RemotePluginSyncResult { - installed_plugin_ids: Vec::new(), - enabled_plugin_ids: vec!["linear@openai-curated".to_string()], - disabled_plugin_ids: Vec::new(), - uninstalled_plugin_ids: Vec::new(), - } - ); - - assert!( - tmp.path() - .join("plugins/cache/openai-curated/linear/local") - .is_dir() - ); - assert!( - tmp.path() - .join("plugins/cache/openai-curated/gmail/local") - .is_dir() - ); - assert!( - tmp.path() - .join("plugins/cache/openai-curated/calendar/local") - .is_dir() - ); - - let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(config.contains(r#"[plugins."gmail@openai-curated"]"#)); - assert!(config.contains(r#"[plugins."calendar@openai-curated"]"#)); - assert!(config.contains("enabled = true")); -} - -#[tokio::test] -async fn sync_plugins_from_remote_ignores_unknown_remote_plugins() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear"]); - write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"plugin-one","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let result = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - /*additive_only*/ false, - ) - .await - .unwrap(); - - assert_eq!( - result, - RemotePluginSyncResult { - installed_plugin_ids: Vec::new(), - enabled_plugin_ids: Vec::new(), - disabled_plugin_ids: Vec::new(), - uninstalled_plugin_ids: vec!["linear@openai-curated".to_string()], - } - ); - let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(!config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!( - !tmp.path() - .join("plugins/cache/openai-curated/linear") - .exists() - ); -} - -#[tokio::test] -async fn sync_plugins_from_remote_keeps_existing_plugins_when_install_fails() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear", "gmail"]); - write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); - fs::remove_dir_all(curated_root.join("plugins/gmail")).unwrap(); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "linear/local", - "linear", - ); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let err = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - /*additive_only*/ false, - ) - .await - .unwrap_err(); - - assert!(matches!( - err, - PluginRemoteSyncError::Store(PluginStoreError::Invalid(ref message)) - if message.contains("plugin source path is not a directory") - )); - assert!( - tmp.path() - .join("plugins/cache/openai-curated/linear/local") - .is_dir() - ); - assert!( - !tmp.path() - .join("plugins/cache/openai-curated/gmail") - .exists() - ); - - let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#)); - assert!(config.contains("enabled = false")); -} - -#[tokio::test] -async fn sync_plugins_from_remote_uses_first_duplicate_local_plugin_entry() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); - fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap(); - fs::write( - curated_root.join(".agents/plugins/marketplace.json"), - r#"{ - "name": "openai-curated", - "plugins": [ - { - "name": "gmail", - "source": { - "source": "local", - "path": "./plugins/gmail-first" - } - }, - { - "name": "gmail", - "source": { - "source": "local", - "path": "./plugins/gmail-second" - } - } - ] -}"#, - ) - .unwrap(); - write_plugin(&curated_root, "plugins/gmail-first", "gmail"); - write_plugin(&curated_root, "plugins/gmail-second", "gmail"); - fs::write(curated_root.join("plugins/gmail-first/marker.txt"), "first").unwrap(); - fs::write( - curated_root.join("plugins/gmail-second/marker.txt"), - "second", - ) - .unwrap(); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let result = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - /*additive_only*/ false, - ) - .await - .unwrap(); - - assert_eq!( - result, - RemotePluginSyncResult { - installed_plugin_ids: vec!["gmail@openai-curated".to_string()], - enabled_plugin_ids: vec!["gmail@openai-curated".to_string()], - disabled_plugin_ids: Vec::new(), - uninstalled_plugin_ids: Vec::new(), - } - ); - assert_eq!( - fs::read_to_string(tmp.path().join(format!( - "plugins/cache/openai-curated/gmail/{TEST_CURATED_PLUGIN_CACHE_VERSION}/marker.txt" - ))) - .unwrap(), - "first" - ); -} - -#[tokio::test] -async fn featured_plugin_ids_for_config_uses_restriction_product_query_param() { +async fn featured_plugin_ids_for_test_config_uses_restriction_product_query_param() { let tmp = tempfile::tempdir().unwrap(); write_file( &tmp.path().join(CONFIG_TOML_FILE), @@ -2941,7 +2597,7 @@ plugins = true ); let featured_plugin_ids = manager - .featured_plugin_ids_for_config( + .featured_plugin_ids_for_test_config( &config, Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), ) @@ -2952,7 +2608,7 @@ plugins = true } #[tokio::test] -async fn featured_plugin_ids_for_config_defaults_query_param_to_codex() { +async fn featured_plugin_ids_for_test_config_defaults_query_param_to_codex() { let tmp = tempfile::tempdir().unwrap(); write_file( &tmp.path().join(CONFIG_TOML_FILE), @@ -2977,7 +2633,7 @@ plugins = true ); let featured_plugin_ids = manager - .featured_plugin_ids_for_config(&config, /*auth*/ None) + .featured_plugin_ids_for_test_config(&config, /*auth*/ None) .await .unwrap(); diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 624331ea62..bd5b641b4a 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -23,6 +23,7 @@ use codex_config::CloudRequirementsLoader; use codex_config::LoaderOverrides; use codex_config::loader::load_config_layers_state; use codex_exec_server::LOCAL_FS; +use codex_features::Feature; use codex_utils_absolute_path::AbsolutePathBuf; use crate::review_prompts::resolve_review_request; @@ -621,7 +622,12 @@ pub async fn list_skills(sess: &Session, sub_id: String, cwds: Vec, for } }; let effective_skill_roots = plugins_manager - .effective_skill_roots_for_layer_stack(&config_layer_stack, &config) + .effective_skill_roots_for_layer_stack( + &config_layer_stack, + config.features.enabled(Feature::Plugins), + config.features.enabled(Feature::RemotePlugin), + config.features.enabled(Feature::PluginHooks), + ) .await; let skills_input = crate::SkillsLoadInput::new( cwd_abs.clone(), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index e45db20848..67a1437281 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -475,7 +475,14 @@ impl Codex { let fs = environment .as_ref() .map(|environment| environment.get_filesystem()); - let plugin_outcome = plugins_manager.plugins_for_config(&config).await; + let plugin_outcome = plugins_manager + .plugins_for_config( + &config.config_layer_stack, + config.features.enabled(Feature::Plugins), + config.features.enabled(Feature::RemotePlugin), + config.features.enabled(Feature::PluginHooks), + ) + .await; let effective_skill_roots = plugin_outcome.effective_skill_roots(); let skills_input = skills_load_input_from_config(&config, effective_skill_roots); let loaded_skills = skills_manager.skills_for_config(&skills_input, fs).await; @@ -2666,7 +2673,12 @@ impl Session { let loaded_plugins = self .services .plugins_manager - .plugins_for_config(&turn_context.config) + .plugins_for_config( + &turn_context.config.config_layer_stack, + turn_context.config.features.enabled(Feature::Plugins), + turn_context.config.features.enabled(Feature::RemotePlugin), + turn_context.config.features.enabled(Feature::PluginHooks), + ) .await; if let Some(plugin_instructions) = AvailablePluginsInstructions::from_plugins(loaded_plugins.capability_summaries()) @@ -3362,7 +3374,14 @@ async fn build_hooks_for_config( let _ = hook_shell_argv.pop(); let plugin_hooks_enabled = config.features.enabled(Feature::PluginHooks); let (plugin_hook_sources, plugin_hook_load_warnings) = if plugin_hooks_enabled { - let plugin_outcome = plugins_manager.plugins_for_config(config).await; + let plugin_outcome = plugins_manager + .plugins_for_config( + &config.config_layer_stack, + config.features.enabled(Feature::Plugins), + config.features.enabled(Feature::RemotePlugin), + config.features.enabled(Feature::PluginHooks), + ) + .await; ( plugin_outcome.effective_plugin_hook_sources(), plugin_outcome.effective_plugin_hook_warnings(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 1afb28f35d..69efbb0cda 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3558,7 +3558,12 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let plugin_outcome = services .plugins_manager - .plugins_for_config(&per_turn_config) + .plugins_for_config( + &per_turn_config.config_layer_stack, + per_turn_config.features.enabled(Feature::Plugins), + per_turn_config.features.enabled(Feature::RemotePlugin), + per_turn_config.features.enabled(Feature::PluginHooks), + ) .await; let effective_skill_roots = plugin_outcome.effective_skill_roots(); let skills_input = @@ -4987,7 +4992,12 @@ where let plugin_outcome = services .plugins_manager - .plugins_for_config(&per_turn_config) + .plugins_for_config( + &per_turn_config.config_layer_stack, + per_turn_config.features.enabled(Feature::Plugins), + per_turn_config.features.enabled(Feature::RemotePlugin), + per_turn_config.features.enabled(Feature::PluginHooks), + ) .await; let effective_skill_roots = plugin_outcome.effective_skill_roots(); let skills_input = diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 07e4e30041..86666c1bf4 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -170,7 +170,12 @@ pub(crate) async fn run_turn( let loaded_plugins = sess .services .plugins_manager - .plugins_for_config(&turn_context.config) + .plugins_for_config( + &turn_context.config.config_layer_stack, + turn_context.config.features.enabled(Feature::Plugins), + turn_context.config.features.enabled(Feature::RemotePlugin), + turn_context.config.features.enabled(Feature::PluginHooks), + ) .await; // Structured plugin:// mentions are resolved from the current session's // enabled plugins, then converted into turn-scoped guidance below. @@ -1124,7 +1129,12 @@ pub(crate) async fn built_tools( let loaded_plugins = sess .services .plugins_manager - .plugins_for_config(&turn_context.config) + .plugins_for_config( + &turn_context.config.config_layer_stack, + turn_context.config.features.enabled(Feature::Plugins), + turn_context.config.features.enabled(Feature::RemotePlugin), + turn_context.config.features.enabled(Feature::PluginHooks), + ) .await; let mut effective_explicitly_enabled_connectors = explicitly_enabled_connectors.clone(); diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 09690f93de..dce384b239 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -696,7 +696,12 @@ impl Session { let plugin_outcome = self .services .plugins_manager - .plugins_for_config(&per_turn_config) + .plugins_for_config( + &per_turn_config.config_layer_stack, + per_turn_config.features.enabled(Feature::Plugins), + per_turn_config.features.enabled(Feature::RemotePlugin), + per_turn_config.features.enabled(Feature::PluginHooks), + ) .await; let effective_skill_roots = plugin_outcome.effective_skill_roots(); let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots); diff --git a/codex-rs/core/src/skills_watcher.rs b/codex-rs/core/src/skills_watcher.rs index 9473282b14..194a5d7949 100644 --- a/codex-rs/core/src/skills_watcher.rs +++ b/codex-rs/core/src/skills_watcher.rs @@ -8,6 +8,8 @@ use tokio::runtime::Handle; use tokio::sync::broadcast; use tracing::warn; +use codex_features::Feature; + use crate::SkillsManager; use crate::config::Config; use crate::file_watcher::FileWatcher; @@ -61,7 +63,14 @@ impl SkillsWatcher { plugins_manager: &PluginsManager, fs: Option>, ) -> WatchRegistration { - let plugin_outcome = plugins_manager.plugins_for_config(config).await; + let plugin_outcome = plugins_manager + .plugins_for_config( + &config.config_layer_stack, + config.features.enabled(Feature::Plugins), + config.features.enabled(Feature::RemotePlugin), + config.features.enabled(Feature::PluginHooks), + ) + .await; let effective_skill_roots = plugin_outcome.effective_skill_roots(); let skills_input = skills_load_input_from_config(config, effective_skill_roots); let roots = skills_manager diff --git a/codex-rs/core/src/tools/handlers/tool_suggest.rs b/codex-rs/core/src/tools/handlers/tool_suggest.rs index e0fa9156f4..4306dec4fb 100644 --- a/codex-rs/core/src/tools/handlers/tool_suggest.rs +++ b/codex-rs/core/src/tools/handlers/tool_suggest.rs @@ -2,6 +2,7 @@ use std::collections::HashSet; use codex_app_server_protocol::AppInfo; use codex_config::types::ToolSuggestDisabledTool; +use codex_features::Feature; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; @@ -317,7 +318,11 @@ fn verified_plugin_suggestion_completed( plugins_manager: &crate::plugins::PluginsManager, ) -> bool { plugins_manager - .list_marketplaces_for_config(config, &[]) + .list_marketplaces_for_config( + &config.config_layer_stack, + config.features.enabled(Feature::Plugins), + &[], + ) .ok() .into_iter() .flat_map(|outcome| outcome.marketplaces) diff --git a/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs b/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs index 2d482874ca..86cbbc98f5 100644 --- a/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs +++ b/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::config::edit::ConfigEditsBuilder; use crate::plugins::PluginInstallRequest; use crate::plugins::PluginsManager; use crate::plugins::test_support::load_plugins_config; @@ -48,6 +49,11 @@ async fn verified_plugin_suggestion_completed_requires_installed_plugin() { }) .await .expect("plugin should install"); + ConfigEditsBuilder::new(codex_home.path()) + .set_plugin_enabled("sample@openai-curated", /*enabled*/ true) + .apply() + .await + .expect("plugin config should persist"); let refreshed_config = load_plugins_config(codex_home.path()).await; assert!(verified_plugin_suggestion_completed( diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 559bdfd99c..63a4fd0657 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -233,7 +233,12 @@ impl App { tokio::spawn(async move { let plugins = PluginsManager::new(config.codex_home.to_path_buf()) - .plugins_for_config(&config) + .plugins_for_config( + &config.config_layer_stack, + config.features.enabled(Feature::Plugins), + config.features.enabled(Feature::RemotePlugin), + config.features.enabled(Feature::PluginHooks), + ) .await .capability_summaries() .to_vec();