Codex Handbook
core/src/tools/handlers/request_plugin_install.rs 372 lines
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;