Compare commits

...

5 Commits

Author SHA1 Message Date
Matthew Zeng
c634ffe902 update 2026-03-16 23:55:48 -07:00
Matthew Zeng
27217b4c91 update 2026-03-16 23:20:14 -07:00
Matthew Zeng
1b7d84e6ca Merge branch 'main' of github.com:openai/codex into dev/mzeng/plugin_suggestion_2 2026-03-16 23:15:37 -07:00
Matthew Zeng
fb88b513dc update 2026-03-16 23:06:37 -07:00
Matthew Zeng
cf119966f4 update 2026-03-16 22:45:39 -07:00
12 changed files with 636 additions and 187 deletions

View File

@@ -292,7 +292,6 @@ use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use crate::tools::ToolRouter;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::discoverable::DiscoverableTool;
use crate::tools::js_repl::JsReplHandle;
use crate::tools::js_repl::resolve_compatible_node;
use crate::tools::network_approval::NetworkApprovalService;
@@ -6469,10 +6468,8 @@ pub(crate) async fn built_tools(
)
.await
{
Ok(connectors) if connectors.is_empty() => None,
Ok(connectors) => {
Some(connectors.into_iter().map(DiscoverableTool::from).collect())
}
Ok(discoverable_tools) if discoverable_tools.is_empty() => None,
Ok(discoverable_tools) => Some(discoverable_tools),
Err(err) => {
warn!("failed to load discoverable tool suggestions: {err:#}");
None

View File

@@ -42,24 +42,14 @@ use crate::mcp_connection_manager::McpConnectionManager;
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
use crate::plugins::AppConnectorId;
use crate::plugins::PluginsManager;
use crate::plugins::list_tool_suggest_discoverable_plugins;
use crate::token_data::TokenData;
use crate::tools::discoverable::DiscoverablePluginInfo;
use crate::tools::discoverable::DiscoverableTool;
pub use codex_connectors::CONNECTORS_CACHE_TTL;
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
const TOOL_SUGGEST_DISCOVERABLE_CONNECTOR_IDS: &[&str] = &[
"connector_2128aebfecb84f64a069897515042a44",
"connector_68df038e0ba48191908c8434991bbac2",
"asdk_app_69a1d78e929881919bba0dbda1f6436d",
"connector_4964e3b22e3e427e9b4ae1acf2c1fa34",
"connector_9d7cfa34e6654a5f98d3387af34b2e1c",
"connector_6f1ec045b8fa4ced8738e32c7f74514b",
"connector_947e0d954944416db111db556030eea6",
"connector_5f3c8c41a1e54ad7a76272c89e2554fa",
"connector_686fad9b54914a35b75be6d06a0f6f31",
"connector_76869538009648d5b282a4bb21c3d157",
"connector_37316be7febe4224b3d31465bae4dbd7",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AppToolPolicy {
@@ -116,13 +106,24 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
config: &Config,
auth: Option<&CodexAuth>,
accessible_connectors: &[AppInfo],
) -> anyhow::Result<Vec<AppInfo>> {
) -> anyhow::Result<Vec<DiscoverableTool>> {
let directory_connectors =
list_directory_connectors_for_tool_suggest_with_auth(config, auth).await?;
Ok(filter_tool_suggest_discoverable_tools(
let connector_ids = tool_suggest_connector_ids(config);
let discoverable_connectors = filter_tool_suggest_discoverable_connectors(
directory_connectors,
accessible_connectors,
))
&connector_ids,
)
.into_iter()
.map(DiscoverableTool::from);
let discoverable_plugins = list_tool_suggest_discoverable_plugins(config)?
.into_iter()
.map(DiscoverablePluginInfo::from)
.map(DiscoverableTool::from);
Ok(discoverable_connectors
.chain(discoverable_plugins)
.collect())
}
pub async fn list_cached_accessible_connectors_from_mcp_tools(
@@ -350,24 +351,21 @@ fn write_cached_accessible_connectors(
});
}
fn filter_tool_suggest_discoverable_tools(
fn filter_tool_suggest_discoverable_connectors(
directory_connectors: Vec<AppInfo>,
accessible_connectors: &[AppInfo],
discoverable_connector_ids: &HashSet<String>,
) -> Vec<AppInfo> {
let accessible_connector_ids: HashSet<&str> = accessible_connectors
.iter()
.filter(|connector| connector.is_accessible && connector.is_enabled)
.filter(|connector| connector.is_accessible)
.map(|connector| connector.id.as_str())
.collect();
let allowed_connector_ids: HashSet<&str> = TOOL_SUGGEST_DISCOVERABLE_CONNECTOR_IDS
.iter()
.copied()
.collect();
let mut connectors = filter_disallowed_connectors(directory_connectors)
.into_iter()
.filter(|connector| !accessible_connector_ids.contains(connector.id.as_str()))
.filter(|connector| allowed_connector_ids.contains(connector.id.as_str()))
.filter(|connector| discoverable_connector_ids.contains(connector.id.as_str()))
.collect::<Vec<_>>();
connectors.sort_by(|left, right| {
left.name
@@ -377,6 +375,16 @@ fn filter_tool_suggest_discoverable_tools(
connectors
}
fn tool_suggest_connector_ids(config: &Config) -> HashSet<String> {
PluginsManager::new(config.codex_home.clone())
.plugins_for_config(config)
.capability_summaries()
.iter()
.flat_map(|plugin| plugin.app_connector_ids.iter())
.map(|connector_id| connector_id.0.clone())
.collect()
}
async fn list_directory_connectors_for_tool_suggest_with_auth(
config: &Config,
auth: Option<&CodexAuth>,
@@ -675,6 +683,7 @@ pub(crate) fn codex_app_tool_is_enabled(
const DISALLOWED_CONNECTOR_IDS: &[&str] = &[
"asdk_app_6938a94a61d881918ef32cb999ff937c",
"connector_2b0a9009c9c64bf9933a3dae3f2b1254",
"connector_3f8d1a79f27c4c7ba1a897ab13bf37dc",
"connector_68de829bf7648191acd70a907364c67c",
"connector_68e004f14af881919eb50893d3d9f523",
"connector_69272cb413a081919685ec3c88d1744e",

View File

@@ -1,4 +1,5 @@
use super::*;
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use crate::config::types::AppConfig;
use crate::config::types::AppToolConfig;
@@ -13,13 +14,13 @@ use crate::config_loader::ConfigRequirementsToml;
use crate::features::Feature;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp_connection_manager::ToolInfo;
use codex_config::CONFIG_TOML_FILE;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use rmcp::model::JsonObject;
use rmcp::model::Tool;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use tempfile::tempdir;
@@ -957,6 +958,7 @@ fn filter_disallowed_connectors_filters_openai_prefix() {
fn filter_disallowed_connectors_filters_disallowed_connector_ids() {
let filtered = filter_disallowed_connectors(vec![
app("asdk_app_6938a94a61d881918ef32cb999ff937c"),
app("connector_3f8d1a79f27c4c7ba1a897ab13bf37dc"),
app("delta"),
]);
assert_eq!(filtered, vec![app("delta")]);
@@ -979,8 +981,8 @@ fn first_party_chat_originator_filters_target_and_openai_prefixed_connectors() {
}
#[test]
fn filter_tool_suggest_discoverable_tools_keeps_only_allowlisted_uninstalled_apps() {
let filtered = filter_tool_suggest_discoverable_tools(
fn filter_tool_suggest_discoverable_connectors_keeps_only_plugin_backed_uninstalled_apps() {
let filtered = filter_tool_suggest_discoverable_connectors(
vec![
named_app(
"connector_2128aebfecb84f64a069897515042a44",
@@ -996,6 +998,10 @@ fn filter_tool_suggest_discoverable_tools_keeps_only_allowlisted_uninstalled_app
"Google Calendar",
)
}],
&HashSet::from([
"connector_2128aebfecb84f64a069897515042a44".to_string(),
"connector_68df038e0ba48191908c8434991bbac2".to_string(),
]),
);
assert_eq!(
@@ -1008,8 +1014,8 @@ fn filter_tool_suggest_discoverable_tools_keeps_only_allowlisted_uninstalled_app
}
#[test]
fn filter_tool_suggest_discoverable_tools_keeps_disabled_accessible_apps() {
let filtered = filter_tool_suggest_discoverable_tools(
fn filter_tool_suggest_discoverable_connectors_excludes_accessible_apps_even_when_disabled() {
let filtered = filter_tool_suggest_discoverable_connectors(
vec![
named_app(
"connector_2128aebfecb84f64a069897515042a44",
@@ -1031,13 +1037,11 @@ fn filter_tool_suggest_discoverable_tools_keeps_disabled_accessible_apps() {
..named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail")
},
],
&HashSet::from([
"connector_2128aebfecb84f64a069897515042a44".to_string(),
"connector_68df038e0ba48191908c8434991bbac2".to_string(),
]),
);
assert_eq!(
filtered,
vec![named_app(
"connector_68df038e0ba48191908c8434991bbac2",
"Gmail"
)]
);
assert_eq!(filtered, Vec::<AppInfo>::new());
}

View File

@@ -0,0 +1,72 @@
use anyhow::Context;
use tracing::warn;
use super::OPENAI_CURATED_MARKETPLACE_NAME;
use super::PluginDetailSummary;
use super::PluginReadRequest;
use super::PluginsManager;
use crate::config::Config;
use crate::features::Feature;
const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[
"github@openai-curated",
"notion@openai-curated",
"slack@openai-curated",
"gmail@openai-curated",
"google-calendar@openai-curated",
"google-docs@openai-curated",
"google-drive@openai-curated",
"google-sheets@openai-curated",
"google-slides@openai-curated",
];
pub(crate) fn list_tool_suggest_discoverable_plugins(
config: &Config,
) -> anyhow::Result<Vec<PluginDetailSummary>> {
if !config.features.enabled(Feature::Plugins) {
return Ok(Vec::new());
}
let plugins_manager = PluginsManager::new(config.codex_home.clone());
let marketplaces = plugins_manager
.list_marketplaces_for_config(config, &[])
.context("failed to list plugin marketplaces for tool suggestions")?;
let Some(curated_marketplace) = marketplaces
.into_iter()
.find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
else {
return Ok(Vec::new());
};
let mut discoverable_plugins = Vec::new();
for plugin in curated_marketplace.plugins {
if plugin.installed
|| !TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str())
{
continue;
}
let plugin_id = plugin.id.clone();
let plugin_name = plugin.name.clone();
match plugins_manager.read_plugin_for_config(
config,
&PluginReadRequest {
plugin_name,
marketplace_path: curated_marketplace.path.clone(),
},
) {
Ok(plugin) => discoverable_plugins.push(plugin.plugin),
Err(err) => warn!("failed to load curated plugin suggestion {plugin_id}: {err:#}"),
}
}
discoverable_plugins.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then_with(|| left.id.cmp(&right.id))
});
Ok(discoverable_plugins)
}
#[cfg(test)]
#[path = "discoverable_tests.rs"]
mod tests;

View File

@@ -0,0 +1,184 @@
use super::*;
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use crate::plugins::PluginInstallRequest;
use crate::tools::discoverable::DiscoverablePluginInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::Path;
use tempfile::tempdir;
fn write_file(path: &Path, contents: &str) {
fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap();
fs::write(path, contents).unwrap();
}
fn write_curated_plugin(root: &Path, plugin_name: &str) {
let plugin_root = root.join("plugins").join(plugin_name);
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
&format!(
r#"{{
"name": "{plugin_name}",
"description": "Plugin that includes skills, MCP servers, and app connectors"
}}"#
),
);
write_file(
&plugin_root.join("skills/SKILL.md"),
"---\nname: sample\ndescription: sample\n---\n",
);
write_file(
&plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample-docs": {
"type": "http",
"url": "https://sample.example/mcp"
}
}
}"#,
);
write_file(
&plugin_root.join(".app.json"),
r#"{
"apps": {
"calendar": {
"id": "connector_calendar"
}
}
}"#,
);
}
fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) {
let plugins = plugin_names
.iter()
.map(|plugin_name| {
format!(
r#"{{
"name": "{plugin_name}",
"source": {{
"source": "local",
"path": "./plugins/{plugin_name}"
}}
}}"#
)
})
.collect::<Vec<_>>()
.join(",\n");
write_file(
&root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "{OPENAI_CURATED_MARKETPLACE_NAME}",
"plugins": [
{plugins}
]
}}"#
),
);
for plugin_name in plugin_names {
write_curated_plugin(root, plugin_name);
}
}
fn write_curated_plugin_sha(codex_home: &Path) {
write_file(
&codex_home.join(".tmp/plugins.sha"),
"0123456789abcdef0123456789abcdef01234567\n",
);
}
fn write_plugins_feature_config(codex_home: &Path) {
write_file(
&codex_home.join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
}
async fn load_plugins_config(codex_home: &Path) -> crate::config::Config {
ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.fallback_cwd(Some(codex_home.to_path_buf()))
.build()
.await
.expect("config should load")
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plugins() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["sample", "slack"]);
write_plugins_feature_config(codex_home.path());
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.unwrap()
.into_iter()
.map(DiscoverablePluginInfo::from)
.collect::<Vec<_>>();
assert_eq!(
discoverable_plugins,
vec![DiscoverablePluginInfo {
id: "slack@openai-curated".to_string(),
name: "slack".to_string(),
description: Some(
"Plugin that includes skills, MCP servers, and app connectors".to_string(),
),
has_skills: true,
mcp_server_names: vec!["sample-docs".to_string()],
app_connector_ids: vec!["connector_calendar".to_string()],
}]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_feature_disabled() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.unwrap()
.into_iter()
.map(DiscoverablePluginInfo::from)
.collect::<Vec<_>>();
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
write_curated_plugin_sha(codex_home.path());
write_plugins_feature_config(codex_home.path());
PluginsManager::new(codex_home.path().to_path_buf())
.install_plugin(PluginInstallRequest {
plugin_name: "slack".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;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config)
.unwrap()
.into_iter()
.map(DiscoverablePluginInfo::from)
.collect::<Vec<_>>();
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}

View File

@@ -68,7 +68,7 @@ use tracing::warn;
const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false);
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;

View File

@@ -1,4 +1,5 @@
mod curated_repo;
mod discoverable;
mod injection;
mod manager;
mod manifest;
@@ -11,11 +12,13 @@ mod toggles;
pub(crate) use curated_repo::curated_plugins_repo_path;
pub(crate) use curated_repo::read_curated_plugins_sha;
pub(crate) use curated_repo::sync_openai_plugins_repo;
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
pub(crate) use injection::build_plugin_injections;
pub use manager::AppConnectorId;
pub use manager::ConfiguredMarketplacePluginSummary;
pub use manager::ConfiguredMarketplaceSummary;
pub use manager::LoadedPlugin;
pub use manager::OPENAI_CURATED_MARKETPLACE_NAME;
pub use manager::PluginCapabilitySummary;
pub use manager::PluginDetailSummary;
pub use manager::PluginInstallError;

View File

@@ -1,4 +1,5 @@
use crate::plugins::PluginCapabilitySummary;
use crate::plugins::PluginDetailSummary;
use codex_app_server_protocol::AppInfo;
use serde::Deserialize;
use serde::Serialize;
@@ -69,6 +70,13 @@ impl DiscoverableTool {
Self::Plugin(plugin) => plugin.description.as_deref(),
}
}
pub(crate) fn install_url(&self) -> Option<&str> {
match self {
Self::Connector(connector) => connector.install_url.as_deref(),
Self::Plugin(_) => None,
}
}
}
impl From<AppInfo> for DiscoverableTool {
@@ -109,3 +117,20 @@ impl From<PluginCapabilitySummary> for DiscoverablePluginInfo {
}
}
}
impl From<PluginDetailSummary> for DiscoverablePluginInfo {
fn from(value: PluginDetailSummary) -> Self {
Self {
id: value.id,
name: value.name,
description: value.description,
has_skills: !value.skills.is_empty(),
mcp_server_names: value.mcp_server_names,
app_connector_ids: value
.apps
.into_iter()
.map(|connector_id| connector_id.0)
.collect(),
}
}
}

