Compare commits

...

6 Commits

Author SHA1 Message Date
Dylan Hurd
9689bd16f7 codex: address PR review feedback (#23230) 2026-05-18 00:35:18 -07:00
Dylan Hurd
c7aaadc76f simplify dependency 2026-05-18 00:11:05 -07:00
Dylan Hurd
dd72c7cb18 simplify 2026-05-17 22:55:22 -07:00
Dylan Hurd
049ddd2681 codex: fix CI failure on PR #23230 2026-05-17 22:55:22 -07:00
Dylan Hurd
4203429e33 codex: fix CI failure on PR #23230 2026-05-17 22:55:21 -07:00
Dylan Hurd
e62d0a7b49 codex: add installable plugins list tool 2026-05-17 22:55:21 -07:00
30 changed files with 565 additions and 143 deletions

15
codex-rs/Cargo.lock generated
View File

@@ -1914,6 +1914,7 @@ dependencies = [
"codex-models-manager",
"codex-otel",
"codex-plugin",
"codex-plugins-extension",
"codex-protocol",
"codex-rmcp-client",
"codex-rollout",
@@ -2514,6 +2515,7 @@ dependencies = [
"codex-network-proxy",
"codex-otel",
"codex-plugin",
"codex-plugins-extension",
"codex-protocol",
"codex-response-debug-context",
"codex-rmcp-client",
@@ -3392,6 +3394,18 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "codex-plugins-extension"
version = "0.0.0"
dependencies = [
"async-trait",
"codex-extension-api",
"codex-tools",
"pretty_assertions",
"serde_json",
"tokio",
]
[[package]]
name = "codex-process-hardening"
version = "0.0.0"
@@ -4361,6 +4375,7 @@ dependencies = [
"codex-login",
"codex-model-provider-info",
"codex-models-manager",
"codex-plugins-extension",
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",

View File

@@ -47,6 +47,7 @@ members = [
"ext/extension-api",
"ext/guardian",
"ext/memories",
"ext/plugins",
"external-agent-migration",
"external-agent-sessions",
"keyring-store",
@@ -189,6 +190,7 @@ codex-network-proxy = { path = "network-proxy" }
codex-ollama = { path = "ollama" }
codex-otel = { path = "otel" }
codex-plugin = { path = "plugin" }
codex-plugins-extension = { path = "ext/plugins" }
codex-model-provider = { path = "model-provider" }
codex-process-hardening = { path = "process-hardening" }
codex-protocol = { path = "protocol" }

View File

@@ -47,6 +47,7 @@ codex-file-watcher = { workspace = true }
codex-hooks = { workspace = true }
codex-otel = { workspace = true }
codex-plugin = { workspace = true }
codex-plugins-extension = { workspace = true }
codex-shell-command = { workspace = true }
codex-utils-cli = { workspace = true }
codex-utils-pty = { workspace = true }

View File

@@ -19,6 +19,7 @@ where
let mut builder = ExtensionRegistryBuilder::<Config>::new();
codex_guardian::install(&mut builder, guardian_agent_spawner);
codex_memories_extension::install(&mut builder);
codex_plugins_extension::install(&mut builder);
Arc::new(builder.build())
}

View File

@@ -50,6 +50,7 @@ codex-hooks = { workspace = true }
codex-network-proxy = { workspace = true }
codex-otel = { workspace = true }
codex-plugin = { workspace = true }
codex-plugins-extension = { workspace = true }
codex-model-provider = { workspace = true }
codex-protocol = { workspace = true }
codex-response-debug-context = { workspace = true }

View File

@@ -487,6 +487,9 @@
"plugin_hooks": {
"type": "boolean"
},
"plugin_install_list_tool": {
"type": "boolean"
},
"plugin_sharing": {
"type": "boolean"
},
@@ -4273,6 +4276,9 @@
"plugin_hooks": {
"type": "boolean"
},
"plugin_install_list_tool": {
"type": "boolean"
},
"plugin_sharing": {
"type": "boolean"
},

View File

@@ -113,6 +113,7 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
config: &Config,
auth: Option<&CodexAuth>,
accessible_connectors: &[AppInfo],
plugins_manager: &PluginsManager,
) -> anyhow::Result<Vec<DiscoverableTool>> {
let connector_ids = tool_suggest_connector_ids(config).await;
let directory_connectors = codex_connectors::merge::merge_plugin_connectors(
@@ -128,7 +129,7 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
)
.into_iter()
.map(DiscoverableTool::from);
let discoverable_plugins = list_tool_suggest_discoverable_plugins(config)
let discoverable_plugins = list_tool_suggest_discoverable_plugins(config, plugins_manager)
.await?
.into_iter()
.map(DiscoverableTool::from);

View File

@@ -16,6 +16,7 @@ use codex_config::types::AppsDefaultConfig;
use codex_connectors::merge::plugin_connector_to_app_info;
use codex_connectors::metadata::connector_install_url;
use codex_connectors::metadata::sanitize_name;
use codex_core_plugins::PluginsManager;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
@@ -1253,8 +1254,9 @@ discoverables = [
.expect("config should load");
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf());
let discoverable_tools =
list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[])
list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[], &plugins_manager)
.await
.expect("discoverable tools should load");

View File

@@ -19,12 +19,12 @@ const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[
pub(crate) async fn list_tool_suggest_discoverable_plugins(
config: &Config,
plugins_manager: &PluginsManager,
) -> anyhow::Result<Vec<DiscoverablePluginInfo>> {
if !config.features.enabled(Feature::Plugins) {
return Ok(Vec::new());
}
let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf());
let plugins_input = config.plugins_config_input();
let configured_plugin_ids = config
.tool_suggest

View File

@@ -6,6 +6,7 @@ use crate::plugins::test_support::write_file;
use crate::plugins::test_support::write_openai_curated_marketplace;
use crate::plugins::test_support::write_plugins_feature_config;
use codex_core_plugins::PluginInstallRequest;
use codex_core_plugins::PluginsManager;
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
use codex_tools::DiscoverablePluginInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -15,6 +16,13 @@ use tracing::Level;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_test::internal::MockWriter;
async fn discoverable_plugins(
config: &crate::config::Config,
) -> anyhow::Result<Vec<DiscoverablePluginInfo>> {
let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf());
list_tool_suggest_discoverable_plugins(config, &plugins_manager).await
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plugins() {
let codex_home = tempdir().expect("tempdir should succeed");
@@ -23,9 +31,7 @@ async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plug
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)
.await
.unwrap();
let discoverable_plugins = discoverable_plugins(&config).await.unwrap();
assert_eq!(
discoverable_plugins,
@@ -65,9 +71,7 @@ async fn list_tool_suggest_discoverable_plugins_returns_microsoft_curated_plugin
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)
.await
.unwrap();
let discoverable_plugins = discoverable_plugins(&config).await.unwrap();
assert_eq!(
discoverable_plugins
@@ -133,9 +137,7 @@ discoverables = [{{ type = "plugin", id = "{plugin_id}" }}]
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.await
.unwrap();
let discoverable_plugins = discoverable_plugins(&config).await.unwrap();
assert_eq!(discoverable_plugins.len(), 1);
assert_eq!(discoverable_plugins[0].id, plugin_id);
@@ -184,9 +186,7 @@ source = "/tmp/{marketplace_name}"
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.await
.unwrap();
let discoverable_plugins = discoverable_plugins(&config).await.unwrap();
assert_eq!(discoverable_plugins.len(), 1);
assert_eq!(discoverable_plugins[0].id, "slack@openai-curated");
@@ -205,9 +205,7 @@ plugins = false
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.await
.unwrap();
let discoverable_plugins = discoverable_plugins(&config).await.unwrap();
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}
@@ -227,9 +225,7 @@ async fn list_tool_suggest_discoverable_plugins_normalizes_description() {
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.await
.unwrap();
let discoverable_plugins = discoverable_plugins(&config).await.unwrap();
assert_eq!(
discoverable_plugins,
@@ -264,9 +260,7 @@ async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins(
.expect("plugin should install");
let refreshed_config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config)
.await
.unwrap();
let discoverable_plugins = discoverable_plugins(&refreshed_config).await.unwrap();
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}
@@ -289,9 +283,7 @@ disabled_tools = [
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.await
.unwrap();
let discoverable_plugins = discoverable_plugins(&config).await.unwrap();
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}
@@ -312,9 +304,7 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }]
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.await
.unwrap();
let discoverable_plugins = discoverable_plugins(&config).await.unwrap();
assert_eq!(
discoverable_plugins,
@@ -369,9 +359,7 @@ async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.await
.unwrap();
let discoverable_plugins = discoverable_plugins(&config).await.unwrap();
assert_eq!(discoverable_plugins.len(), 1);
assert_eq!(discoverable_plugins[0].id, "slack@openai-curated");

View File

@@ -971,6 +971,34 @@ impl Session {
services,
next_internal_sub_id: AtomicU64::new(0),
});
let session = Arc::downgrade(&sess);
sess.services.session_extension_data.insert(
codex_plugins_extension::InstallablePluginsProviderHandle::from_fn(move || {
let session = session.clone();
Box::pin(async move {
let session = session.upgrade().ok_or_else(|| {
"plugin install requests are unavailable right now".to_string()
})?;
let config = session.get_config().await;
let app_server_client_metadata =
session.app_server_client_metadata().await;
let discoverable_tools =
crate::tools::handlers::discoverable_request_plugin_install_tools(
session.as_ref(),
config.as_ref(),
app_server_client_metadata.client_name.as_deref(),
)
.await
.map_err(|err| {
format!("plugin install requests are unavailable right now: {err}")
})?;
Ok(codex_tools::collect_request_plugin_install_entries(
&discoverable_tools,
))
})
}),
);
if let Some(network_policy_decider_session) = network_policy_decider_session {
let mut guard = network_policy_decider_session.write().await;
*guard = Arc::downgrade(&sess);

View File

@@ -1210,6 +1210,7 @@ pub(crate) async fn built_tools(
&turn_context.config,
auth.as_ref(),
accessible_connectors.as_slice(),
sess.services.plugins_manager.as_ref(),
)
.await
.map(|discoverable_tools| {

View File

@@ -61,6 +61,7 @@ pub use mcp_resource::ReadMcpResourceHandler;
pub use plan::PlanHandler;
pub use request_permissions::RequestPermissionsHandler;
pub use request_plugin_install::RequestPluginInstallHandler;
pub(crate) use request_plugin_install::discoverable_request_plugin_install_tools;
pub use request_user_input::RequestUserInputHandler;
pub use shell::ShellCommandHandler;
pub(crate) use shell::ShellCommandHandlerOptions;

View File

@@ -25,10 +25,12 @@ use rmcp::model::RequestId;
use serde_json::Value;
use tracing::warn;
use crate::config::Config;
use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
use crate::connectors;
use crate::function_tool::FunctionCallError;
use crate::session::session::Session;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
@@ -38,15 +40,52 @@ use crate::tools::handlers::request_plugin_install_spec::create_request_plugin_i
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
#[derive(Default)]
pub struct RequestPluginInstallHandler {
discoverable_tools: Vec<RequestPluginInstallEntry>,
plugin_install_list_tool: bool,
}
#[expect(
clippy::await_holding_invalid_type,
reason = "plugin install discovery reads through the session-owned manager guard"
)]
pub(crate) async fn discoverable_request_plugin_install_tools(
session: &Session,
config: &Config,
app_server_client_name: Option<&str>,
) -> anyhow::Result<Vec<DiscoverableTool>> {
let auth = session.services.auth_manager.auth().await;
let manager = session.services.mcp_connection_manager.read().await;
let mcp_tools = manager.list_all_tools().await;
drop(manager);
let accessible_connectors = connectors::with_app_enabled_state(
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
config,
);
connectors::list_tool_suggest_discoverable_tools_with_auth(
config,
auth.as_ref(),
&accessible_connectors,
session.services.plugins_manager.as_ref(),
)
.await
.map(|discoverable_tools| {
filter_request_plugin_install_discoverable_tools_for_client(
discoverable_tools,
app_server_client_name,
)
})
}
impl RequestPluginInstallHandler {
pub(crate) fn new(discoverable_tools: &[DiscoverableTool]) -> Self {
pub(crate) fn new(
discoverable_tools: &[DiscoverableTool],
plugin_install_list_tool: bool,
) -> Self {
Self {
discoverable_tools: collect_request_plugin_install_entries(discoverable_tools),
plugin_install_list_tool,
}
}
}
@@ -58,17 +97,16 @@ impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_request_plugin_install_tool(&self.discoverable_tools))
Some(create_request_plugin_install_tool(
&self.discoverable_tools,
self.plugin_install_list_tool,
))
}
fn supports_parallel_tool_calls(&self) -> bool {
true
}
#[expect(
clippy::await_holding_invalid_type,
reason = "plugin install discovery reads through the session-owned manager guard"
)]
async fn handle(
&self,
invocation: ToolInvocation,
@@ -97,13 +135,16 @@ impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
"suggest_reason must not be empty".to_string(),
));
}
if args.action_type != DiscoverableToolAction::Install {
if args
.action_type
.is_some_and(|action| action != DiscoverableToolAction::Install)
{
return Err(FunctionCallError::RespondToModel(
"plugin install requests currently support only action_type=\"install\""
.to_string(),
));
}
if args.tool_type == DiscoverableToolType::Plugin
if args.tool_type == Some(DiscoverableToolType::Plugin)
&& turn.app_server_client_name.as_deref() == Some("codex-tui")
{
return Err(FunctionCallError::RespondToModel(
@@ -112,46 +153,37 @@ impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
}
let auth = session.services.auth_manager.auth().await;
let manager = session.services.mcp_connection_manager.read().await;
let mcp_tools = manager.list_all_tools().await;
drop(manager);
let accessible_connectors = connectors::with_app_enabled_state(
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
let discoverable_tools = discoverable_request_plugin_install_tools(
session.as_ref(),
&turn.config,
);
let discoverable_tools = connectors::list_tool_suggest_discoverable_tools_with_auth(
&turn.config,
auth.as_ref(),
&accessible_connectors,
turn.app_server_client_name.as_deref(),
)
.await
.map(|discoverable_tools| {
filter_request_plugin_install_discoverable_tools_for_client(
discoverable_tools,
turn.app_server_client_name.as_deref(),
)
})
.map_err(|err| {
FunctionCallError::RespondToModel(format!(
"plugin install requests are unavailable right now: {err}"
))
})?;
let tool = discoverable_tools
let mut matching_tools = discoverable_tools
.into_iter()
.find(|tool| tool.tool_type() == args.tool_type && tool.id() == args.tool_id)
.ok_or_else(|| {
FunctionCallError::RespondToModel(format!(
"tool_id must match one of the discoverable tools exposed by {REQUEST_PLUGIN_INSTALL_TOOL_NAME}"
))
})?;
.filter(|tool| tool.id() == args.tool_id);
let tool = matching_tools.next().ok_or_else(|| {
FunctionCallError::RespondToModel(format!(
"tool_id must match one of the discoverable tools exposed by {REQUEST_PLUGIN_INSTALL_TOOL_NAME}"
))
})?;
if matching_tools.next().is_some() {
return Err(FunctionCallError::RespondToModel(format!(
"tool_id matched more than one discoverable tool exposed by {REQUEST_PLUGIN_INSTALL_TOOL_NAME}"
)));
}
let request_id = RequestId::String(format!("request_plugin_install_{call_id}").into());
let params = build_request_plugin_install_elicitation_request(
CODEX_APPS_MCP_SERVER_NAME,
session.conversation_id.to_string(),
turn.sub_id.clone(),
&args,
suggest_reason,
&tool,
);
@@ -180,8 +212,8 @@ impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
let content = serde_json::to_string(&RequestPluginInstallResult {
completed,
user_confirmed,
tool_type: args.tool_type,
action_type: args.action_type,
tool_type: tool.tool_type(),
action_type: DiscoverableToolAction::Install,
tool_id: tool.id().to_string(),
tool_name: tool.name().to_string(),
suggest_reason: suggest_reason.to_string(),

View File

@@ -1,5 +1,6 @@
use codex_tools::DiscoverableToolType;
use codex_tools::JsonSchema;
use codex_tools::LIST_INSTALLABLE_PLUGINS_TOOL_NAME;
use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME;
use codex_tools::RequestPluginInstallEntry;
use codex_tools::ResponsesApiTool;
@@ -9,19 +10,44 @@ use std::collections::BTreeMap;
pub(crate) fn create_request_plugin_install_tool(
discoverable_tools: &[RequestPluginInstallEntry],
plugin_install_list_tool: bool,
) -> ToolSpec {
let properties = BTreeMap::from([
let (properties, required, description) = if plugin_install_list_tool {
(
"tool_type".to_string(),
JsonSchema::string(Some(
"Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"."
.to_string(),
)),
),
common_properties(),
vec!["tool_id".to_string(), "suggest_reason".to_string()],
format!(
"Only call this tool with a result from `{LIST_INSTALLABLE_PLUGINS_TOOL_NAME}()`, when one of listed plugins or connectors exactly fits the user's request.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools."
),
)
} else {
let discoverable_tools = format_discoverable_tools(discoverable_tools);
(
"action_type".to_string(),
JsonSchema::string(Some("Suggested action for the tool. Use \"install\".".to_string())),
),
legacy_properties(),
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 to ask the user to install one known plugin or connector from the list below. The list contains known candidates that are not currently installed.\n\nUse this ONLY when all of the following are true:\n- The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list.\n- `{TOOL_SEARCH_TOOL_NAME}` is not available, or it has already been called and did not find or make the requested tool callable.\n- The plugin or connector is one of the known installable plugins or connectors listed below. Only ask to install plugins or connectors from this list.\n\nDo not use this tool for adjacent capabilities, broad recommendations, or tools that merely seem useful. Only use when the user explicitly asks to use that exact listed plugin or connector.\n\nKnown plugins/connectors available to install:\n{discoverable_tools}\n\nWorkflow:\n\n1. Check the current context and active `tools` list first. If current active tools aren't relevant and `{TOOL_SEARCH_TOOL_NAME}` is available, only call this tool after `{TOOL_SEARCH_TOOL_NAME}` has already been tried and found no relevant tool.\n2. Match the user's explicit request against the known plugin/connector list above. Only proceed when one listed plugin or connector exactly fits.\n3. If we found both connectors and plugins to install, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not.\n4. If one plugin or connector clearly fits, call `{REQUEST_PLUGIN_INSTALL_TOOL_NAME}` with:\n - `tool_type`: `connector` or `plugin`\n - `action_type`: `install`\n - `tool_id`: exact id from the known plugin/connector list above\n - `suggest_reason`: concise one-line user-facing reason this plugin or connector can help with the current request\n5. After the request flow completes:\n - if the user finished the install flow, continue by searching again or using the newly available plugin or connector\n - if the user did not finish, continue without that plugin or connector, and don't request it again unless the user explicitly asks for it.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools."
),
)
};
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,
})
}
fn common_properties() -> BTreeMap<String, JsonSchema> {
BTreeMap::from([
(
"tool_id".to_string(),
JsonSchema::string(Some("Connector or plugin id to suggest.".to_string())),
@@ -33,30 +59,24 @@ pub(crate) fn create_request_plugin_install_tool(
.to_string(),
)),
),
]);
])
}
let discoverable_tools = format_discoverable_tools(discoverable_tools);
let description = format!(
"# Request plugin/connector install\n\nUse this tool only to ask the user to install one known plugin or connector from the list below. The list contains known candidates that are not currently installed.\n\nUse this ONLY when all of the following are true:\n- The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list.\n- `{TOOL_SEARCH_TOOL_NAME}` is not available, or it has already been called and did not find or make the requested tool callable.\n- The plugin or connector is one of the known installable plugins or connectors listed below. Only ask to install plugins or connectors from this list.\n\nDo not use this tool for adjacent capabilities, broad recommendations, or tools that merely seem useful. Only use when the user explicitly asks to use that exact listed plugin or connector.\n\nKnown plugins/connectors available to install:\n{discoverable_tools}\n\nWorkflow:\n\n1. Check the current context and active `tools` list first. If current active tools aren't relevant and `{TOOL_SEARCH_TOOL_NAME}` is available, only call this tool after `{TOOL_SEARCH_TOOL_NAME}` has already been tried and found no relevant tool.\n2. Match the user's explicit request against the known plugin/connector list above. Only proceed when one listed plugin or connector exactly fits.\n3. If we found both connectors and plugins to install, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not.\n4. If one plugin or connector clearly fits, call `{REQUEST_PLUGIN_INSTALL_TOOL_NAME}` with:\n - `tool_type`: `connector` or `plugin`\n - `action_type`: `install`\n - `tool_id`: exact id from the known plugin/connector list above\n - `suggest_reason`: concise one-line user-facing reason this plugin or connector can help with the current request\n5. After the request flow completes:\n - if the user finished the install flow, continue by searching again or using the newly available plugin or connector\n - if the user did not finish, continue without that plugin or connector, and don't request it again unless the user explicitly asks for it.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools."
fn legacy_properties() -> BTreeMap<String, JsonSchema> {
let mut properties = common_properties();
properties.insert(
"tool_type".to_string(),
JsonSchema::string(Some(
"Type of discoverable tool to suggest. Use \"connector\" or \"plugin\".".to_string(),
)),
);
ToolSpec::Function(ResponsesApiTool {
name: REQUEST_PLUGIN_INSTALL_TOOL_NAME.to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
properties,
Some(vec![
"tool_type".to_string(),
"action_type".to_string(),
"tool_id".to_string(),
"suggest_reason".to_string(),
]),
Some(false.into()),
),
output_schema: None,
})
properties.insert(
"action_type".to_string(),
JsonSchema::string(Some(
"Suggested action for the tool. Use \"install\".".to_string(),
)),
);
properties
}
fn format_discoverable_tools(discoverable_tools: &[RequestPluginInstallEntry]) -> String {
@@ -182,7 +202,7 @@ mod tests {
mcp_server_names: vec!["github-mcp".to_string()],
app_connector_ids: vec!["github-app".to_string()],
},
]),
], /*plugin_install_list_tool*/ false),
ToolSpec::Function(ResponsesApiTool {
name: "request_plugin_install".to_string(),
description: expected_description.to_string(),

View File

@@ -59,6 +59,7 @@ use codex_mcp::ToolInfo;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_tools::DiscoverableTool;
use codex_tools::LIST_INSTALLABLE_PLUGINS_TOOL_NAME;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::TOOL_SEARCH_TOOL_NAME;
@@ -84,6 +85,7 @@ struct ToolRegistryBuildParams<'a> {
deferred_mcp_tools: Option<&'a [ToolInfo]>,
discoverable_tools: Option<&'a [DiscoverableTool]>,
extension_tool_executors: &'a [Arc<dyn ToolExecutor<ExtensionToolCall>>],
plugin_install_list_tool_enabled: bool,
dynamic_tools: &'a [DynamicToolSpec],
default_agent_type_description: &'a str,
wait_agent_timeouts: WaitAgentTimeoutOptions,
@@ -105,6 +107,14 @@ fn build_tool_specs_and_registry(
extension_tool_executors,
dynamic_tools,
} = params;
let list_installable_plugins_registered = extension_tool_executors.iter().any(|executor| {
executor.tool_name() == ToolName::plain(LIST_INSTALLABLE_PLUGINS_TOOL_NAME)
});
let plugin_install_list_tool_enabled = config.plugin_install_list_tool
&& discoverable_tools
.as_ref()
.is_some_and(|tools| !tools.is_empty())
&& list_installable_plugins_registered;
let default_agent_type_description =
crate::agent::role::spawn_tool_spec::build(&std::collections::BTreeMap::new());
let mut executors = collect_tool_executors(
@@ -114,6 +124,7 @@ fn build_tool_specs_and_registry(
deferred_mcp_tools: deferred_mcp_tools.as_deref(),
discoverable_tools: discoverable_tools.as_deref(),
extension_tool_executors: &extension_tool_executors,
plugin_install_list_tool_enabled,
dynamic_tools,
default_agent_type_description: &default_agent_type_description,
wait_agent_timeouts: wait_agent_timeout_options(config),
@@ -418,6 +429,7 @@ fn collect_tool_executors(
{
executors.push(Arc::new(RequestPluginInstallHandler::new(
discoverable_tools,
params.plugin_install_list_tool_enabled,
)));
}
@@ -546,7 +558,12 @@ fn collect_tool_executors(
executors.push(handler);
}
append_extension_tool_executors(config, params.extension_tool_executors, &mut executors);
append_extension_tool_executors(
config,
params.extension_tool_executors,
params.plugin_install_list_tool_enabled,
&mut executors,
);
executors
}
@@ -587,6 +604,7 @@ fn prepend_code_mode_executors(
fn append_extension_tool_executors(
config: &ToolsConfig,
executors: &[Arc<dyn ToolExecutor<ExtensionToolCall>>],
plugin_install_list_tool_enabled: bool,
registered_executors: &mut Vec<Arc<dyn CoreToolRuntime>>,
) {
if executors.is_empty() {
@@ -612,6 +630,11 @@ fn append_extension_tool_executors(
for executor in executors.iter().cloned() {
let tool_name = executor.tool_name();
if tool_name == ToolName::plain(LIST_INSTALLABLE_PLUGINS_TOOL_NAME)
&& !plugin_install_list_tool_enabled
{
continue;
}
if !reserved_tool_names.insert(tool_name.clone()) {
warn!("Skipping extension tool `{tool_name}`: tool already registered");
continue;

View File

@@ -1970,7 +1970,10 @@ fn request_plugin_install_is_not_registered_without_feature_flag() {
"Google Calendar",
"Plan events and schedules.",
)]),
/*extension_tool_executors*/ &[],
&[extension_tool_executor(
LIST_INSTALLABLE_PLUGINS_TOOL_NAME,
"List installable plugins.",
)],
&[],
);
@@ -1979,6 +1982,7 @@ fn request_plugin_install_is_not_registered_without_feature_flag() {
.iter()
.any(|tool| tool.name() == REQUEST_PLUGIN_INSTALL_TOOL_NAME)
);
assert_lacks_tool_name(&tools, LIST_INSTALLABLE_PLUGINS_TOOL_NAME);
}
#[test]
@@ -2011,7 +2015,10 @@ fn request_plugin_install_can_be_registered_without_search_tool() {
"Google Calendar",
"Plan events and schedules.",
)]),
/*extension_tool_executors*/ &[],
&[extension_tool_executor(
LIST_INSTALLABLE_PLUGINS_TOOL_NAME,
"List installable plugins.",
)],
&[],
);
@@ -2019,19 +2026,71 @@ fn request_plugin_install_can_be_registered_without_search_tool() {
let request_plugin_install = find_tool(&tools, REQUEST_PLUGIN_INSTALL_TOOL_NAME);
assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME);
let ToolSpec::Function(ResponsesApiTool { description, .. }) = request_plugin_install else {
let ToolSpec::Function(ResponsesApiTool {
description,
parameters,
..
}) = request_plugin_install
else {
panic!("expected function tool");
};
assert!(description.contains(
"Use this tool only to ask the user to install one known plugin or connector from the list below. The list contains known candidates that are not currently installed."
));
assert!(description.contains(
"`tool_search` is not available, or it has already been called and did not find or make the requested tool callable."
));
assert!(
description.contains("Only call this tool with a result from `list_installable_plugins()`")
);
assert!(!description.contains("Known plugins/connectors available to install:"));
let (_, required) = expect_object_schema(parameters);
assert_eq!(
required,
Some(&vec!["tool_id".to_string(), "suggest_reason".to_string()])
);
}
#[test]
fn request_plugin_install_description_lists_discoverable_tools() {
fn request_plugin_install_description_lists_discoverable_tools_when_list_feature_disabled() {
let model_info = search_capable_model_info();
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
features.enable(Feature::Plugins);
features.enable(Feature::ToolSuggest);
features.disable(Feature::PluginInstallListTool);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
image_generation_tool_auth_allowed: true,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
permission_profile: &PermissionProfile::Disabled,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
let (tools, _) = build_specs_with_inputs_for_test(
&tools_config,
/*mcp_tools*/ None,
/*deferred_mcp_tools*/ None,
Some(vec![discoverable_connector(
"connector_2128aebfecb84f64a069897515042a44",
"Google Calendar",
"Plan events and schedules.",
)]),
&[extension_tool_executor(
LIST_INSTALLABLE_PLUGINS_TOOL_NAME,
"List installable plugins.",
)],
&[],
);
assert_lacks_tool_name(&tools, LIST_INSTALLABLE_PLUGINS_TOOL_NAME);
let request_plugin_install = find_tool(&tools, REQUEST_PLUGIN_INSTALL_TOOL_NAME);
let ToolSpec::Function(ResponsesApiTool { description, .. }) = request_plugin_install else {
panic!("expected function tool");
};
assert!(description.contains("Known plugins/connectors available to install:"));
assert!(!description.contains("Only call this tool with a result from"));
}
#[test]
fn request_plugin_install_description_lists_discoverable_tools_without_list_tool() {
let model_info = search_capable_model_info();
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
@@ -2500,6 +2559,13 @@ fn build_specs_with_inputs_for_test(
deferred_mcp_tools: deferred_mcp_tools.as_deref(),
discoverable_tools: discoverable_tools.as_deref(),
extension_tool_executors,
plugin_install_list_tool_enabled: config.plugin_install_list_tool
&& discoverable_tools
.as_ref()
.is_some_and(|tools| !tools.is_empty())
&& extension_tool_executors.iter().any(|executor| {
executor.tool_name() == ToolName::plain(LIST_INSTALLABLE_PLUGINS_TOOL_NAME)
}),
dynamic_tools,
default_agent_type_description: DEFAULT_AGENT_TYPE_DESCRIPTION,
wait_agent_timeouts: wait_agent_timeout_options(),

View File

@@ -25,6 +25,7 @@ codex-hooks = { workspace = true }
codex-login = { workspace = true }
codex-model-provider-info = { workspace = true }
codex-models-manager = { workspace = true }
codex-plugins-extension = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-cargo-bin = { workspace = true }

View File

@@ -23,6 +23,7 @@ use codex_core::thread_store_from_config;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::RemoveOptions;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::empty_extension_registry;
use codex_login::CodexAuth;
use codex_model_provider_info::ModelProviderInfo;
@@ -219,6 +220,7 @@ pub struct TestCodexBuilder {
cloud_requirements: Option<CloudRequirementsLoader>,
user_shell_override: Option<Shell>,
exec_server_url: Option<String>,
plugins_extension_enabled: bool,
}
impl TestCodexBuilder {
@@ -280,6 +282,11 @@ impl TestCodexBuilder {
self
}
pub fn with_plugins_extension(mut self) -> Self {
self.plugins_extension_enabled = true;
self
}
pub fn with_windows_cmd_shell(self) -> Self {
if cfg!(windows) {
self.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("cmd.exe")))
@@ -468,12 +475,19 @@ impl TestCodexBuilder {
let state_db = codex_core::init_state_db(&config).await;
let thread_store = thread_store_from_config(&config, state_db.clone());
let installation_id = resolve_installation_id(&config.codex_home).await?;
let extensions = if self.plugins_extension_enabled {
let mut builder = ExtensionRegistryBuilder::new();
codex_plugins_extension::install(&mut builder);
Arc::new(builder.build())
} else {
empty_extension_registry()
};
let thread_manager = ThreadManager::new(
&config,
codex_core::test_support::auth_manager_from_auth(auth.clone()),
SessionSource::Exec,
Arc::clone(&environment_manager),
empty_extension_registry(),
extensions,
/*analytics_events_client*/ None,
thread_store,
state_db.clone(),
@@ -1043,6 +1057,7 @@ pub fn test_codex() -> TestCodexBuilder {
cloud_requirements: None,
user_shell_override: None,
exec_server_url: None,
plugins_extension_enabled: false,
}
}

View File

@@ -22,6 +22,7 @@ use core_test_support::test_codex::test_codex;
use serde_json::Value;
const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
const LIST_INSTALLABLE_PLUGINS_TOOL_NAME: &str = "list_installable_plugins";
const REQUEST_PLUGIN_INSTALL_TOOL_NAME: &str = "request_plugin_install";
const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2";
@@ -107,6 +108,7 @@ async fn request_plugin_install_is_available_without_search_tool_after_discovery
let mut builder = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_plugins_extension()
.with_config(move |config| {
configure_apps_without_search_tool(config, apps_server.chatgpt_base_url.as_str())
});
@@ -125,6 +127,12 @@ async fn request_plugin_install_is_available_without_search_tool_after_discovery
!tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME),
"tools list should not include {TOOL_SEARCH_TOOL_NAME}: {tools:?}"
);
assert!(
tools
.iter()
.any(|name| name == LIST_INSTALLABLE_PLUGINS_TOOL_NAME),
"tools list should include {LIST_INSTALLABLE_PLUGINS_TOOL_NAME}: {tools:?}"
);
assert!(
tools
.iter()
@@ -134,17 +142,11 @@ async fn request_plugin_install_is_available_without_search_tool_after_discovery
let description =
function_tool_description(&body, REQUEST_PLUGIN_INSTALL_TOOL_NAME).expect("description");
assert!(description.contains(
"Use this tool only to ask the user to install one known plugin or connector from the list below"
));
assert!(description.contains(
"`tool_search` is not available, or it has already been called and did not find or make the requested tool callable."
));
assert!(description.contains(
"Only use when the user explicitly asks to use that exact listed plugin or connector."
));
assert!(
description.contains("Only call this tool with a result from `list_installable_plugins()`")
);
assert!(description.contains("IMPORTANT: DO NOT call this tool in parallel with other tools."));
assert!(!description.contains("tool_search fails to find a good match"));
assert!(!description.contains("Known plugins/connectors available to install:"));
Ok(())
}

View File

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

View File

@@ -0,0 +1,23 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-plugins-extension"
version.workspace = true
[lib]
name = "codex_plugins_extension"
path = "src/lib.rs"
doctest = false
[lints]
workspace = true
[dependencies]
async-trait = { workspace = true }
codex-extension-api = { workspace = true }
codex-tools = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tokio = { workspace = true, features = ["macros"] }

View File

@@ -0,0 +1,180 @@
use std::collections::BTreeMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::FunctionCallError;
use codex_extension_api::JsonToolOutput;
use codex_extension_api::ResponsesApiTool;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolContributor;
use codex_extension_api::ToolExecutor;
use codex_extension_api::ToolName;
use codex_extension_api::ToolOutput;
use codex_extension_api::ToolSpec;
use codex_tools::JsonSchema;
use codex_tools::LIST_INSTALLABLE_PLUGINS_TOOL_NAME;
use codex_tools::RequestPluginInstallEntry;
use serde_json::json;
#[derive(Clone, Copy, Debug)]
struct PluginsExtension;
pub type InstallablePluginsFuture =
Pin<Box<dyn Future<Output = Result<Vec<RequestPluginInstallEntry>, String>> + Send>>;
#[derive(Clone)]
pub struct InstallablePluginsProviderHandle {
list_installable_plugins: Arc<dyn Fn() -> InstallablePluginsFuture + Send + Sync + 'static>,
}
impl InstallablePluginsProviderHandle {
pub fn from_fn<F>(list_installable_plugins: F) -> Self
where
F: Fn() -> InstallablePluginsFuture + Send + Sync + 'static,
{
Self {
list_installable_plugins: Arc::new(list_installable_plugins),
}
}
async fn list_installable_plugins(&self) -> Result<Vec<RequestPluginInstallEntry>, String> {
(self.list_installable_plugins)().await
}
}
impl ToolContributor for PluginsExtension {
fn tools(
&self,
session_store: &ExtensionData,
_thread_store: &ExtensionData,
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
let Some(provider) = session_store.get::<InstallablePluginsProviderHandle>() else {
return Vec::new();
};
vec![Arc::new(ListInstallablePluginsTool {
provider: provider.as_ref().clone(),
})]
}
}
#[derive(Clone)]
struct ListInstallablePluginsTool {
provider: InstallablePluginsProviderHandle,
}
#[async_trait::async_trait]
impl ToolExecutor<ToolCall> for ListInstallablePluginsTool {
fn tool_name(&self) -> ToolName {
ToolName::plain(LIST_INSTALLABLE_PLUGINS_TOOL_NAME)
}
fn spec(&self) -> Option<ToolSpec> {
Some(ToolSpec::Function(ResponsesApiTool {
name: LIST_INSTALLABLE_PLUGINS_TOOL_NAME.to_string(),
description: "Use this ONLY when all of the following are true:\n- The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list.\n- `tool_search` is not available, or it has already been called and did not find or make the requested tool callable.\nReturns a list of plugins eligible to be installed."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(BTreeMap::new(), Some(Vec::new()), Some(false.into())),
output_schema: None,
}))
}
async fn handle(&self, _call: ToolCall) -> Result<Box<dyn ToolOutput>, FunctionCallError> {
let mut entries = self
.provider
.list_installable_plugins()
.await
.map_err(FunctionCallError::RespondToModel)?;
entries.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then_with(|| left.id.cmp(&right.id))
});
Ok(Box::new(JsonToolOutput::new(json!({ "entries": entries }))))
}
}
/// Installs plugins extension contributors into the supplied extension registry.
pub fn install<C>(registry: &mut ExtensionRegistryBuilder<C>) {
registry.tool_contributor(Arc::new(PluginsExtension));
}
#[cfg(test)]
mod tests {
use super::*;
use codex_tools::DiscoverableToolType;
use pretty_assertions::assert_eq;
#[test]
fn tools_are_not_contributed_without_provider() {
let extension = PluginsExtension;
assert!(
extension
.tools(
&ExtensionData::new("session"),
&ExtensionData::new("thread"),
)
.is_empty()
);
}
#[tokio::test]
async fn list_tool_returns_provider_installable_entries() {
let extension = PluginsExtension;
let session_store = ExtensionData::new("session");
let entries = vec![RequestPluginInstallEntry {
id: "sample@openai-curated".to_string(),
name: "Sample Plugin".to_string(),
description: Some("Adds sample capabilities.".to_string()),
tool_type: DiscoverableToolType::Plugin,
has_skills: true,
mcp_server_names: vec!["sample-docs".to_string()],
app_connector_ids: vec!["connector_sample".to_string()],
}];
session_store.insert(InstallablePluginsProviderHandle::from_fn(move || {
let entries = entries.clone();
Box::pin(async move { Ok(entries) })
}));
let tools = extension.tools(&session_store, &ExtensionData::new("thread"));
assert_eq!(tools.len(), 1);
assert_eq!(
tools[0].tool_name(),
ToolName::plain(LIST_INSTALLABLE_PLUGINS_TOOL_NAME)
);
let payload = codex_extension_api::ToolPayload::Function {
arguments: "{}".to_string(),
};
let output = tools[0]
.handle(ToolCall {
call_id: "call-1".to_string(),
tool_name: ToolName::plain(LIST_INSTALLABLE_PLUGINS_TOOL_NAME),
payload: payload.clone(),
})
.await
.expect("list tool should succeed");
assert_eq!(
output.code_mode_result(&payload),
json!({
"entries": [{
"id": "sample@openai-curated",
"name": "Sample Plugin",
"description": "Adds sample capabilities.",
"tool_type": "plugin",
"has_skills": true,
"mcp_server_names": ["sample-docs"],
"app_connector_ids": ["connector_sample"]
}]
})
);
}
}

View File

@@ -138,6 +138,8 @@ pub enum Feature {
ToolSearchAlwaysDeferMcpTools,
/// Enable discoverable tool suggestions for apps.
ToolSuggest,
/// Expose installable plugin discovery through a dedicated list tool.
PluginInstallListTool,
/// Enable plugins.
Plugins,
/// Enable plugin-bundled lifecycle hooks.
@@ -964,6 +966,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::PluginInstallListTool,
key: "plugin_install_list_tool",
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::Plugins,
key: "plugins",

View File

@@ -174,6 +174,12 @@ fn tool_suggest_is_stable_and_enabled_by_default() {
assert_eq!(Feature::ToolSuggest.default_enabled(), true);
}
#[test]
fn plugin_install_list_tool_is_stable_and_enabled_by_default() {
assert_eq!(Feature::PluginInstallListTool.stage(), Stage::Stable);
assert_eq!(Feature::PluginInstallListTool.default_enabled(), true);
}
#[test]
fn network_proxy_is_experimental_and_disabled_by_default() {
assert_eq!(

View File

@@ -71,6 +71,7 @@ pub use tool_discovery::DiscoverablePluginInfo;
pub use tool_discovery::DiscoverableTool;
pub use tool_discovery::DiscoverableToolAction;
pub use tool_discovery::DiscoverableToolType;
pub use tool_discovery::LIST_INSTALLABLE_PLUGINS_TOOL_NAME;
pub use tool_discovery::REQUEST_PLUGIN_INSTALL_TOOL_NAME;
pub use tool_discovery::RequestPluginInstallEntry;
pub use tool_discovery::TOOL_SEARCH_DEFAULT_LIMIT;

View File

@@ -19,10 +19,12 @@ 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,
#[serde(default)]
pub tool_type: Option<DiscoverableToolType>,
#[serde(default)]
pub action_type: Option<DiscoverableToolAction>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
@@ -53,7 +55,6 @@ pub fn build_request_plugin_install_elicitation_request(
server_name: &str,
thread_id: String,
turn_id: String,
args: &RequestPluginInstallArgs,
suggest_reason: &str,
tool: &DiscoverableTool,
) -> McpServerElicitationRequestParams {
@@ -67,8 +68,8 @@ pub fn build_request_plugin_install_elicitation_request(
server_name: server_name.to_string(),
request: McpServerElicitationRequest::Form {
meta: Some(json!(build_request_plugin_install_meta(
args.tool_type,
args.action_type,
tool.tool_type(),
DiscoverableToolAction::Install,
suggest_reason,
tool.id(),
tool_name.as_str(),

View File

@@ -5,12 +5,6 @@ use serde_json::json;
#[test]
fn build_request_plugin_install_elicitation_request_uses_expected_shape() {
let args = RequestPluginInstallArgs {
tool_type: DiscoverableToolType::Connector,
action_type: DiscoverableToolAction::Install,
tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
suggest_reason: "Plan and reference events from your calendar".to_string(),
};
let connector = DiscoverableTool::Connector(Box::new(AppInfo {
id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
name: "Google Calendar".to_string(),
@@ -34,7 +28,6 @@ fn build_request_plugin_install_elicitation_request_uses_expected_shape() {
"codex-apps",
"thread-1".to_string(),
"turn-1".to_string(),
&args,
"Plan and reference events from your calendar",
&connector,
);
@@ -72,12 +65,6 @@ fn build_request_plugin_install_elicitation_request_uses_expected_shape() {
#[test]
fn build_request_plugin_install_elicitation_request_for_plugin_omits_install_url() {
let args = RequestPluginInstallArgs {
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(),
@@ -91,7 +78,6 @@ fn build_request_plugin_install_elicitation_request_for_plugin_omits_install_url
"codex-apps",
"thread-1".to_string(),
"turn-1".to_string(),
&args,
"Use the sample plugin's skills and MCP server",
&plugin,
);

View File

@@ -109,6 +109,7 @@ pub struct ToolsConfig {
pub search_tool: bool,
pub namespace_tools: bool,
pub tool_suggest: bool,
pub plugin_install_list_tool: bool,
pub exec_permission_approvals_enabled: bool,
pub request_permissions_tool_enabled: bool,
pub code_mode_enabled: bool,
@@ -187,6 +188,8 @@ impl ToolsConfig {
let include_tool_suggest = features.enabled(Feature::ToolSuggest)
&& features.enabled(Feature::Apps)
&& features.enabled(Feature::Plugins);
let include_plugin_install_list_tool =
include_tool_suggest && features.enabled(Feature::PluginInstallListTool);
let include_original_image_detail = can_request_original_image_detail(model_info);
// API-key auth bypasses Codex backend entitlement/tool normalization, so
// callers must confirm ChatGPT auth before exposing the built-in tool.
@@ -249,6 +252,7 @@ impl ToolsConfig {
search_tool: include_search_tool,
namespace_tools: true,
tool_suggest: include_tool_suggest,
plugin_install_list_tool: include_plugin_install_list_tool,
exec_permission_approvals_enabled,
request_permissions_tool_enabled,
code_mode_enabled: include_code_mode,

View File

@@ -5,6 +5,7 @@ use serde::Serialize;
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_INSTALLABLE_PLUGINS_TOOL_NAME: &str = "list_installable_plugins";
pub const REQUEST_PLUGIN_INSTALL_TOOL_NAME: &str = "request_plugin_install";
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -99,7 +100,7 @@ pub struct DiscoverablePluginInfo {
pub app_connector_ids: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct RequestPluginInstallEntry {
pub id: String,
pub name: String,