From d7f7d9af3a700eaa3f6fba82bf2cf6b86a8d7d6b Mon Sep 17 00:00:00 2001 From: Zhanshi Wang Date: Wed, 17 Jun 2026 16:58:03 -0700 Subject: [PATCH] Add plugin install request model --- codex-rs/Cargo.lock | 12 + codex-rs/Cargo.toml | 2 + codex-rs/ext/plugin-installs/BUILD.bazel | 6 + codex-rs/ext/plugin-installs/Cargo.toml | 23 ++ codex-rs/ext/plugin-installs/src/domain.rs | 220 ++++++++++++ .../ext/plugin-installs/src/domain_tests.rs | 315 ++++++++++++++++++ codex-rs/ext/plugin-installs/src/lib.rs | 16 + 7 files changed, 594 insertions(+) create mode 100644 codex-rs/ext/plugin-installs/BUILD.bazel create mode 100644 codex-rs/ext/plugin-installs/Cargo.toml create mode 100644 codex-rs/ext/plugin-installs/src/domain.rs create mode 100644 codex-rs/ext/plugin-installs/src/domain_tests.rs create mode 100644 codex-rs/ext/plugin-installs/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 844a84ef73..b7e1b8f7ac 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3601,6 +3601,18 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "codex-plugin-installs-extension" +version = "0.0.0" +dependencies = [ + "codex-app-server-protocol", + "codex-extension-api", + "codex-tools", + "pretty_assertions", + "serde", + "serde_json", +] + [[package]] name = "codex-process-hardening" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index e5f5025e91..3d09cd41ea 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -53,6 +53,7 @@ members = [ "ext/image-generation", "ext/memories", "ext/mcp", + "ext/plugin-installs", "ext/skills", "ext/web-search", "external-agent-migration", @@ -200,6 +201,7 @@ codex-mcp = { path = "codex-mcp" } codex-mcp-extension = { path = "ext/mcp" } codex-mcp-server = { path = "mcp-server" } codex-model-provider-info = { path = "model-provider-info" } +codex-plugin-installs-extension = { path = "ext/plugin-installs" } codex-models-manager = { path = "models-manager" } codex-network-proxy = { path = "network-proxy" } codex-ollama = { path = "ollama" } diff --git a/codex-rs/ext/plugin-installs/BUILD.bazel b/codex-rs/ext/plugin-installs/BUILD.bazel new file mode 100644 index 0000000000..fa4dc68ede --- /dev/null +++ b/codex-rs/ext/plugin-installs/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "plugin-installs", + crate_name = "codex_plugin_installs_extension", +) diff --git a/codex-rs/ext/plugin-installs/Cargo.toml b/codex-rs/ext/plugin-installs/Cargo.toml new file mode 100644 index 0000000000..c997fa0177 --- /dev/null +++ b/codex-rs/ext/plugin-installs/Cargo.toml @@ -0,0 +1,23 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-plugin-installs-extension" +version.workspace = true + +[lib] +name = "codex_plugin_installs_extension" +path = "src/lib.rs" +doctest = false + +[lints] +workspace = true + +[dependencies] +codex-app-server-protocol = { workspace = true } +codex-extension-api = { workspace = true } +codex-tools = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/ext/plugin-installs/src/domain.rs b/codex-rs/ext/plugin-installs/src/domain.rs new file mode 100644 index 0000000000..424a872618 --- /dev/null +++ b/codex-rs/ext/plugin-installs/src/domain.rs @@ -0,0 +1,220 @@ +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 codex_tools::DiscoverableTool; +use codex_tools::DiscoverableToolAction; +use codex_tools::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"; +const REQUEST_PLUGIN_INSTALL_MESSAGE: &str = "Choose integrations"; + +#[derive(Debug, Deserialize)] +pub struct RequestPluginInstallsArgs { + pub action_type: DiscoverableToolAction, + pub entries: Option>, + pub categories: Option>, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +pub struct RequestPluginInstallPickerEntry { + pub tool_id: String, + pub tool_type: DiscoverableToolType, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +pub struct RequestPluginInstallPickerCategory { + pub title: String, + pub entries: Vec, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct RequestPluginInstallsResult { + pub completed: bool, + pub user_confirmed: bool, + pub action_type: DiscoverableToolAction, + pub entries: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct RequestPluginInstallInstalledEntry { + pub tool_id: String, + pub tool_type: DiscoverableToolType, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct RequestPluginInstallEntryResult { + pub tool_type: DiscoverableToolType, + pub tool_id: String, + pub tool_name: String, + pub completed: bool, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct RequestPluginInstallsMeta<'a> { + pub codex_approval_kind: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub persist: Option<&'static str>, + pub suggest_type: DiscoverableToolAction, + #[serde(skip_serializing_if = "Option::is_none")] + pub entries: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + pub categories: Option>>, +} + +#[derive(Clone, Debug)] +pub struct RequestPluginInstallResolvedPickerEntry { + pub category_index: Option, + pub tool: DiscoverableTool, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct RequestPluginInstallEntryMeta<'a> { + pub tool_id: &'a str, + pub tool_name: &'a str, + pub tool_type: DiscoverableToolType, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option<&'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]>, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct RequestPluginInstallCategoryMeta<'a> { + pub title: &'a str, + pub entries: Vec>, +} + +pub fn build_request_plugin_installs_elicitation_request( + server_name: &str, + thread_id: String, + turn_id: String, + args: &RequestPluginInstallsArgs, + resolved_entries: &[RequestPluginInstallResolvedPickerEntry], +) -> McpServerElicitationRequestParams { + McpServerElicitationRequestParams { + thread_id, + turn_id: Some(turn_id), + server_name: server_name.to_string(), + request: McpServerElicitationRequest::Form { + meta: Some(json!(build_request_plugin_installs_meta( + args, + resolved_entries + ))), + message: REQUEST_PLUGIN_INSTALL_MESSAGE.to_string(), + requested_schema: empty_elicitation_schema(), + }, + } +} + +fn empty_elicitation_schema() -> McpElicitationSchema { + 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_installs_meta<'a>( + args: &'a RequestPluginInstallsArgs, + resolved_entries: &'a [RequestPluginInstallResolvedPickerEntry], +) -> RequestPluginInstallsMeta<'a> { + let entries = args.entries.as_ref().map(|_| { + resolved_entries + .iter() + .map(build_request_plugin_install_entry_meta) + .collect() + }); + let categories = args.categories.as_ref().map(|categories| { + categories + .iter() + .enumerate() + .map(|(category_index, category)| { + let entries = resolved_entries + .iter() + .filter(|entry| entry.category_index == Some(category_index)) + .map(build_request_plugin_install_entry_meta) + .collect(); + RequestPluginInstallCategoryMeta { + title: category.title.as_str(), + entries, + } + }) + .collect() + }); + + RequestPluginInstallsMeta { + codex_approval_kind: REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE, + persist: Some(REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE), + suggest_type: args.action_type, + entries, + categories, + } +} + +fn build_request_plugin_install_entry_meta<'a>( + entry: &'a RequestPluginInstallResolvedPickerEntry, +) -> RequestPluginInstallEntryMeta<'a> { + let tool = &entry.tool; + let (remote_plugin_id, app_connector_ids) = match tool { + DiscoverableTool::Connector(_) => (None, None), + DiscoverableTool::Plugin(plugin) => ( + plugin.remote_plugin_id.as_deref(), + Some(plugin.app_connector_ids.as_slice()), + ), + }; + + RequestPluginInstallEntryMeta { + tool_id: tool.id(), + tool_name: tool.name(), + tool_type: tool.tool_type(), + description: discoverable_tool_description(tool), + install_url: tool.install_url(), + remote_plugin_id, + app_connector_ids, + } +} + +fn discoverable_tool_description(tool: &DiscoverableTool) -> Option<&str> { + match tool { + DiscoverableTool::Connector(connector) => connector.description.as_deref(), + DiscoverableTool::Plugin(plugin) => plugin.description.as_deref(), + } +} + +#[cfg(test)] +#[path = "domain_tests.rs"] +mod tests; diff --git a/codex-rs/ext/plugin-installs/src/domain_tests.rs b/codex-rs/ext/plugin-installs/src/domain_tests.rs new file mode 100644 index 0000000000..c0ec150990 --- /dev/null +++ b/codex-rs/ext/plugin-installs/src/domain_tests.rs @@ -0,0 +1,315 @@ +use super::*; +use codex_tools::DiscoverablePluginInfo; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn request_plugin_installs_result_keeps_model_response_minimal() { + assert_eq!( + json!(RequestPluginInstallsResult { + completed: false, + user_confirmed: false, + action_type: DiscoverableToolAction::Install, + entries: vec![RequestPluginInstallEntryResult { + tool_type: DiscoverableToolType::Plugin, + tool_id: "apollo@openai-curated-remote".to_string(), + tool_name: "Apollo".to_string(), + completed: false, + }], + }), + json!({ + "completed": false, + "user_confirmed": false, + "action_type": "install", + "entries": [ + { + "tool_type": "plugin", + "tool_id": "apollo@openai-curated-remote", + "tool_name": "Apollo", + "completed": false, + }, + ], + }), + ); +} + +#[test] +fn build_request_plugin_installs_elicitation_request_uses_flat_entries_shape() { + let args = RequestPluginInstallsArgs { + action_type: DiscoverableToolAction::Install, + entries: Some(vec![RequestPluginInstallPickerEntry { + tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(), + tool_type: DiscoverableToolType::Connector, + }]), + categories: None, + }; + 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 resolved_entries = [RequestPluginInstallResolvedPickerEntry { + category_index: None, + tool: connector, + }]; + + let request = build_request_plugin_installs_elicitation_request( + "codex-apps", + "thread-1".to_string(), + "turn-1".to_string(), + &args, + &resolved_entries, + ); + + 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!(RequestPluginInstallsMeta { + codex_approval_kind: REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE, + persist: Some(REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE), + suggest_type: DiscoverableToolAction::Install, + entries: Some(vec![RequestPluginInstallEntryMeta { + tool_id: "connector_2128aebfecb84f64a069897515042a44", + tool_name: "Google Calendar", + tool_type: DiscoverableToolType::Connector, + description: Some("Plan events and schedules."), + install_url: Some( + "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44" + ), + remote_plugin_id: None, + app_connector_ids: None, + }]), + categories: None, + })), + message: "Choose integrations".to_string(), + requested_schema: McpElicitationSchema { + schema_uri: None, + type_: McpElicitationObjectType::Object, + properties: BTreeMap::new(), + required: None, + }, + }, + }, + ); +} + +#[test] +fn build_request_plugin_installs_elicitation_request_injects_plugin_metadata() { + let args = RequestPluginInstallsArgs { + action_type: DiscoverableToolAction::Install, + entries: Some(vec![RequestPluginInstallPickerEntry { + tool_id: "sample@openai-curated-remote".to_string(), + tool_type: DiscoverableToolType::Plugin, + }]), + categories: None, + }; + 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 resolved_entries = [RequestPluginInstallResolvedPickerEntry { + category_index: None, + tool: plugin, + }]; + + let request = build_request_plugin_installs_elicitation_request( + "codex-apps", + "thread-1".to_string(), + "turn-1".to_string(), + &args, + &resolved_entries, + ); + + 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!(RequestPluginInstallsMeta { + codex_approval_kind: REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE, + persist: Some(REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE), + suggest_type: DiscoverableToolAction::Install, + entries: Some(vec![RequestPluginInstallEntryMeta { + tool_id: "sample@openai-curated-remote", + tool_name: "Sample Plugin", + tool_type: DiscoverableToolType::Plugin, + description: Some("Includes skills, MCP servers, and apps."), + install_url: None, + remote_plugin_id: Some("plugins~Plugin_sample"), + app_connector_ids: Some(&["connector_calendar".to_string()]), + }]), + categories: None, + })), + message: "Choose integrations".to_string(), + requested_schema: McpElicitationSchema { + schema_uri: None, + type_: McpElicitationObjectType::Object, + properties: BTreeMap::new(), + required: None, + }, + }, + }, + ); +} + +#[test] +fn build_request_plugin_installs_elicitation_request_uses_categories_shape() { + let args = RequestPluginInstallsArgs { + action_type: DiscoverableToolAction::Install, + entries: None, + categories: Some(vec![RequestPluginInstallPickerCategory { + title: "Calendar".to_string(), + entries: vec![RequestPluginInstallPickerEntry { + tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(), + tool_type: DiscoverableToolType::Connector, + }], + }]), + }; + 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 resolved_entries = [RequestPluginInstallResolvedPickerEntry { + category_index: Some(0), + tool: connector, + }]; + + let request = build_request_plugin_installs_elicitation_request( + "codex-apps", + "thread-1".to_string(), + "turn-1".to_string(), + &args, + &resolved_entries, + ); + + 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!(RequestPluginInstallsMeta { + codex_approval_kind: REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE, + persist: Some(REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE), + suggest_type: DiscoverableToolAction::Install, + entries: None, + categories: Some(vec![RequestPluginInstallCategoryMeta { + title: "Calendar", + entries: vec![RequestPluginInstallEntryMeta { + tool_id: "connector_2128aebfecb84f64a069897515042a44", + tool_name: "Google Calendar", + tool_type: DiscoverableToolType::Connector, + description: Some("Plan events and schedules."), + install_url: Some( + "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44" + ), + remote_plugin_id: None, + app_connector_ids: None, + }], + }]), + })), + message: "Choose integrations".to_string(), + requested_schema: McpElicitationSchema { + schema_uri: None, + type_: McpElicitationObjectType::Object, + properties: BTreeMap::new(), + required: 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/ext/plugin-installs/src/lib.rs b/codex-rs/ext/plugin-installs/src/lib.rs new file mode 100644 index 0000000000..0e42665a1b --- /dev/null +++ b/codex-rs/ext/plugin-installs/src/lib.rs @@ -0,0 +1,16 @@ +mod domain; + +pub const REQUEST_PLUGIN_INSTALLS_TOOL_NAME: &str = "request_plugin_installs"; + +pub use domain::REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE; +pub use domain::REQUEST_PLUGIN_INSTALL_PERSIST_KEY; +pub use domain::RequestPluginInstallEntryResult; +pub use domain::RequestPluginInstallInstalledEntry; +pub use domain::RequestPluginInstallPickerCategory; +pub use domain::RequestPluginInstallPickerEntry; +pub use domain::RequestPluginInstallResolvedPickerEntry; +pub use domain::RequestPluginInstallsArgs; +pub use domain::RequestPluginInstallsResult; +pub use domain::all_requested_connectors_picked_up; +pub use domain::build_request_plugin_installs_elicitation_request; +pub use domain::verified_connector_install_completed;