use std::collections::HashSet;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::DiscoverableToolAction;use codex_tools::DiscoverableToolType;use codex_tools::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME;use codex_tools::REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE;use codex_tools::REQUEST_PLUGIN_INSTALL_PERSIST_KEY;use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME;use codex_tools::RequestPluginInstallArgs;use codex_tools::RequestPluginInstallResult;use codex_tools::ToolName;use codex_tools::ToolSpec;use codex_tools::all_requested_connectors_picked_up;use codex_tools::build_request_plugin_install_elicitation_request;use codex_tools::filter_request_plugin_install_discoverable_tools_for_client;use codex_tools::verified_connector_install_completed;use rmcp::model::RequestId;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 crate::tools::context::FunctionToolOutput;use crate::tools::context::ToolInvocation;use crate::tools::context::ToolPayload;use crate::tools::context::boxed_tool_output;use crate::tools::handlers::parse_arguments;use crate::tools::handlers::request_plugin_install_spec::create_request_plugin_install_tool;use crate::tools::registry::CoreToolRuntime;use crate::tools::registry::ToolExecutor;pub struct RequestPluginInstallHandler { discoverable_tools: Vec<DiscoverableTool>,}impl RequestPluginInstallHandler { pub(crate) fn new(discoverable_tools: Vec<DiscoverableTool>) -> Self { Self { discoverable_tools } }}impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler { fn tool_name(&self) -> ToolName { ToolName::plain(REQUEST_PLUGIN_INSTALL_TOOL_NAME) } fn spec(&self) -> ToolSpec { create_request_plugin_install_tool() } fn supports_parallel_tool_calls(&self) -> bool { true } fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> { Box::pin(self.handle_call(invocation)) }}impl RequestPluginInstallHandler { async fn handle_call( &self, invocation: ToolInvocation, ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> { let ToolInvocation { payload, session, turn, call_id, .. } = invocation; let arguments = match payload { ToolPayload::Function { arguments } => arguments, _ => { return Err(FunctionCallError::Fatal(format!( "{REQUEST_PLUGIN_INSTALL_TOOL_NAME} handler received unsupported payload" ))); } }; let args: RequestPluginInstallArgs = parse_arguments(&arguments)?; let suggest_reason = args.suggest_reason.trim(); if suggest_reason.is_empty() { return Err(FunctionCallError::RespondToModel( "suggest_reason must not be empty".to_string(), )); } if args.action_type != DiscoverableToolAction::Install { return Err(FunctionCallError::RespondToModel( "plugin install requests currently support only action_type=\"install\"" .to_string(), )); } if args.tool_type == DiscoverableToolType::Plugin && turn.app_server_client_name.as_deref() == Some("codex-tui") { return Err(FunctionCallError::RespondToModel( "plugin install requests are not available in codex-tui yet".to_string(), )); } let discoverable_tools = filter_request_plugin_install_discoverable_tools_for_client( self.discoverable_tools.clone(), turn.app_server_client_name.as_deref(), ); let tool = discoverable_tools .into_iter() .find(|tool| tool.tool_type() == args.tool_type && tool.id() == args.tool_id) .ok_or_else(|| { FunctionCallError::RespondToModel(format!( "tool_id must match one of the discoverable tools returned by {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}" )) })?; let request_id = RequestId::String(format!("request_plugin_install_{call_id}").into()); let params = build_request_plugin_install_elicitation_request( CODEX_APPS_MCP_SERVER_NAME, session.thread_id.to_string(), turn.sub_id.clone(), &args, suggest_reason, &tool, ); 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_request(&session, &turn, &tool, response).await; } let user_confirmed = response .as_ref() .is_some_and(|response| response.action == ElicitationAction::Accept); let auth = session.services.auth_manager.auth().await; let completed = if user_confirmed { verify_request_plugin_install_completed(&session, &turn, &tool, auth.as_ref()).await } else { false }; if completed && let DiscoverableTool::Connector(connector) = &tool { session .merge_connector_selection(HashSet::from([connector.id.clone()])) .await; } if elicitation.sent { let tool_type = match args.tool_type { DiscoverableToolType::Connector => "connector", DiscoverableToolType::Plugin => "plugin", }; 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", }; turn.session_telemetry.record_plugin_install_suggestion( tool_type, tool.id(), tool.name(), response_action, user_confirmed, completed, ); } let content = serde_json::to_string(&RequestPluginInstallResult { completed, user_confirmed, tool_type: args.tool_type, action_type: args.action_type, tool_id: tool.id().to_string(), tool_name: tool.name().to_string(), suggest_reason: suggest_reason.to_string(), }) .map_err(|err| { FunctionCallError::Fatal(format!( "failed to serialize {REQUEST_PLUGIN_INSTALL_TOOL_NAME} response: {err}" )) })?; Ok(boxed_tool_output(FunctionToolOutput::from_text( content, Some(true), ))) }}impl CoreToolRuntime for RequestPluginInstallHandler {}async fn maybe_persist_disabled_install_request( session: &crate::session::session::Session, turn: &crate::session::turn_context::TurnContext, tool: &DiscoverableTool, response: &ElicitationResponse,) { if !request_plugin_install_response_requests_persistent_disable(response) { return; } if let Err(err) = persist_disabled_install_request(&turn.config.codex_home, tool).await { warn!( error = %err, tool_id = tool.id(), "failed to persist disabled tool suggestion" ); return; } session.reload_user_config_layer().await;}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)}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_completed( session: &crate::session::session::Session, turn: &crate::session::turn_context::TurnContext, tool: &DiscoverableTool, auth: Option<&codex_login::CodexAuth>,) -> bool { match tool { DiscoverableTool::Connector(connector) => refresh_missing_requested_connectors( session, turn, auth, std::slice::from_ref(&connector.id), connector.id.as_str(), ) .await .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) { return true; } session.reload_user_config_layer().await; let config = session.get_config().await; let completed = verified_plugin_install_completed( plugin.id.as_str(), config.as_ref(), session.services.plugins_manager.as_ref(), ); let _ = refresh_missing_requested_connectors( session, turn, auth, &plugin.app_connector_ids, plugin.id.as_str(), ) .await; completed } }}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)}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<Vec<AppInfo>> { 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 } }}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_install_tests.rs"]mod tests;