diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs deleted file mode 100644 index 8047213140..0000000000 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ /dev/null @@ -1,420 +0,0 @@ -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::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 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; -use crate::tools::router::ToolSuggestPresentation; - -#[derive(Debug, Deserialize, PartialEq, Eq)] -struct RecommendedPluginInstallArgs { - #[serde(alias = "tool_id")] - plugin_id: String, - suggest_reason: String, -} - -pub struct RequestPluginInstallHandler { - discoverable_tools: Vec, - presentation: ToolSuggestPresentation, -} - -impl RequestPluginInstallHandler { - pub(crate) fn new( - discoverable_tools: Vec, - presentation: ToolSuggestPresentation, - ) -> Self { - Self { - discoverable_tools, - presentation, - } - } -} - -impl ToolExecutor for RequestPluginInstallHandler { - fn tool_name(&self) -> ToolName { - ToolName::plain(REQUEST_PLUGIN_INSTALL_TOOL_NAME) - } - - fn spec(&self) -> ToolSpec { - create_request_plugin_install_tool(self.presentation) - } - - 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, 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 (requested_tool_id, requested_tool_type, suggest_reason) = match self.presentation { - ToolSuggestPresentation::ListTool => { - let args: RequestPluginInstallArgs = parse_arguments(&arguments)?; - if args.action_type != DiscoverableToolAction::Install { - return Err(FunctionCallError::RespondToModel( - "plugin install requests currently support only action_type=\"install\"" - .to_string(), - )); - } - (args.tool_id, Some(args.tool_type), args.suggest_reason) - } - ToolSuggestPresentation::RecommendationContext => { - let args: RecommendedPluginInstallArgs = parse_arguments(&arguments)?; - (args.plugin_id, None, args.suggest_reason) - } - }; - let suggest_reason = suggest_reason.trim(); - if suggest_reason.is_empty() { - return Err(FunctionCallError::RespondToModel( - "suggest_reason must not be empty".to_string(), - )); - } - if (requested_tool_type == Some(DiscoverableToolType::Plugin) - || self.presentation == ToolSuggestPresentation::RecommendationContext) - && 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.id() == requested_tool_id - && match self.presentation { - ToolSuggestPresentation::ListTool => { - Some(tool.tool_type()) == requested_tool_type - } - ToolSuggestPresentation::RecommendationContext => { - matches!(tool, DiscoverableTool::Plugin(_)) - } - } - }) - .ok_or_else(|| { - let (argument_name, source) = match self.presentation { - ToolSuggestPresentation::ListTool => ( - "tool_id", - format!( - "the discoverable tools returned by {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}" - ), - ), - ToolSuggestPresentation::RecommendationContext => ( - "plugin_id", - "the entries in the list".to_string(), - ), - }; - FunctionCallError::RespondToModel(format!( - "{argument_name} must match one of {source}" - )) - })?; - let tool_type = tool.tool_type(); - - 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(), - 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 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, - action_type: DiscoverableToolAction::Install, - 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> { - 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; diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs b/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs deleted file mode 100644 index e37e223832..0000000000 --- a/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs +++ /dev/null @@ -1,177 +0,0 @@ -use codex_tools::JsonSchema; -use codex_tools::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME; -use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME; -use codex_tools::ResponsesApiTool; -use codex_tools::ToolSpec; -use std::collections::BTreeMap; - -use crate::tools::router::ToolSuggestPresentation; - -pub(crate) fn create_request_plugin_install_tool( - presentation: ToolSuggestPresentation, -) -> ToolSpec { - let (properties, required, description) = match presentation { - ToolSuggestPresentation::ListTool => ( - BTreeMap::from([ - ( - "tool_type".to_string(), - JsonSchema::string(Some( - "Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"." - .to_string(), - )), - ), - ( - "action_type".to_string(), - JsonSchema::string(Some( - "Suggested action for the tool. Use \"install\".".to_string(), - )), - ), - ( - "tool_id".to_string(), - JsonSchema::string(Some("Connector or plugin id to suggest.".to_string())), - ), - ( - "suggest_reason".to_string(), - JsonSchema::string(Some( - "Concise one-line user-facing reason why this plugin or connector can help with the current request." - .to_string(), - )), - ), - ]), - vec![ - "tool_type".to_string(), - "action_type".to_string(), - "tool_id".to_string(), - "suggest_reason".to_string(), - ], - format!( - "# Request plugin/connector install\n\nUse this tool only after `{LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}` returns a plugin or connector that exactly matches the user's explicit request.\n\nDo not use it for adjacent capabilities, broad recommendations, or tools that merely seem useful. Pass the returned `tool_type` through directly, and pass the returned `id` as `tool_id`.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools." - ), - ), - ToolSuggestPresentation::RecommendationContext => ( - BTreeMap::from([ - ( - "plugin_id".to_string(), - JsonSchema::string(Some( - "Plugin id from the `` list.".to_string(), - )), - ), - ( - "suggest_reason".to_string(), - JsonSchema::string(Some( - "Concise one-line user-facing reason why this plugin can help with the current request." - .to_string(), - )), - ), - ]), - vec!["plugin_id".to_string(), "suggest_reason".to_string()], - "# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the `` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string(), - ), - }; - - ToolSpec::Function(ResponsesApiTool { - name: REQUEST_PLUGIN_INSTALL_TOOL_NAME.to_string(), - description, - strict: false, - defer_loading: None, - parameters: JsonSchema::object(properties, Some(required), Some(false.into())), - output_schema: None, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use codex_tools::JsonSchema; - use pretty_assertions::assert_eq; - use std::collections::BTreeMap; - - #[test] - fn create_request_plugin_install_tool_uses_expected_legacy_wire_shape() { - let expected_description = concat!( - "# Request plugin/connector install\n\n", - "Use this tool only after `list_available_plugins_to_install` returns a plugin or connector that exactly matches the user's explicit request.\n\n", - "Do not use it for adjacent capabilities, broad recommendations, or tools that merely seem useful. Pass the returned `tool_type` through directly, and pass the returned `id` as `tool_id`.\n\n", - "IMPORTANT: DO NOT call this tool in parallel with other tools.", - ); - - assert_eq!( - create_request_plugin_install_tool(ToolSuggestPresentation::ListTool), - ToolSpec::Function(ResponsesApiTool { - name: "request_plugin_install".to_string(), - description: expected_description.to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::object(BTreeMap::from([ - ( - "action_type".to_string(), - JsonSchema::string(Some( - "Suggested action for the tool. Use \"install\"." - .to_string(), - ),), - ), - ( - "suggest_reason".to_string(), - JsonSchema::string(Some( - "Concise one-line user-facing reason why this plugin or connector can help with the current request." - .to_string(), - ),), - ), - ( - "tool_id".to_string(), - JsonSchema::string(Some( - "Connector or plugin id to suggest." - .to_string(), - ),), - ), - ( - "tool_type".to_string(), - JsonSchema::string(Some( - "Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"." - .to_string(), - ),), - ), - ]), Some(vec![ - "tool_type".to_string(), - "action_type".to_string(), - "tool_id".to_string(), - "suggest_reason".to_string(), - ]), Some(false.into())), - output_schema: None, - }) - ); - } - - #[test] - fn recommendation_context_uses_simplified_plugin_wire_shape() { - assert_eq!( - create_request_plugin_install_tool(ToolSuggestPresentation::RecommendationContext), - ToolSpec::Function(ResponsesApiTool { - name: "request_plugin_install".to_string(), - description: "# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the `` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - BTreeMap::from([ - ( - "plugin_id".to_string(), - JsonSchema::string(Some( - "Plugin id from the `` list.".to_string(), - )), - ), - ( - "suggest_reason".to_string(), - JsonSchema::string(Some( - "Concise one-line user-facing reason why this plugin can help with the current request." - .to_string(), - )), - ), - ]), - Some(vec!["plugin_id".to_string(), "suggest_reason".to_string()]), - Some(false.into()), - ), - output_schema: None, - }) - ); - } -} diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs b/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs deleted file mode 100644 index 62c8afaa65..0000000000 --- a/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs +++ /dev/null @@ -1,244 +0,0 @@ -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 recommended_plugin_install_args_accept_legacy_tool_id() { - let current: RecommendedPluginInstallArgs = serde_json::from_value(json!({ - "plugin_id": "google-drive@openai-curated-remote", - "suggest_reason": "Use Google Drive for this request" - })) - .expect("current arguments should deserialize"); - let legacy: RecommendedPluginInstallArgs = serde_json::from_value(json!({ - "tool_type": "plugin", - "action_type": "install", - "tool_id": "google-drive@openai-curated-remote", - "suggest_reason": "Use Google Drive for this request" - })) - .expect("legacy arguments should deserialize"); - - assert_eq!(current, legacy); -} - -#[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, - }) - ); -} - -#[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(), - })) -} diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 8bcad84981..950eb47529 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -7,7 +7,6 @@ mod function_call_error; mod image_detail; mod json_schema; mod mcp_tool; -mod request_plugin_install; mod response_history; mod responses_api; mod tool_call; @@ -39,15 +38,6 @@ pub use json_schema::parse_tool_input_schema; pub use json_schema::parse_tool_input_schema_without_compaction; pub use mcp_tool::mcp_call_tool_result_output_schema; pub use mcp_tool::parse_mcp_tool; -pub use request_plugin_install::REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE; -pub use request_plugin_install::REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE; -pub use request_plugin_install::REQUEST_PLUGIN_INSTALL_PERSIST_KEY; -pub use request_plugin_install::RequestPluginInstallArgs; -pub use request_plugin_install::RequestPluginInstallMeta; -pub use request_plugin_install::RequestPluginInstallResult; -pub use request_plugin_install::all_requested_connectors_picked_up; -pub use request_plugin_install::build_request_plugin_install_elicitation_request; -pub use request_plugin_install::verified_connector_install_completed; pub use response_history::retain_tail_from_last_n_user_messages; pub use response_history::truncate_assistant_output_text_to_token_budget; pub use responses_api::FreeformTool; @@ -86,7 +76,6 @@ pub use tool_discovery::DiscoverableToolAction; pub use tool_discovery::DiscoverableToolType; pub use tool_discovery::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME; pub use tool_discovery::ListAvailablePluginsToInstallResult; -pub use tool_discovery::REQUEST_PLUGIN_INSTALL_TOOL_NAME; pub use tool_discovery::RequestPluginInstallEntry; pub use tool_discovery::TOOL_SEARCH_DEFAULT_LIMIT; pub use tool_discovery::TOOL_SEARCH_TOOL_NAME; diff --git a/codex-rs/tools/src/request_plugin_install.rs b/codex-rs/tools/src/request_plugin_install.rs deleted file mode 100644 index 7bc0f6d4c6..0000000000 --- a/codex-rs/tools/src/request_plugin_install.rs +++ /dev/null @@ -1,132 +0,0 @@ -use std::collections::BTreeMap; - -use codex_app_server_protocol::AppInfo; -use codex_app_server_protocol::McpElicitationObjectType; -use codex_app_server_protocol::McpElicitationSchema; -use codex_app_server_protocol::McpServerElicitationRequest; -use codex_app_server_protocol::McpServerElicitationRequestParams; -use serde::Deserialize; -use serde::Serialize; -use serde_json::json; - -use crate::DiscoverableTool; -use crate::DiscoverableToolAction; -use crate::DiscoverableToolType; - -pub const REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE: &str = "tool_suggestion"; -pub const REQUEST_PLUGIN_INSTALL_PERSIST_KEY: &str = "persist"; -pub const REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE: &str = "always"; - -#[derive(Debug, Deserialize)] -pub struct RequestPluginInstallArgs { - pub tool_type: DiscoverableToolType, - pub action_type: DiscoverableToolAction, - pub tool_id: String, - pub suggest_reason: String, -} - -#[derive(Debug, Serialize, PartialEq, Eq)] -pub struct RequestPluginInstallResult { - pub completed: bool, - pub user_confirmed: bool, - pub tool_type: DiscoverableToolType, - pub action_type: DiscoverableToolAction, - pub tool_id: String, - pub tool_name: String, - pub suggest_reason: String, -} - -#[derive(Debug, Serialize, PartialEq, Eq)] -pub struct RequestPluginInstallMeta<'a> { - pub codex_approval_kind: &'static str, - pub persist: &'static str, - pub tool_type: DiscoverableToolType, - pub suggest_type: DiscoverableToolAction, - pub suggest_reason: &'a str, - pub tool_id: &'a str, - pub tool_name: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - pub install_url: Option<&'a str>, - #[serde(skip_serializing_if = "Option::is_none")] - pub remote_plugin_id: Option<&'a str>, - #[serde(skip_serializing_if = "Option::is_none")] - pub app_connector_ids: Option<&'a [String]>, -} - -pub fn build_request_plugin_install_elicitation_request( - server_name: &str, - thread_id: String, - turn_id: String, - suggest_reason: &str, - tool: &DiscoverableTool, -) -> McpServerElicitationRequestParams { - let message = suggest_reason.to_string(); - - McpServerElicitationRequestParams { - thread_id, - turn_id: Some(turn_id), - server_name: server_name.to_string(), - request: McpServerElicitationRequest::Form { - meta: Some(json!(build_request_plugin_install_meta( - suggest_reason, - tool, - ))), - message, - requested_schema: McpElicitationSchema { - schema_uri: None, - type_: McpElicitationObjectType::Object, - properties: BTreeMap::new(), - required: None, - }, - }, - } -} - -pub fn all_requested_connectors_picked_up( - expected_connector_ids: &[String], - accessible_connectors: &[AppInfo], -) -> bool { - expected_connector_ids.iter().all(|connector_id| { - verified_connector_install_completed(connector_id, accessible_connectors) - }) -} - -pub fn verified_connector_install_completed( - tool_id: &str, - accessible_connectors: &[AppInfo], -) -> bool { - accessible_connectors - .iter() - .find(|connector| connector.id == tool_id) - .is_some_and(|connector| connector.is_accessible) -} - -fn build_request_plugin_install_meta<'a>( - suggest_reason: &'a str, - tool: &'a DiscoverableTool, -) -> RequestPluginInstallMeta<'a> { - let (tool_type, remote_plugin_id, app_connector_ids) = match tool { - DiscoverableTool::Connector(_) => (DiscoverableToolType::Connector, None, None), - DiscoverableTool::Plugin(plugin) => ( - DiscoverableToolType::Plugin, - plugin.remote_plugin_id.as_deref(), - Some(plugin.app_connector_ids.as_slice()), - ), - }; - RequestPluginInstallMeta { - codex_approval_kind: REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE, - persist: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE, - tool_type, - suggest_type: DiscoverableToolAction::Install, - suggest_reason, - tool_id: tool.id(), - tool_name: tool.name(), - install_url: tool.install_url(), - remote_plugin_id, - app_connector_ids, - } -} - -#[cfg(test)] -#[path = "request_plugin_install_tests.rs"] -mod tests; diff --git a/codex-rs/tools/src/request_plugin_install_tests.rs b/codex-rs/tools/src/request_plugin_install_tests.rs deleted file mode 100644 index 400126dc6d..0000000000 --- a/codex-rs/tools/src/request_plugin_install_tests.rs +++ /dev/null @@ -1,214 +0,0 @@ -use super::*; -use crate::DiscoverablePluginInfo; -use pretty_assertions::assert_eq; -use serde_json::json; - -#[test] -fn build_request_plugin_install_elicitation_request_uses_expected_shape() { - let connector = DiscoverableTool::Connector(Box::new(AppInfo { - id: "connector_2128aebfecb84f64a069897515042a44".to_string(), - name: "Google Calendar".to_string(), - description: Some("Plan events and schedules.".to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some( - "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44" - .to_string(), - ), - is_accessible: false, - is_enabled: true, - plugin_display_names: Vec::new(), - })); - - let request = build_request_plugin_install_elicitation_request( - "codex-apps", - "thread-1".to_string(), - "turn-1".to_string(), - "Plan and reference events from your calendar", - &connector, - ); - - assert_eq!( - request, - McpServerElicitationRequestParams { - thread_id: "thread-1".to_string(), - turn_id: Some("turn-1".to_string()), - server_name: "codex-apps".to_string(), - request: McpServerElicitationRequest::Form { - meta: Some(json!(RequestPluginInstallMeta { - codex_approval_kind: REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE, - persist: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE, - tool_type: DiscoverableToolType::Connector, - suggest_type: DiscoverableToolAction::Install, - suggest_reason: "Plan and reference events from your calendar", - tool_id: "connector_2128aebfecb84f64a069897515042a44", - tool_name: "Google Calendar", - install_url: Some( - "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44" - ), - remote_plugin_id: None, - app_connector_ids: None, - })), - message: "Plan and reference events from your calendar".to_string(), - requested_schema: McpElicitationSchema { - schema_uri: None, - type_: McpElicitationObjectType::Object, - properties: BTreeMap::new(), - required: None, - }, - }, - }, - ); -} - -#[test] -fn build_request_plugin_install_elicitation_request_injects_plugin_metadata() { - let plugin = DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo { - id: "sample@openai-curated-remote".to_string(), - remote_plugin_id: Some("plugins~Plugin_sample".to_string()), - name: "Sample Plugin".to_string(), - description: Some("Includes skills, MCP servers, and apps.".to_string()), - has_skills: true, - mcp_server_names: vec!["sample-docs".to_string()], - app_connector_ids: vec!["connector_calendar".to_string()], - })); - - let request = build_request_plugin_install_elicitation_request( - "codex-apps", - "thread-1".to_string(), - "turn-1".to_string(), - "Use the sample plugin's skills and MCP server", - &plugin, - ); - - assert_eq!( - request, - McpServerElicitationRequestParams { - thread_id: "thread-1".to_string(), - turn_id: Some("turn-1".to_string()), - server_name: "codex-apps".to_string(), - request: McpServerElicitationRequest::Form { - meta: Some(json!(RequestPluginInstallMeta { - codex_approval_kind: REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE, - persist: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE, - tool_type: DiscoverableToolType::Plugin, - suggest_type: DiscoverableToolAction::Install, - suggest_reason: "Use the sample plugin's skills and MCP server", - tool_id: "sample@openai-curated-remote", - tool_name: "Sample Plugin", - install_url: None, - remote_plugin_id: Some("plugins~Plugin_sample"), - app_connector_ids: Some(&["connector_calendar".to_string()]), - })), - message: "Use the sample plugin's skills and MCP server".to_string(), - requested_schema: McpElicitationSchema { - schema_uri: None, - type_: McpElicitationObjectType::Object, - properties: BTreeMap::new(), - required: None, - }, - }, - }, - ); -} - -#[test] -fn build_request_plugin_install_meta_uses_expected_shape() { - let connector = DiscoverableTool::Connector(Box::new(AppInfo { - id: "connector_68df038e0ba48191908c8434991bbac2".to_string(), - name: "Gmail".to_string(), - description: None, - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some( - "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2".to_string(), - ), - is_accessible: false, - is_enabled: true, - plugin_display_names: Vec::new(), - })); - let meta = - build_request_plugin_install_meta("Find and reference emails from your inbox", &connector); - - assert_eq!( - meta, - RequestPluginInstallMeta { - codex_approval_kind: REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE, - persist: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE, - tool_type: DiscoverableToolType::Connector, - suggest_type: DiscoverableToolAction::Install, - suggest_reason: "Find and reference emails from your inbox", - tool_id: "connector_68df038e0ba48191908c8434991bbac2", - tool_name: "Gmail", - install_url: Some( - "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2" - ), - remote_plugin_id: None, - app_connector_ids: None, - }, - ); -} - -#[test] -fn verified_connector_install_completed_requires_accessible_connector() { - let accessible_connectors = vec![AppInfo { - id: "calendar".to_string(), - name: "Google Calendar".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: true, - is_enabled: false, - plugin_display_names: Vec::new(), - }]; - - assert!(verified_connector_install_completed( - "calendar", - &accessible_connectors, - )); - assert!(!verified_connector_install_completed( - "gmail", - &accessible_connectors, - )); -} - -#[test] -fn all_requested_connectors_picked_up_requires_every_expected_connector() { - let accessible_connectors = vec![AppInfo { - id: "calendar".to_string(), - name: "Google Calendar".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: true, - is_enabled: false, - plugin_display_names: Vec::new(), - }]; - - assert!(all_requested_connectors_picked_up( - &["calendar".to_string()], - &accessible_connectors, - )); - assert!(!all_requested_connectors_picked_up( - &["calendar".to_string(), "gmail".to_string()], - &accessible_connectors, - )); -} diff --git a/codex-rs/tools/src/tool_discovery.rs b/codex-rs/tools/src/tool_discovery.rs index 311c496b68..d0511a1761 100644 --- a/codex-rs/tools/src/tool_discovery.rs +++ b/codex-rs/tools/src/tool_discovery.rs @@ -6,7 +6,6 @@ const TUI_CLIENT_NAME: &str = "codex-tui"; pub const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; pub const TOOL_SEARCH_DEFAULT_LIMIT: usize = 8; pub const LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME: &str = "list_available_plugins_to_install"; -pub const REQUEST_PLUGIN_INSTALL_TOOL_NAME: &str = "request_plugin_install"; #[derive(Clone, Debug, PartialEq, Eq)] pub struct ToolSearchSourceInfo {