View File

@@ -59,7 +59,8 @@ struct ToolSuggestMeta<'a> {
suggest_reason: &'a str,
tool_id: &'a str,
tool_name: &'a str,
install_url: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
install_url: Option<&'a str>,
}
#[async_trait]
@@ -95,15 +96,9 @@ impl ToolHandler for ToolSuggestHandler {
"suggest_reason must not be empty".to_string(),
));
}
if args.tool_type == DiscoverableToolType::Plugin {
return Err(FunctionCallError::RespondToModel(
"plugin tool suggestions are not currently available".to_string(),
));
}
if args.action_type != DiscoverableToolAction::Install {
return Err(FunctionCallError::RespondToModel(
"connector tool suggestions currently support only action_type=\"install\""
.to_string(),
"tool suggestions currently support only action_type=\"install\"".to_string(),
));
}
@@ -121,26 +116,15 @@ impl ToolHandler for ToolSuggestHandler {
&accessible_connectors,
)
.await
.map(|connectors| {
connectors
.into_iter()
.map(DiscoverableTool::from)
.collect::<Vec<_>>()
})
.map_err(|err| {
FunctionCallError::RespondToModel(format!(
"tool suggestions are unavailable right now: {err}"
))
})?;
let connector = discoverable_tools
let tool = discoverable_tools
.into_iter()
.find_map(|tool| match tool {
DiscoverableTool::Connector(connector) if connector.id == args.tool_id => {
Some(*connector)
}
DiscoverableTool::Connector(_) | DiscoverableTool::Plugin(_) => None,
})
.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 exposed by {TOOL_SUGGEST_TOOL_NAME}"
@@ -153,7 +137,7 @@ impl ToolHandler for ToolSuggestHandler {
turn.sub_id.clone(),
&args,
suggest_reason,
&connector,
&tool,
);
let response = session
.request_mcp_server_elicitation(turn.as_ref(), request_id, params)
@@ -163,37 +147,12 @@ impl ToolHandler for ToolSuggestHandler {
.is_some_and(|response| response.action == ElicitationAction::Accept);
let completed = if user_confirmed {
let manager = session.services.mcp_connection_manager.read().await;
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.as_ref(),
&mcp_tools,
);
verified_connector_suggestion_completed(
args.action_type,
connector.id.as_str(),
&accessible_connectors,
)
}
Err(err) => {
warn!(
"failed to refresh codex apps tools cache after tool suggestion for {}: {err:#}",
connector.id
);
false
}
}
verify_tool_suggestion_completed(&session, &turn, &tool, auth.as_ref()).await
} else {
false
};
if completed {
if completed && let DiscoverableTool::Connector(connector) = &tool {
session
.merge_connector_selection(HashSet::from([connector.id.clone()]))
.await;
@@ -204,8 +163,8 @@ impl ToolHandler for ToolSuggestHandler {
user_confirmed,
tool_type: args.tool_type,
action_type: args.action_type,
tool_id: connector.id,
tool_name: connector.name,
tool_id: tool.id().to_string(),
tool_name: tool.name().to_string(),
suggest_reason: suggest_reason.to_string(),
})
.map_err(|err| {
@@ -223,18 +182,11 @@ fn build_tool_suggestion_elicitation_request(
turn_id: String,
args: &ToolSuggestArgs,
suggest_reason: &str,
connector: &AppInfo,
tool: &DiscoverableTool,
) -> McpServerElicitationRequestParams {
let tool_name = connector.name.clone();
let install_url = connector
.install_url
.clone()
.unwrap_or_else(|| connectors::connector_install_url(&tool_name, &connector.id));
let message = format!(
"{tool_name} could help with this request.\n\n{suggest_reason}\n\nOpen ChatGPT to {} it, then confirm here if you finish.",
args.action_type.as_str()
);
let tool_name = tool.name().to_string();
let install_url = tool.install_url().map(ToString::to_string);
let message = suggest_reason.to_string();
McpServerElicitationRequestParams {
thread_id,
@@ -245,9 +197,9 @@ fn build_tool_suggestion_elicitation_request(
args.tool_type,
args.action_type,
suggest_reason,
connector.id.as_str(),
tool.id(),
tool_name.as_str(),
install_url.as_str(),
install_url.as_deref(),
))),
message,
requested_schema: McpElicitationSchema {
@@ -266,7 +218,7 @@ fn build_tool_suggestion_meta<'a>(
suggest_reason: &'a str,
tool_id: &'a str,
tool_name: &'a str,
install_url: &'a str,
install_url: Option<&'a str>,
) -> ToolSuggestMeta<'a> {
ToolSuggestMeta {
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
@@ -279,18 +231,74 @@ fn build_tool_suggestion_meta<'a>(
}
}
async fn verify_tool_suggestion_completed(
session: &crate::codex::Session,
turn: &crate::codex::TurnContext,
tool: &DiscoverableTool,
auth: Option<&crate::CodexAuth>,
) -> bool {
match tool {
DiscoverableTool::Connector(connector) => {
let manager = session.services.mcp_connection_manager.read().await;
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,
);
verified_connector_suggestion_completed(
connector.id.as_str(),
&accessible_connectors,
)
}
Err(err) => {
warn!(
"failed to refresh codex apps tools cache after tool suggestion for {}: {err:#}",
connector.id
);
false
}
}
}
DiscoverableTool::Plugin(plugin) => {
session.reload_user_config_layer().await;
let config = session.get_config().await;
verified_plugin_suggestion_completed(
plugin.id.as_str(),
config.as_ref(),
session.services.plugins_manager.as_ref(),
)
}
}
}
fn verified_connector_suggestion_completed(
action_type: DiscoverableToolAction,
tool_id: &str,
accessible_connectors: &[AppInfo],
) -> bool {
accessible_connectors
.iter()
.find(|connector| connector.id == tool_id)
.is_some_and(|connector| match action_type {
DiscoverableToolAction::Install => connector.is_accessible,
DiscoverableToolAction::Enable => connector.is_accessible && connector.is_enabled,
})
.is_some_and(|connector| connector.is_accessible)
}
fn verified_plugin_suggestion_completed(
tool_id: &str,
config: &crate::config::Config,
plugins_manager: &crate::plugins::PluginsManager,
) -> bool {
plugins_manager
.list_marketplaces_for_config(config, &[])
.ok()
.into_iter()
.flatten()
.flat_map(|marketplace| marketplace.plugins.into_iter())
.any(|plugin| plugin.id == tool_id && plugin.installed)
}
#[cfg(test)]

