Add plugin install request model

This commit is contained in:
Zhanshi Wang
2026-06-17 16:58:03 -07:00
parent 08604af9f3
commit d7f7d9af3a
7 changed files with 594 additions and 0 deletions

12
codex-rs/Cargo.lock generated
View File

@@ -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"

View File

@@ -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" }

View File

@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "plugin-installs",
crate_name = "codex_plugin_installs_extension",
)

View File

@@ -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 }

View File

@@ -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<Vec<RequestPluginInstallPickerEntry>>,
pub categories: Option<Vec<RequestPluginInstallPickerCategory>>,
}
#[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<RequestPluginInstallPickerEntry>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct RequestPluginInstallsResult {
pub completed: bool,
pub user_confirmed: bool,
pub action_type: DiscoverableToolAction,
pub entries: Vec<RequestPluginInstallEntryResult>,
}
#[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<Vec<RequestPluginInstallEntryMeta<'a>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub categories: Option<Vec<RequestPluginInstallCategoryMeta<'a>>>,
}
#[derive(Clone, Debug)]
pub struct RequestPluginInstallResolvedPickerEntry {
pub category_index: Option<usize>,
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<RequestPluginInstallEntryMeta<'a>>,
}
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;

View File

@@ -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,
));
}

View File

@@ -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;