mirror of
https://github.com/openai/codex.git
synced 2026-09-05 15:18:41 +00:00
Add plugin install extension backend
This commit is contained in:
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -2641,6 +2641,7 @@ dependencies = [
|
||||
"codex-network-proxy",
|
||||
"codex-otel",
|
||||
"codex-plugin",
|
||||
"codex-plugin-installs-extension",
|
||||
"codex-prompts",
|
||||
"codex-protocol",
|
||||
"codex-response-debug-context",
|
||||
|
||||
@@ -52,6 +52,7 @@ codex-install-context = { workspace = true }
|
||||
codex-network-proxy = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-plugin = { workspace = true }
|
||||
codex-plugin-installs-extension = { workspace = true }
|
||||
codex-model-provider = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-response-debug-context = { workspace = true }
|
||||
|
||||
@@ -9,6 +9,7 @@ pub(crate) mod network_approval;
|
||||
pub(crate) mod orchestrator;
|
||||
pub(crate) mod parallel;
|
||||
pub(crate) mod registry;
|
||||
pub(crate) mod request_plugin_installs;
|
||||
pub(crate) mod router;
|
||||
pub(crate) mod runtimes;
|
||||
pub(crate) mod sandboxing;
|
||||
|
||||
441
codex-rs/core/src/tools/request_plugin_installs.rs
Normal file
441
codex-rs/core/src/tools/request_plugin_installs.rs
Normal file
@@ -0,0 +1,441 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Weak;
|
||||
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_config::types::ToolSuggestDisabledTool;
|
||||
use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_rmcp_client::ElicitationAction;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_tools::DiscoverableTool;
|
||||
use codex_tools::DiscoverableToolType;
|
||||
use rmcp::model::RequestId;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::edit::ConfigEdit;
|
||||
use crate::config::edit::ConfigEditsBuilder;
|
||||
use crate::connectors;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use codex_plugin_installs_extension::REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE;
|
||||
use codex_plugin_installs_extension::REQUEST_PLUGIN_INSTALL_PERSIST_KEY;
|
||||
use codex_plugin_installs_extension::REQUEST_PLUGIN_INSTALLS_TOOL_NAME;
|
||||
use codex_plugin_installs_extension::RequestPluginInstallEntryResult;
|
||||
use codex_plugin_installs_extension::RequestPluginInstallInstalledEntry;
|
||||
use codex_plugin_installs_extension::RequestPluginInstallResolvedPickerEntry;
|
||||
use codex_plugin_installs_extension::RequestPluginInstallsBackend;
|
||||
use codex_plugin_installs_extension::RequestPluginInstallsBackendFuture;
|
||||
use codex_plugin_installs_extension::RequestPluginInstallsRequest;
|
||||
use codex_plugin_installs_extension::RequestPluginInstallsResult;
|
||||
use codex_plugin_installs_extension::all_requested_connectors_picked_up;
|
||||
use codex_plugin_installs_extension::build_request_plugin_installs_elicitation_request;
|
||||
use codex_plugin_installs_extension::request_plugin_install_picker_completed;
|
||||
use codex_plugin_installs_extension::verified_connector_install_completed;
|
||||
|
||||
pub(crate) struct CoreRequestPluginInstallsBackend {
|
||||
session: Weak<crate::session::session::Session>,
|
||||
}
|
||||
|
||||
impl CoreRequestPluginInstallsBackend {
|
||||
pub(crate) fn new(session: &Arc<crate::session::session::Session>) -> Self {
|
||||
Self {
|
||||
session: Arc::downgrade(session),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_request(
|
||||
&self,
|
||||
request: RequestPluginInstallsRequest,
|
||||
) -> Result<RequestPluginInstallsResult, FunctionCallError> {
|
||||
let RequestPluginInstallsRequest {
|
||||
call_id,
|
||||
turn_id,
|
||||
args,
|
||||
resolved_entries,
|
||||
} = request;
|
||||
let session = self.session.upgrade().ok_or_else(|| {
|
||||
FunctionCallError::Fatal("plugin install session is no longer available".to_string())
|
||||
})?;
|
||||
let turn = session
|
||||
.turn_context_for_sub_id(&turn_id)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
FunctionCallError::Fatal("plugin install turn is no longer active".to_string())
|
||||
})?;
|
||||
let action_type = args.action_type;
|
||||
let requested_entries = requested_picker_install_entries(&resolved_entries);
|
||||
|
||||
let request_id = RequestId::String(format!("request_plugin_installs_{call_id}").into());
|
||||
let params = build_request_plugin_installs_elicitation_request(
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
session.thread_id.to_string(),
|
||||
turn.sub_id.clone(),
|
||||
&args,
|
||||
&resolved_entries,
|
||||
);
|
||||
drop(resolved_entries);
|
||||
|
||||
let elicitation = session
|
||||
.request_mcp_server_elicitation(turn.as_ref(), request_id, params)
|
||||
.await;
|
||||
let response = elicitation.response;
|
||||
if let Some(response) = response.as_ref() {
|
||||
maybe_persist_disabled_install_requests(&session, &turn, &requested_entries, response)
|
||||
.await;
|
||||
}
|
||||
let user_confirmed = response
|
||||
.as_ref()
|
||||
.is_some_and(|response| response.action == ElicitationAction::Accept);
|
||||
let response_installed_entries =
|
||||
request_plugin_install_picker_response_entries(response.as_ref());
|
||||
|
||||
let auth = session.services.auth_manager.auth().await;
|
||||
let entries = if user_confirmed {
|
||||
verify_request_plugin_install_picker_completed(
|
||||
&session,
|
||||
&turn,
|
||||
&requested_entries,
|
||||
&response_installed_entries,
|
||||
auth.as_ref(),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
requested_entries
|
||||
.iter()
|
||||
.map(|entry| entry.result(/*completed*/ false))
|
||||
.collect()
|
||||
};
|
||||
let completed_connector_ids = requested_entries
|
||||
.iter()
|
||||
.zip(entries.iter())
|
||||
.filter_map(|(requested_entry, entry)| {
|
||||
if !entry.completed {
|
||||
return None;
|
||||
}
|
||||
match &requested_entry.tool {
|
||||
DiscoverableTool::Connector(connector) => Some(connector.id.clone()),
|
||||
DiscoverableTool::Plugin(_) => None,
|
||||
}
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
if !completed_connector_ids.is_empty() {
|
||||
session
|
||||
.merge_connector_selection(completed_connector_ids)
|
||||
.await;
|
||||
}
|
||||
|
||||
if elicitation.sent {
|
||||
let response_action = match response.as_ref().map(|response| &response.action) {
|
||||
Some(ElicitationAction::Accept) => "accept",
|
||||
Some(ElicitationAction::Decline) => "decline",
|
||||
Some(ElicitationAction::Cancel) => "cancel",
|
||||
None => "unavailable",
|
||||
};
|
||||
for entry in &entries {
|
||||
turn.session_telemetry.record_plugin_install_suggestion(
|
||||
tool_type_str(entry.tool_type),
|
||||
entry.tool_id.as_str(),
|
||||
entry.tool_name.as_str(),
|
||||
response_action,
|
||||
user_confirmed,
|
||||
entry.completed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let completed = user_confirmed && request_plugin_install_picker_completed(&entries);
|
||||
Ok(RequestPluginInstallsResult {
|
||||
completed,
|
||||
user_confirmed,
|
||||
action_type,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RequestPluginInstallsBackend for CoreRequestPluginInstallsBackend {
|
||||
fn execute(
|
||||
&self,
|
||||
request: RequestPluginInstallsRequest,
|
||||
) -> RequestPluginInstallsBackendFuture<'_> {
|
||||
Box::pin(self.handle_request(request))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RequestedPickerInstallEntry {
|
||||
tool: DiscoverableTool,
|
||||
}
|
||||
|
||||
impl RequestedPickerInstallEntry {
|
||||
fn result(&self, completed: bool) -> RequestPluginInstallEntryResult {
|
||||
RequestPluginInstallEntryResult {
|
||||
tool_type: self.tool.tool_type(),
|
||||
tool_id: self.tool.id().to_string(),
|
||||
tool_name: self.tool.name().to_string(),
|
||||
completed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RequestPluginInstallPickerResponseContent {
|
||||
#[serde(default)]
|
||||
installed_entries: Vec<RequestPluginInstallInstalledEntry>,
|
||||
}
|
||||
|
||||
fn requested_picker_install_entries(
|
||||
resolved_entries: &[RequestPluginInstallResolvedPickerEntry],
|
||||
) -> Vec<RequestedPickerInstallEntry> {
|
||||
resolved_entries
|
||||
.iter()
|
||||
.map(|entry| RequestedPickerInstallEntry {
|
||||
tool: entry.tool.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn request_plugin_install_picker_response_entries(
|
||||
response: Option<&ElicitationResponse>,
|
||||
) -> Vec<RequestPluginInstallInstalledEntry> {
|
||||
let Some(content) = response.and_then(|response| response.content.as_ref()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
match serde_json::from_value::<RequestPluginInstallPickerResponseContent>(content.clone()) {
|
||||
Ok(content) => content.installed_entries,
|
||||
Err(err) => {
|
||||
warn!("failed to parse request_plugin_installs picker response content: {err:#}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_persist_disabled_install_requests(
|
||||
session: &crate::session::session::Session,
|
||||
turn: &crate::session::turn_context::TurnContext,
|
||||
requested_entries: &[RequestedPickerInstallEntry],
|
||||
response: &ElicitationResponse,
|
||||
) {
|
||||
if !request_plugin_install_response_requests_persistent_disable(response) {
|
||||
return;
|
||||
}
|
||||
|
||||
for entry in requested_entries {
|
||||
if let Err(err) =
|
||||
persist_disabled_install_request(&turn.config.codex_home, &entry.tool).await
|
||||
{
|
||||
warn!(
|
||||
error = %err,
|
||||
tool_id = entry.tool.id(),
|
||||
"failed to persist disabled tool suggestion"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
session.reload_user_config_layer().await;
|
||||
}
|
||||
|
||||
pub(super) fn request_plugin_install_response_requests_persistent_disable(
|
||||
response: &ElicitationResponse,
|
||||
) -> bool {
|
||||
if response.action != ElicitationAction::Decline {
|
||||
return false;
|
||||
}
|
||||
|
||||
response
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|meta| meta.get(REQUEST_PLUGIN_INSTALL_PERSIST_KEY))
|
||||
.and_then(Value::as_str)
|
||||
== Some(REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE)
|
||||
}
|
||||
|
||||
pub(super) async fn persist_disabled_install_request(
|
||||
codex_home: &codex_utils_absolute_path::AbsolutePathBuf,
|
||||
tool: &DiscoverableTool,
|
||||
) -> anyhow::Result<()> {
|
||||
ConfigEditsBuilder::new(codex_home)
|
||||
.with_edits([ConfigEdit::AddToolSuggestDisabledTool(
|
||||
disabled_install_request(tool),
|
||||
)])
|
||||
.apply()
|
||||
.await
|
||||
}
|
||||
|
||||
fn disabled_install_request(tool: &DiscoverableTool) -> ToolSuggestDisabledTool {
|
||||
match tool {
|
||||
DiscoverableTool::Connector(connector) => {
|
||||
ToolSuggestDisabledTool::connector(connector.id.as_str())
|
||||
}
|
||||
DiscoverableTool::Plugin(plugin) => ToolSuggestDisabledTool::plugin(plugin.id.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_request_plugin_install_picker_completed(
|
||||
session: &crate::session::session::Session,
|
||||
turn: &crate::session::turn_context::TurnContext,
|
||||
requested_entries: &[RequestedPickerInstallEntry],
|
||||
response_installed_entries: &[RequestPluginInstallInstalledEntry],
|
||||
auth: Option<&codex_login::CodexAuth>,
|
||||
) -> Vec<RequestPluginInstallEntryResult> {
|
||||
let mut expected_connector_ids = HashSet::new();
|
||||
let mut has_local_plugin_entry = false;
|
||||
for entry in requested_entries {
|
||||
match &entry.tool {
|
||||
DiscoverableTool::Connector(connector) => {
|
||||
expected_connector_ids.insert(connector.id.clone());
|
||||
}
|
||||
DiscoverableTool::Plugin(plugin) => {
|
||||
expected_connector_ids.extend(plugin.app_connector_ids.iter().cloned());
|
||||
if !is_remote_plugin_install_suggestion(&plugin.id) {
|
||||
has_local_plugin_entry = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let expected_connector_ids = expected_connector_ids.into_iter().collect::<Vec<_>>();
|
||||
let accessible_connectors = if expected_connector_ids.is_empty() {
|
||||
Some(Vec::new())
|
||||
} else {
|
||||
refresh_missing_requested_connectors(
|
||||
session,
|
||||
turn,
|
||||
auth,
|
||||
&expected_connector_ids,
|
||||
REQUEST_PLUGIN_INSTALLS_TOOL_NAME,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
let config = if has_local_plugin_entry {
|
||||
session.reload_user_config_layer().await;
|
||||
Some(session.get_config().await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
requested_entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let app_reported_completed =
|
||||
response_reports_picker_entry_completed(response_installed_entries, entry);
|
||||
let locally_verified_completed = match &entry.tool {
|
||||
DiscoverableTool::Connector(connector) => accessible_connectors
|
||||
.as_ref()
|
||||
.is_some_and(|accessible_connectors| {
|
||||
verified_connector_install_completed(
|
||||
connector.id.as_str(),
|
||||
accessible_connectors,
|
||||
)
|
||||
}),
|
||||
DiscoverableTool::Plugin(plugin) => {
|
||||
if is_remote_plugin_install_suggestion(&plugin.id) {
|
||||
false
|
||||
} else {
|
||||
config.as_ref().is_some_and(|config| {
|
||||
verified_plugin_install_completed(
|
||||
plugin.id.as_str(),
|
||||
config.as_ref(),
|
||||
session.services.plugins_manager.as_ref(),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
entry.result(app_reported_completed || locally_verified_completed)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn response_reports_picker_entry_completed(
|
||||
response_installed_entries: &[RequestPluginInstallInstalledEntry],
|
||||
requested_entry: &RequestedPickerInstallEntry,
|
||||
) -> bool {
|
||||
match &requested_entry.tool {
|
||||
DiscoverableTool::Connector(_) => false,
|
||||
DiscoverableTool::Plugin(_) => response_installed_entries.iter().any(|installed_entry| {
|
||||
installed_entry.tool_id == requested_entry.tool.id()
|
||||
&& installed_entry.tool_type == requested_entry.tool.tool_type()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn tool_type_str(tool_type: DiscoverableToolType) -> &'static str {
|
||||
match tool_type {
|
||||
DiscoverableToolType::Connector => "connector",
|
||||
DiscoverableToolType::Plugin => "plugin",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_remote_plugin_install_suggestion(plugin_id: &str) -> bool {
|
||||
plugin_id
|
||||
.rsplit_once('@')
|
||||
.is_some_and(|(_, marketplace_name)| marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME)
|
||||
}
|
||||
|
||||
pub(super) async fn refresh_missing_requested_connectors(
|
||||
session: &crate::session::session::Session,
|
||||
turn: &crate::session::turn_context::TurnContext,
|
||||
auth: Option<&codex_login::CodexAuth>,
|
||||
expected_connector_ids: &[String],
|
||||
tool_id: &str,
|
||||
) -> Option<Vec<AppInfo>> {
|
||||
if expected_connector_ids.is_empty() {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
|
||||
let manager = session.services.mcp_connection_manager.load_full();
|
||||
let mcp_tools = manager.list_all_tools().await;
|
||||
let accessible_connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn.config,
|
||||
);
|
||||
if all_requested_connectors_picked_up(expected_connector_ids, &accessible_connectors) {
|
||||
return Some(accessible_connectors);
|
||||
}
|
||||
|
||||
match manager.hard_refresh_codex_apps_tools_cache().await {
|
||||
Ok(mcp_tools) => {
|
||||
let accessible_connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn.config,
|
||||
);
|
||||
connectors::refresh_accessible_connectors_cache_from_mcp_tools(
|
||||
&turn.config,
|
||||
auth,
|
||||
&mcp_tools,
|
||||
);
|
||||
Some(accessible_connectors)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to refresh codex apps tools cache after plugin install request for {tool_id}: {err:#}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn verified_plugin_install_completed(
|
||||
tool_id: &str,
|
||||
config: &crate::config::Config,
|
||||
plugins_manager: &codex_core_plugins::PluginsManager,
|
||||
) -> bool {
|
||||
let plugins_input = config.plugins_config_input();
|
||||
plugins_manager
|
||||
.list_marketplaces_for_config(&plugins_input, &[], /*include_openai_curated*/ true)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|outcome| outcome.marketplaces)
|
||||
.flat_map(|marketplace| marketplace.plugins.into_iter())
|
||||
.any(|plugin| plugin.id == tool_id && plugin.installed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "request_plugin_installs_tests.rs"]
|
||||
mod tests;
|
||||
262
codex-rs/core/src/tools/request_plugin_installs_tests.rs
Normal file
262
codex-rs/core/src/tools/request_plugin_installs_tests.rs
Normal file
@@ -0,0 +1,262 @@
|
||||
use super::*;
|
||||
use crate::plugins::test_support::load_plugins_config;
|
||||
use crate::plugins::test_support::write_curated_plugin_sha;
|
||||
use crate::plugins::test_support::write_openai_curated_marketplace;
|
||||
use crate::plugins::test_support::write_plugins_feature_config;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_config::types::ToolSuggestConfig;
|
||||
use codex_config::types::ToolSuggestDisabledTool;
|
||||
use codex_config::types::ToolSuggestDiscoverable;
|
||||
use codex_config::types::ToolSuggestDiscoverableType;
|
||||
use codex_core_plugins::PluginInstallRequest;
|
||||
use codex_core_plugins::PluginsManager;
|
||||
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_tools::DiscoverablePluginInfo;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::PathExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::model::ElicitationAction;
|
||||
use serde_json::json;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn verified_plugin_install_completed_requires_installed_plugin() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let curated_root = curated_plugins_repo_path(codex_home.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["sample"]);
|
||||
write_curated_plugin_sha(codex_home.path());
|
||||
write_plugins_feature_config(codex_home.path());
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
|
||||
assert!(!verified_plugin_install_completed(
|
||||
"sample@openai-curated",
|
||||
&config,
|
||||
&plugins_manager,
|
||||
));
|
||||
|
||||
plugins_manager
|
||||
.install_plugin(PluginInstallRequest {
|
||||
plugin_name: "sample".to_string(),
|
||||
marketplace_path: AbsolutePathBuf::try_from(
|
||||
curated_root.join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.expect("marketplace path"),
|
||||
})
|
||||
.await
|
||||
.expect("plugin should install");
|
||||
|
||||
let refreshed_config = load_plugins_config(codex_home.path()).await;
|
||||
assert!(verified_plugin_install_completed(
|
||||
"sample@openai-curated",
|
||||
&refreshed_config,
|
||||
&plugins_manager,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_plugin_install_suggestions_skip_core_installed_verification() {
|
||||
assert!(is_remote_plugin_install_suggestion(
|
||||
"snowflake@openai-curated-remote"
|
||||
));
|
||||
assert!(!is_remote_plugin_install_suggestion(
|
||||
"snowflake@openai-curated"
|
||||
));
|
||||
assert!(!is_remote_plugin_install_suggestion("Plugin_123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_plugin_install_response_persists_only_decline_always_mode() {
|
||||
assert!(request_plugin_install_response_requests_persistent_disable(
|
||||
&ElicitationResponse {
|
||||
action: ElicitationAction::Decline,
|
||||
content: None,
|
||||
meta: Some(json!({
|
||||
REQUEST_PLUGIN_INSTALL_PERSIST_KEY: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE
|
||||
})),
|
||||
}
|
||||
));
|
||||
assert!(
|
||||
!request_plugin_install_response_requests_persistent_disable(&ElicitationResponse {
|
||||
action: ElicitationAction::Accept,
|
||||
content: None,
|
||||
meta: Some(json!({
|
||||
REQUEST_PLUGIN_INSTALL_PERSIST_KEY: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE
|
||||
})),
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
!request_plugin_install_response_requests_persistent_disable(&ElicitationResponse {
|
||||
action: ElicitationAction::Decline,
|
||||
content: None,
|
||||
meta: Some(json!({ REQUEST_PLUGIN_INSTALL_PERSIST_KEY: "session" })),
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
!request_plugin_install_response_requests_persistent_disable(&ElicitationResponse {
|
||||
action: ElicitationAction::Decline,
|
||||
content: None,
|
||||
meta: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_response_acknowledgements_apply_only_to_plugins() {
|
||||
let connector = RequestedPickerInstallEntry {
|
||||
tool: connector_tool("connector_calendar", "Google Calendar"),
|
||||
};
|
||||
let plugin = RequestedPickerInstallEntry {
|
||||
tool: DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo {
|
||||
id: "slack@openai-curated-remote".to_string(),
|
||||
remote_plugin_id: Some("plugins~Plugin_slack".to_string()),
|
||||
name: "Slack".to_string(),
|
||||
description: None,
|
||||
has_skills: true,
|
||||
mcp_server_names: Vec::new(),
|
||||
app_connector_ids: Vec::new(),
|
||||
})),
|
||||
};
|
||||
let installed_entries = vec![
|
||||
RequestPluginInstallInstalledEntry {
|
||||
tool_id: "connector_calendar".to_string(),
|
||||
tool_type: DiscoverableToolType::Connector,
|
||||
},
|
||||
RequestPluginInstallInstalledEntry {
|
||||
tool_id: "slack@openai-curated-remote".to_string(),
|
||||
tool_type: DiscoverableToolType::Plugin,
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
response_reports_picker_entry_completed(&installed_entries, &connector),
|
||||
response_reports_picker_entry_completed(&installed_entries, &plugin),
|
||||
),
|
||||
(false, true),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_disabled_install_request_writes_connector_config() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let tool = connector_tool("connector_calendar", "Google Calendar");
|
||||
|
||||
persist_disabled_install_request(&codex_home.path().abs(), &tool)
|
||||
.await
|
||||
.expect("persist connector disable");
|
||||
|
||||
let contents =
|
||||
std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let parsed: ConfigToml = toml::from_str(&contents).expect("parse config");
|
||||
assert_eq!(
|
||||
parsed.tool_suggest,
|
||||
Some(ToolSuggestConfig {
|
||||
discoverables: Vec::new(),
|
||||
disabled_tools: vec![ToolSuggestDisabledTool::connector("connector_calendar")],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_disabled_install_request_writes_plugin_config() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let tool = DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo {
|
||||
id: "slack@openai-curated".to_string(),
|
||||
remote_plugin_id: None,
|
||||
name: "Slack".to_string(),
|
||||
description: None,
|
||||
has_skills: true,
|
||||
mcp_server_names: Vec::new(),
|
||||
app_connector_ids: Vec::new(),
|
||||
}));
|
||||
|
||||
persist_disabled_install_request(&codex_home.path().abs(), &tool)
|
||||
.await
|
||||
.expect("persist plugin disable");
|
||||
|
||||
let contents =
|
||||
std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let parsed: ConfigToml = toml::from_str(&contents).expect("parse config");
|
||||
assert_eq!(
|
||||
parsed.tool_suggest,
|
||||
Some(ToolSuggestConfig {
|
||||
discoverables: Vec::new(),
|
||||
disabled_tools: vec![ToolSuggestDisabledTool::plugin("slack@openai-curated")],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_disabled_install_request_dedupes_existing_disabled_tools() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let tool = connector_tool("connector_calendar", "Google Calendar");
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[tool_suggest]
|
||||
discoverables = [
|
||||
{ type = "plugin", id = "sample@openai-curated" }
|
||||
]
|
||||
|
||||
[[tool_suggest.disabled_tools]]
|
||||
type = "connector"
|
||||
id = " connector_calendar "
|
||||
|
||||
[[tool_suggest.disabled_tools]]
|
||||
type = "connector"
|
||||
id = "connector_calendar"
|
||||
|
||||
[[tool_suggest.disabled_tools]]
|
||||
type = "connector"
|
||||
id = " "
|
||||
|
||||
[[tool_suggest.disabled_tools]]
|
||||
type = "plugin"
|
||||
id = "slack@openai-curated"
|
||||
"#,
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
persist_disabled_install_request(&codex_home.path().abs(), &tool)
|
||||
.await
|
||||
.expect("persist connector disable");
|
||||
|
||||
let contents =
|
||||
std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let parsed: ConfigToml = toml::from_str(&contents).expect("parse config");
|
||||
assert_eq!(
|
||||
parsed.tool_suggest,
|
||||
Some(ToolSuggestConfig {
|
||||
discoverables: vec![ToolSuggestDiscoverable {
|
||||
kind: ToolSuggestDiscoverableType::Plugin,
|
||||
id: "sample@openai-curated".to_string(),
|
||||
}],
|
||||
disabled_tools: vec![
|
||||
ToolSuggestDisabledTool::connector("connector_calendar"),
|
||||
ToolSuggestDisabledTool::plugin("slack@openai-curated"),
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn connector_tool(id: &str, name: &str) -> DiscoverableTool {
|
||||
DiscoverableTool::Connector(Box::new(AppInfo {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user