From fcbff1ea068c0c459da7ddb6d64a3fbd4a2c28d7 Mon Sep 17 00:00:00 2001 From: Zhanshi Wang Date: Wed, 17 Jun 2026 17:01:28 -0700 Subject: [PATCH] Add plugin install extension backend --- codex-rs/Cargo.lock | 1 + codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/tools/mod.rs | 1 + .../core/src/tools/request_plugin_installs.rs | 441 ++++++++++++++++++ .../tools/request_plugin_installs_tests.rs | 262 +++++++++++ 5 files changed, 706 insertions(+) create mode 100644 codex-rs/core/src/tools/request_plugin_installs.rs create mode 100644 codex-rs/core/src/tools/request_plugin_installs_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b7e1b8f7ac..60daf56c8a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2641,6 +2641,7 @@ dependencies = [ "codex-network-proxy", "codex-otel", "codex-plugin", + "codex-plugin-installs-extension", "codex-prompts", "codex-protocol", "codex-response-debug-context", diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index d16e8d9cbe..96adcf2c65 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -52,6 +52,7 @@ codex-install-context = { workspace = true } codex-network-proxy = { workspace = true } codex-otel = { workspace = true } codex-plugin = { workspace = true } +codex-plugin-installs-extension = { workspace = true } codex-model-provider = { workspace = true } codex-protocol = { workspace = true } codex-response-debug-context = { workspace = true } diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 1bc9786217..57bcd1bb63 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod network_approval; pub(crate) mod orchestrator; pub(crate) mod parallel; pub(crate) mod registry; +pub(crate) mod request_plugin_installs; pub(crate) mod router; pub(crate) mod runtimes; pub(crate) mod sandboxing; diff --git a/codex-rs/core/src/tools/request_plugin_installs.rs b/codex-rs/core/src/tools/request_plugin_installs.rs new file mode 100644 index 0000000000..55b9f60141 --- /dev/null +++ b/codex-rs/core/src/tools/request_plugin_installs.rs @@ -0,0 +1,441 @@ +use std::collections::HashSet; +use std::sync::Arc; +use std::sync::Weak; + +use codex_app_server_protocol::AppInfo; +use codex_config::types::ToolSuggestDisabledTool; +use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; +use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_rmcp_client::ElicitationAction; +use codex_rmcp_client::ElicitationResponse; +use codex_tools::DiscoverableTool; +use codex_tools::DiscoverableToolType; +use rmcp::model::RequestId; +use serde::Deserialize; +use serde_json::Value; +use tracing::warn; + +use crate::config::edit::ConfigEdit; +use crate::config::edit::ConfigEditsBuilder; +use crate::connectors; +use crate::function_tool::FunctionCallError; +use codex_plugin_installs_extension::REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE; +use codex_plugin_installs_extension::REQUEST_PLUGIN_INSTALL_PERSIST_KEY; +use codex_plugin_installs_extension::REQUEST_PLUGIN_INSTALLS_TOOL_NAME; +use codex_plugin_installs_extension::RequestPluginInstallEntryResult; +use codex_plugin_installs_extension::RequestPluginInstallInstalledEntry; +use codex_plugin_installs_extension::RequestPluginInstallResolvedPickerEntry; +use codex_plugin_installs_extension::RequestPluginInstallsBackend; +use codex_plugin_installs_extension::RequestPluginInstallsBackendFuture; +use codex_plugin_installs_extension::RequestPluginInstallsRequest; +use codex_plugin_installs_extension::RequestPluginInstallsResult; +use codex_plugin_installs_extension::all_requested_connectors_picked_up; +use codex_plugin_installs_extension::build_request_plugin_installs_elicitation_request; +use codex_plugin_installs_extension::request_plugin_install_picker_completed; +use codex_plugin_installs_extension::verified_connector_install_completed; + +pub(crate) struct CoreRequestPluginInstallsBackend { + session: Weak, +} + +impl CoreRequestPluginInstallsBackend { + pub(crate) fn new(session: &Arc) -> Self { + Self { + session: Arc::downgrade(session), + } + } + + async fn handle_request( + &self, + request: RequestPluginInstallsRequest, + ) -> Result { + let RequestPluginInstallsRequest { + call_id, + turn_id, + args, + resolved_entries, + } = request; + let session = self.session.upgrade().ok_or_else(|| { + FunctionCallError::Fatal("plugin install session is no longer available".to_string()) + })?; + let turn = session + .turn_context_for_sub_id(&turn_id) + .await + .ok_or_else(|| { + FunctionCallError::Fatal("plugin install turn is no longer active".to_string()) + })?; + let action_type = args.action_type; + let requested_entries = requested_picker_install_entries(&resolved_entries); + + let request_id = RequestId::String(format!("request_plugin_installs_{call_id}").into()); + let params = build_request_plugin_installs_elicitation_request( + CODEX_APPS_MCP_SERVER_NAME, + session.thread_id.to_string(), + turn.sub_id.clone(), + &args, + &resolved_entries, + ); + drop(resolved_entries); + + let elicitation = session + .request_mcp_server_elicitation(turn.as_ref(), request_id, params) + .await; + let response = elicitation.response; + if let Some(response) = response.as_ref() { + maybe_persist_disabled_install_requests(&session, &turn, &requested_entries, response) + .await; + } + let user_confirmed = response + .as_ref() + .is_some_and(|response| response.action == ElicitationAction::Accept); + let response_installed_entries = + request_plugin_install_picker_response_entries(response.as_ref()); + + let auth = session.services.auth_manager.auth().await; + let entries = if user_confirmed { + verify_request_plugin_install_picker_completed( + &session, + &turn, + &requested_entries, + &response_installed_entries, + auth.as_ref(), + ) + .await + } else { + requested_entries + .iter() + .map(|entry| entry.result(/*completed*/ false)) + .collect() + }; + let completed_connector_ids = requested_entries + .iter() + .zip(entries.iter()) + .filter_map(|(requested_entry, entry)| { + if !entry.completed { + return None; + } + match &requested_entry.tool { + DiscoverableTool::Connector(connector) => Some(connector.id.clone()), + DiscoverableTool::Plugin(_) => None, + } + }) + .collect::>(); + if !completed_connector_ids.is_empty() { + session + .merge_connector_selection(completed_connector_ids) + .await; + } + + if elicitation.sent { + let response_action = match response.as_ref().map(|response| &response.action) { + Some(ElicitationAction::Accept) => "accept", + Some(ElicitationAction::Decline) => "decline", + Some(ElicitationAction::Cancel) => "cancel", + None => "unavailable", + }; + for entry in &entries { + turn.session_telemetry.record_plugin_install_suggestion( + tool_type_str(entry.tool_type), + entry.tool_id.as_str(), + entry.tool_name.as_str(), + response_action, + user_confirmed, + entry.completed, + ); + } + } + + let completed = user_confirmed && request_plugin_install_picker_completed(&entries); + Ok(RequestPluginInstallsResult { + completed, + user_confirmed, + action_type, + entries, + }) + } +} + +impl RequestPluginInstallsBackend for CoreRequestPluginInstallsBackend { + fn execute( + &self, + request: RequestPluginInstallsRequest, + ) -> RequestPluginInstallsBackendFuture<'_> { + Box::pin(self.handle_request(request)) + } +} + +#[derive(Clone)] +struct RequestedPickerInstallEntry { + tool: DiscoverableTool, +} + +impl RequestedPickerInstallEntry { + fn result(&self, completed: bool) -> RequestPluginInstallEntryResult { + RequestPluginInstallEntryResult { + tool_type: self.tool.tool_type(), + tool_id: self.tool.id().to_string(), + tool_name: self.tool.name().to_string(), + completed, + } + } +} + +#[derive(Debug, Deserialize)] +struct RequestPluginInstallPickerResponseContent { + #[serde(default)] + installed_entries: Vec, +} + +fn requested_picker_install_entries( + resolved_entries: &[RequestPluginInstallResolvedPickerEntry], +) -> Vec { + resolved_entries + .iter() + .map(|entry| RequestedPickerInstallEntry { + tool: entry.tool.clone(), + }) + .collect() +} + +fn request_plugin_install_picker_response_entries( + response: Option<&ElicitationResponse>, +) -> Vec { + let Some(content) = response.and_then(|response| response.content.as_ref()) else { + return Vec::new(); + }; + + match serde_json::from_value::(content.clone()) { + Ok(content) => content.installed_entries, + Err(err) => { + warn!("failed to parse request_plugin_installs picker response content: {err:#}"); + Vec::new() + } + } +} + +async fn maybe_persist_disabled_install_requests( + session: &crate::session::session::Session, + turn: &crate::session::turn_context::TurnContext, + requested_entries: &[RequestedPickerInstallEntry], + response: &ElicitationResponse, +) { + if !request_plugin_install_response_requests_persistent_disable(response) { + return; + } + + for entry in requested_entries { + if let Err(err) = + persist_disabled_install_request(&turn.config.codex_home, &entry.tool).await + { + warn!( + error = %err, + tool_id = entry.tool.id(), + "failed to persist disabled tool suggestion" + ); + return; + } + } + + session.reload_user_config_layer().await; +} + +pub(super) fn request_plugin_install_response_requests_persistent_disable( + response: &ElicitationResponse, +) -> bool { + if response.action != ElicitationAction::Decline { + return false; + } + + response + .meta + .as_ref() + .and_then(Value::as_object) + .and_then(|meta| meta.get(REQUEST_PLUGIN_INSTALL_PERSIST_KEY)) + .and_then(Value::as_str) + == Some(REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE) +} + +pub(super) async fn persist_disabled_install_request( + codex_home: &codex_utils_absolute_path::AbsolutePathBuf, + tool: &DiscoverableTool, +) -> anyhow::Result<()> { + ConfigEditsBuilder::new(codex_home) + .with_edits([ConfigEdit::AddToolSuggestDisabledTool( + disabled_install_request(tool), + )]) + .apply() + .await +} + +fn disabled_install_request(tool: &DiscoverableTool) -> ToolSuggestDisabledTool { + match tool { + DiscoverableTool::Connector(connector) => { + ToolSuggestDisabledTool::connector(connector.id.as_str()) + } + DiscoverableTool::Plugin(plugin) => ToolSuggestDisabledTool::plugin(plugin.id.as_str()), + } +} + +async fn verify_request_plugin_install_picker_completed( + session: &crate::session::session::Session, + turn: &crate::session::turn_context::TurnContext, + requested_entries: &[RequestedPickerInstallEntry], + response_installed_entries: &[RequestPluginInstallInstalledEntry], + auth: Option<&codex_login::CodexAuth>, +) -> Vec { + let mut expected_connector_ids = HashSet::new(); + let mut has_local_plugin_entry = false; + for entry in requested_entries { + match &entry.tool { + DiscoverableTool::Connector(connector) => { + expected_connector_ids.insert(connector.id.clone()); + } + DiscoverableTool::Plugin(plugin) => { + expected_connector_ids.extend(plugin.app_connector_ids.iter().cloned()); + if !is_remote_plugin_install_suggestion(&plugin.id) { + has_local_plugin_entry = true; + } + } + } + } + let expected_connector_ids = expected_connector_ids.into_iter().collect::>(); + let accessible_connectors = if expected_connector_ids.is_empty() { + Some(Vec::new()) + } else { + refresh_missing_requested_connectors( + session, + turn, + auth, + &expected_connector_ids, + REQUEST_PLUGIN_INSTALLS_TOOL_NAME, + ) + .await + }; + + let config = if has_local_plugin_entry { + session.reload_user_config_layer().await; + Some(session.get_config().await) + } else { + None + }; + + requested_entries + .iter() + .map(|entry| { + let app_reported_completed = + response_reports_picker_entry_completed(response_installed_entries, entry); + let locally_verified_completed = match &entry.tool { + DiscoverableTool::Connector(connector) => accessible_connectors + .as_ref() + .is_some_and(|accessible_connectors| { + verified_connector_install_completed( + connector.id.as_str(), + accessible_connectors, + ) + }), + DiscoverableTool::Plugin(plugin) => { + if is_remote_plugin_install_suggestion(&plugin.id) { + false + } else { + config.as_ref().is_some_and(|config| { + verified_plugin_install_completed( + plugin.id.as_str(), + config.as_ref(), + session.services.plugins_manager.as_ref(), + ) + }) + } + } + }; + entry.result(app_reported_completed || locally_verified_completed) + }) + .collect() +} + +fn response_reports_picker_entry_completed( + response_installed_entries: &[RequestPluginInstallInstalledEntry], + requested_entry: &RequestedPickerInstallEntry, +) -> bool { + match &requested_entry.tool { + DiscoverableTool::Connector(_) => false, + DiscoverableTool::Plugin(_) => response_installed_entries.iter().any(|installed_entry| { + installed_entry.tool_id == requested_entry.tool.id() + && installed_entry.tool_type == requested_entry.tool.tool_type() + }), + } +} + +pub(super) fn tool_type_str(tool_type: DiscoverableToolType) -> &'static str { + match tool_type { + DiscoverableToolType::Connector => "connector", + DiscoverableToolType::Plugin => "plugin", + } +} + +pub(super) fn is_remote_plugin_install_suggestion(plugin_id: &str) -> bool { + plugin_id + .rsplit_once('@') + .is_some_and(|(_, marketplace_name)| marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME) +} + +pub(super) async fn refresh_missing_requested_connectors( + session: &crate::session::session::Session, + turn: &crate::session::turn_context::TurnContext, + auth: Option<&codex_login::CodexAuth>, + expected_connector_ids: &[String], + tool_id: &str, +) -> Option> { + if expected_connector_ids.is_empty() { + return Some(Vec::new()); + } + + let manager = session.services.mcp_connection_manager.load_full(); + let mcp_tools = manager.list_all_tools().await; + let accessible_connectors = connectors::with_app_enabled_state( + connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + &turn.config, + ); + if all_requested_connectors_picked_up(expected_connector_ids, &accessible_connectors) { + return Some(accessible_connectors); + } + + match manager.hard_refresh_codex_apps_tools_cache().await { + Ok(mcp_tools) => { + let accessible_connectors = connectors::with_app_enabled_state( + connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + &turn.config, + ); + connectors::refresh_accessible_connectors_cache_from_mcp_tools( + &turn.config, + auth, + &mcp_tools, + ); + Some(accessible_connectors) + } + Err(err) => { + warn!( + "failed to refresh codex apps tools cache after plugin install request for {tool_id}: {err:#}" + ); + None + } + } +} + +pub(super) fn verified_plugin_install_completed( + tool_id: &str, + config: &crate::config::Config, + plugins_manager: &codex_core_plugins::PluginsManager, +) -> bool { + let plugins_input = config.plugins_config_input(); + plugins_manager + .list_marketplaces_for_config(&plugins_input, &[], /*include_openai_curated*/ true) + .ok() + .into_iter() + .flat_map(|outcome| outcome.marketplaces) + .flat_map(|marketplace| marketplace.plugins.into_iter()) + .any(|plugin| plugin.id == tool_id && plugin.installed) +} + +#[cfg(test)] +#[path = "request_plugin_installs_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/request_plugin_installs_tests.rs b/codex-rs/core/src/tools/request_plugin_installs_tests.rs new file mode 100644 index 0000000000..ec8e963b39 --- /dev/null +++ b/codex-rs/core/src/tools/request_plugin_installs_tests.rs @@ -0,0 +1,262 @@ +use super::*; +use crate::plugins::test_support::load_plugins_config; +use crate::plugins::test_support::write_curated_plugin_sha; +use crate::plugins::test_support::write_openai_curated_marketplace; +use crate::plugins::test_support::write_plugins_feature_config; +use codex_config::CONFIG_TOML_FILE; +use codex_config::config_toml::ConfigToml; +use codex_config::types::ToolSuggestConfig; +use codex_config::types::ToolSuggestDisabledTool; +use codex_config::types::ToolSuggestDiscoverable; +use codex_config::types::ToolSuggestDiscoverableType; +use codex_core_plugins::PluginInstallRequest; +use codex_core_plugins::PluginsManager; +use codex_core_plugins::startup_sync::curated_plugins_repo_path; +use codex_rmcp_client::ElicitationResponse; +use codex_tools::DiscoverablePluginInfo; +use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::PathExt; +use pretty_assertions::assert_eq; +use rmcp::model::ElicitationAction; +use serde_json::json; +use tempfile::tempdir; + +#[tokio::test] +async fn verified_plugin_install_completed_requires_installed_plugin() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["sample"]); + write_curated_plugin_sha(codex_home.path()); + write_plugins_feature_config(codex_home.path()); + + let config = load_plugins_config(codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + + assert!(!verified_plugin_install_completed( + "sample@openai-curated", + &config, + &plugins_manager, + )); + + plugins_manager + .install_plugin(PluginInstallRequest { + plugin_name: "sample".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + curated_root.join(".agents/plugins/marketplace.json"), + ) + .expect("marketplace path"), + }) + .await + .expect("plugin should install"); + + let refreshed_config = load_plugins_config(codex_home.path()).await; + assert!(verified_plugin_install_completed( + "sample@openai-curated", + &refreshed_config, + &plugins_manager, + )); +} + +#[test] +fn remote_plugin_install_suggestions_skip_core_installed_verification() { + assert!(is_remote_plugin_install_suggestion( + "snowflake@openai-curated-remote" + )); + assert!(!is_remote_plugin_install_suggestion( + "snowflake@openai-curated" + )); + assert!(!is_remote_plugin_install_suggestion("Plugin_123")); +} + +#[test] +fn request_plugin_install_response_persists_only_decline_always_mode() { + assert!(request_plugin_install_response_requests_persistent_disable( + &ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: Some(json!({ + REQUEST_PLUGIN_INSTALL_PERSIST_KEY: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE + })), + } + )); + assert!( + !request_plugin_install_response_requests_persistent_disable(&ElicitationResponse { + action: ElicitationAction::Accept, + content: None, + meta: Some(json!({ + REQUEST_PLUGIN_INSTALL_PERSIST_KEY: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE + })), + }) + ); + assert!( + !request_plugin_install_response_requests_persistent_disable(&ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: Some(json!({ REQUEST_PLUGIN_INSTALL_PERSIST_KEY: "session" })), + }) + ); + assert!( + !request_plugin_install_response_requests_persistent_disable(&ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: None, + }) + ); +} + +#[test] +fn picker_response_acknowledgements_apply_only_to_plugins() { + let connector = RequestedPickerInstallEntry { + tool: connector_tool("connector_calendar", "Google Calendar"), + }; + let plugin = RequestedPickerInstallEntry { + tool: DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo { + id: "slack@openai-curated-remote".to_string(), + remote_plugin_id: Some("plugins~Plugin_slack".to_string()), + name: "Slack".to_string(), + description: None, + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + })), + }; + let installed_entries = vec![ + RequestPluginInstallInstalledEntry { + tool_id: "connector_calendar".to_string(), + tool_type: DiscoverableToolType::Connector, + }, + RequestPluginInstallInstalledEntry { + tool_id: "slack@openai-curated-remote".to_string(), + tool_type: DiscoverableToolType::Plugin, + }, + ]; + + assert_eq!( + ( + response_reports_picker_entry_completed(&installed_entries, &connector), + response_reports_picker_entry_completed(&installed_entries, &plugin), + ), + (false, true), + ); +} + +#[tokio::test] +async fn persist_disabled_install_request_writes_connector_config() { + let codex_home = tempdir().expect("tempdir should succeed"); + let tool = connector_tool("connector_calendar", "Google Calendar"); + + persist_disabled_install_request(&codex_home.path().abs(), &tool) + .await + .expect("persist connector disable"); + + let contents = + std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).expect("read config"); + let parsed: ConfigToml = toml::from_str(&contents).expect("parse config"); + assert_eq!( + parsed.tool_suggest, + Some(ToolSuggestConfig { + discoverables: Vec::new(), + disabled_tools: vec![ToolSuggestDisabledTool::connector("connector_calendar")], + }) + ); +} + +#[tokio::test] +async fn persist_disabled_install_request_writes_plugin_config() { + let codex_home = tempdir().expect("tempdir should succeed"); + let tool = DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo { + id: "slack@openai-curated".to_string(), + remote_plugin_id: None, + name: "Slack".to_string(), + description: None, + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + })); + + persist_disabled_install_request(&codex_home.path().abs(), &tool) + .await + .expect("persist plugin disable"); + + let contents = + std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).expect("read config"); + let parsed: ConfigToml = toml::from_str(&contents).expect("parse config"); + assert_eq!( + parsed.tool_suggest, + Some(ToolSuggestConfig { + discoverables: Vec::new(), + disabled_tools: vec![ToolSuggestDisabledTool::plugin("slack@openai-curated")], + }) + ); +} + +#[tokio::test] +async fn persist_disabled_install_request_dedupes_existing_disabled_tools() { + let codex_home = tempdir().expect("tempdir should succeed"); + let tool = connector_tool("connector_calendar", "Google Calendar"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +[tool_suggest] +discoverables = [ + { type = "plugin", id = "sample@openai-curated" } +] + +[[tool_suggest.disabled_tools]] +type = "connector" +id = " connector_calendar " + +[[tool_suggest.disabled_tools]] +type = "connector" +id = "connector_calendar" + +[[tool_suggest.disabled_tools]] +type = "connector" +id = " " + +[[tool_suggest.disabled_tools]] +type = "plugin" +id = "slack@openai-curated" +"#, + ) + .expect("write config"); + + persist_disabled_install_request(&codex_home.path().abs(), &tool) + .await + .expect("persist connector disable"); + + let contents = + std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).expect("read config"); + let parsed: ConfigToml = toml::from_str(&contents).expect("parse config"); + assert_eq!( + parsed.tool_suggest, + Some(ToolSuggestConfig { + discoverables: vec![ToolSuggestDiscoverable { + kind: ToolSuggestDiscoverableType::Plugin, + id: "sample@openai-curated".to_string(), + }], + disabled_tools: vec![ + ToolSuggestDisabledTool::connector("connector_calendar"), + ToolSuggestDisabledTool::plugin("slack@openai-curated"), + ], + }) + ); +} + +fn connector_tool(id: &str, name: &str) -> DiscoverableTool { + DiscoverableTool::Connector(Box::new(AppInfo { + id: id.to_string(), + name: name.to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + })) +}