View File

@@ -1,5 +1,107 @@
use super::*;
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use crate::plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use crate::plugins::PluginInstallRequest;
use crate::plugins::PluginsManager;
use crate::tools::discoverable::DiscoverablePluginInfo;
use codex_app_server_protocol::AppInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use tempfile::tempdir;
fn write_file(path: &Path, contents: &str) {
fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap();
fs::write(path, contents).unwrap();
}
fn write_curated_plugin(root: &Path, plugin_name: &str) {
let plugin_root = root.join("plugins").join(plugin_name);
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
&format!(
r#"{{
"name": "{plugin_name}",
"description": "Plugin that includes skills, MCP servers, and app connectors"
}}"#
),
);
write_file(
&plugin_root.join("skills/SKILL.md"),
"---\nname: sample\ndescription: sample\n---\n",
);
write_file(
&plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample-docs": {
"type": "http",
"url": "https://sample.example/mcp"
}
}
}"#,
);
write_file(
&plugin_root.join(".app.json"),
r#"{
"apps": {
"calendar": {
"id": "connector_calendar"
}
}
}"#,
);
}
fn write_openai_curated_marketplace(root: &Path, plugin_name: &str) {
write_file(
&root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "{OPENAI_CURATED_MARKETPLACE_NAME}",
"plugins": [
{{
"name": "{plugin_name}",
"source": {{
"source": "local",
"path": "./plugins/{plugin_name}"
}}
}}
]
}}"#
),
);
write_curated_plugin(root, plugin_name);
}
fn write_curated_plugin_sha(codex_home: &Path) {
write_file(
&codex_home.join(".tmp/plugins.sha"),
"0123456789abcdef0123456789abcdef01234567\n",
);
}
fn write_plugins_feature_config(codex_home: &Path) {
write_file(
&codex_home.join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
}
async fn load_plugin_config(codex_home: &Path) -> crate::config::Config {
ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.fallback_cwd(Some(codex_home.to_path_buf()))
.build()
.await
.expect("config should load")
}
#[test]
fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
@@ -9,7 +111,7 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
suggest_reason: "Plan and reference events from your calendar".to_string(),
};
let connector = AppInfo {
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()),
@@ -26,7 +128,7 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
};
}));
let request = build_tool_suggestion_elicitation_request(
"thread-1".to_string(),
@@ -37,31 +139,86 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
);
assert_eq!(
request,
McpServerElicitationRequestParams {
thread_id: "thread-1".to_string(),
turn_id: Some("turn-1".to_string()),
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
request: McpServerElicitationRequest::Form {
meta: Some(json!(ToolSuggestMeta {
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_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: "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44",
})),
message: "Google Calendar could help with this request.\n\nPlan and reference events from your calendar\n\nOpen ChatGPT to install it, then confirm here if you finish.".to_string(),
requested_schema: McpElicitationSchema {
schema_uri: None,
type_: McpElicitationObjectType::Object,
properties: BTreeMap::new(),
required: None,
},
request,
McpServerElicitationRequestParams {
thread_id: "thread-1".to_string(),
turn_id: Some("turn-1".to_string()),
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
request: McpServerElicitationRequest::Form {
meta: Some(json!(ToolSuggestMeta {
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_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"
),
})),
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_tool_suggestion_elicitation_request_for_plugin_omits_install_url() {
let args = ToolSuggestArgs {
tool_type: DiscoverableToolType::Plugin,
action_type: DiscoverableToolAction::Install,
tool_id: "sample@openai-curated".to_string(),
suggest_reason: "Use the sample plugin's skills and MCP server".to_string(),
};
let plugin = DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo {
id: "sample@openai-curated".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_tool_suggestion_elicitation_request(
"thread-1".to_string(),
"turn-1".to_string(),
&args,
"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_MCP_SERVER_NAME.to_string(),
request: McpServerElicitationRequest::Form {
meta: Some(json!(ToolSuggestMeta {
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_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",
tool_name: "Sample Plugin",
install_url: None,
})),
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]
@@ -72,7 +229,7 @@ fn build_tool_suggestion_meta_uses_expected_shape() {
"Find and reference emails from your inbox",
"connector_68df038e0ba48191908c8434991bbac2",
"Gmail",
"https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2",
Some("https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2"),
);
assert_eq!(
@@ -84,13 +241,15 @@ fn build_tool_suggestion_meta_uses_expected_shape() {
suggest_reason: "Find and reference emails from your inbox",
tool_id: "connector_68df038e0ba48191908c8434991bbac2",
tool_name: "Gmail",
install_url: "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2",
install_url: Some(
"https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2"
),
}
);
}
#[test]
fn verified_connector_suggestion_completed_requires_installed_connector() {
fn verified_connector_suggestion_completed_requires_accessible_connector() {
let accessible_connectors = vec![AppInfo {
id: "calendar".to_string(),
name: "Google Calendar".to_string(),
@@ -103,65 +262,52 @@ fn verified_connector_suggestion_completed_requires_installed_connector() {
labels: None,
install_url: None,
is_accessible: true,
is_enabled: true,
is_enabled: false,
plugin_display_names: Vec::new(),
}];
assert!(verified_connector_suggestion_completed(
DiscoverableToolAction::Install,
"calendar",
&accessible_connectors,
));
assert!(!verified_connector_suggestion_completed(
DiscoverableToolAction::Install,
"gmail",
&accessible_connectors,
));
}
#[test]
fn verified_connector_suggestion_completed_requires_enabled_connector_for_enable() {
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(),
},
AppInfo {
id: "gmail".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: None,
is_accessible: true,
is_enabled: true,
plugin_display_names: Vec::new(),
},
];
#[tokio::test]
async fn verified_plugin_suggestion_completed_requires_installed_plugin() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::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());
assert!(!verified_connector_suggestion_completed(
DiscoverableToolAction::Enable,
"calendar",
&accessible_connectors,
let config = load_plugin_config(codex_home.path()).await;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
assert!(!verified_plugin_suggestion_completed(
"sample@openai-curated",
&config,
&plugins_manager,
));
assert!(verified_connector_suggestion_completed(
DiscoverableToolAction::Enable,
"gmail",
&accessible_connectors,
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_plugin_config(codex_home.path()).await;
assert!(verified_plugin_suggestion_completed(
"sample@openai-curated",
&refreshed_config,
&plugins_manager,
));
}

View File

@@ -1824,7 +1824,7 @@ fn format_discoverable_tools(discoverable_tools: &[DiscoverableTool]) -> String
});
let default_action = match tool.tool_type() {
DiscoverableToolType::Connector => DiscoverableToolAction::Install,
DiscoverableToolType::Plugin => DiscoverableToolAction::Enable,
DiscoverableToolType::Plugin => DiscoverableToolAction::Install,
};
format!(
"- {} (id: `{}`, type: {}, action: {}): {}",

View File

@@ -2097,7 +2097,8 @@ fn tool_suggest_description_lists_discoverable_tools() {
assert!(description.contains("Sample Plugin"));
assert!(description.contains("Plan events and schedules."));
assert!(description.contains("Find and summarize email threads."));
assert!(description.contains("id: `sample@test`, type: plugin, action: enable"));
assert!(description.contains("id: `sample@test`, type: plugin, action: install"));
assert!(description.contains("`action_type`: `install` or `enable`"));
assert!(
description.contains("skills; MCP servers: sample-docs; app connectors: connector_sample")
);