mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
## Summary We originally addressed startup prewarming holding the read side of `RwLock<McpConnectionManager>` by snapshotting tool-list state. Review feedback identified the broader ownership problem: the outer synchronization should only publish or retrieve the current manager, while MCP operations rely on the manager's internal synchronization. A follow-up preserved operation retirement with a separate gate, but further review questioned whether that synchronization was actually required and whether we could support latest-wins replacement instead. This PR now stores the current MCP manager in `ArcSwap`. Each operation uses `load_full()` to obtain an owned `Arc<McpConnectionManager>`, then performs MCP I/O without retaining the publication mechanism. Refresh cancels obsolete startup work, constructs a replacement, and atomically publishes it. New operations see the latest manager, while operations that already loaded the previous manager retain a valid handle. Refresh happens at a turn boundary, so there should be no active user tool calls to drain. Git history supports dropping the outer `RwLock`. It was introduced in `03ffe4d595` on November 17, 2025 for non-blocking MCP startup: the session published an empty manager, startup initialized that same object while holding the write lock, and readers waited for initialization. `7cd2e84026` on February 19, 2026 removed that two-phase initialization in favor of constructing a fresh manager and swapping it in, explicitly noting that `Option` or `OnceCell` could replace the placeholder design. Hot reload later reused the existing lock to publish a replacement, but I found no indication that the lock was introduced to guarantee in-flight tool calls finish before refresh or shutdown. Terminal shutdown remains separate from refresh: it aborts startup prewarming and active tasks before shutting down the current manager, so tool calls may be interrupted and no model WebSocket work continues after shutdown. Focused regression coverage exercises pending tool-list cancellation, deferred refresh, and startup-prewarm shutdown.
765 lines
24 KiB
Rust
765 lines
24 KiB
Rust
use std::collections::HashSet;
|
|
use std::sync::Arc;
|
|
use std::sync::LazyLock;
|
|
use std::sync::Mutex as StdMutex;
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
|
|
use async_channel::unbounded;
|
|
pub use codex_app_server_protocol::AppBranding;
|
|
pub use codex_app_server_protocol::AppInfo;
|
|
pub use codex_app_server_protocol::AppMetadata;
|
|
use codex_connectors::ConnectorDirectoryCacheContext;
|
|
use codex_connectors::ConnectorDirectoryCacheKey;
|
|
use codex_exec_server::EnvironmentManager;
|
|
use codex_exec_server::ExecServerRuntimePaths;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_tools::DiscoverableTool;
|
|
use rmcp::model::ToolAnnotations;
|
|
use serde::Deserialize;
|
|
use tokio_util::sync::CancellationToken;
|
|
use tracing::warn;
|
|
|
|
use crate::config::Config;
|
|
use crate::mcp::McpManager;
|
|
use crate::plugins::list_tool_suggest_discoverable_plugins;
|
|
use crate::session::INITIAL_SUBMIT_ID;
|
|
use codex_config::AppsRequirementsToml;
|
|
use codex_config::types::AppToolApproval;
|
|
use codex_config::types::ApprovalsReviewer;
|
|
use codex_config::types::AppsConfigToml;
|
|
use codex_config::types::ToolSuggestDiscoverableType;
|
|
use codex_core_plugins::PluginsManager;
|
|
use codex_features::Feature;
|
|
use codex_login::AuthManager;
|
|
use codex_login::CodexAuth;
|
|
use codex_login::default_client::originator;
|
|
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
|
use codex_mcp::McpConnectionManager;
|
|
use codex_mcp::McpRuntimeContext;
|
|
use codex_mcp::ToolInfo;
|
|
use codex_mcp::ToolPluginProvenance;
|
|
use codex_mcp::codex_apps_tools_cache_key;
|
|
use codex_mcp::compute_auth_statuses;
|
|
use codex_mcp::effective_mcp_servers;
|
|
use codex_mcp::host_owned_codex_apps_enabled;
|
|
|
|
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) struct AppToolPolicy {
|
|
pub enabled: bool,
|
|
pub approval: AppToolApproval,
|
|
}
|
|
|
|
impl Default for AppToolPolicy {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
approval: AppToolApproval::Auto,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Eq)]
|
|
struct AccessibleConnectorsCacheKey {
|
|
chatgpt_base_url: String,
|
|
account_id: Option<String>,
|
|
chatgpt_user_id: Option<String>,
|
|
is_workspace_account: bool,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct CachedAccessibleConnectors {
|
|
key: AccessibleConnectorsCacheKey,
|
|
expires_at: Instant,
|
|
connectors: Vec<AppInfo>,
|
|
}
|
|
|
|
static ACCESSIBLE_CONNECTORS_CACHE: LazyLock<StdMutex<Option<CachedAccessibleConnectors>>> =
|
|
LazyLock::new(|| StdMutex::new(None));
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct AccessibleConnectorsStatus {
|
|
pub connectors: Vec<AppInfo>,
|
|
pub codex_apps_ready: bool,
|
|
}
|
|
|
|
pub async fn list_accessible_connectors_from_mcp_tools(
|
|
config: &Config,
|
|
) -> anyhow::Result<Vec<AppInfo>> {
|
|
Ok(
|
|
list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
|
config, /*force_refetch*/ false,
|
|
)
|
|
.await?
|
|
.connectors,
|
|
)
|
|
}
|
|
|
|
pub(crate) async fn list_accessible_and_enabled_connectors_from_manager(
|
|
mcp_connection_manager: &McpConnectionManager,
|
|
config: &Config,
|
|
) -> Vec<AppInfo> {
|
|
with_app_enabled_state(
|
|
accessible_connectors_from_mcp_tools(&mcp_connection_manager.list_all_tools().await),
|
|
config,
|
|
)
|
|
.into_iter()
|
|
.filter(|connector| connector.is_accessible && connector.is_enabled)
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
|
|
config: &Config,
|
|
plugins_manager: &PluginsManager,
|
|
auth: Option<&CodexAuth>,
|
|
accessible_connectors: &[AppInfo],
|
|
loaded_plugin_app_connector_ids: &[String],
|
|
) -> anyhow::Result<Vec<DiscoverableTool>> {
|
|
let connector_ids = tool_suggest_connector_ids(config, loaded_plugin_app_connector_ids);
|
|
let directory_connectors = codex_connectors::merge::merge_plugin_connectors(
|
|
cached_directory_connectors_for_tool_suggest_with_auth(config, auth).await,
|
|
connector_ids.iter().cloned(),
|
|
);
|
|
let discoverable_connectors =
|
|
codex_connectors::filter::filter_tool_suggest_discoverable_connectors(
|
|
directory_connectors,
|
|
accessible_connectors,
|
|
&connector_ids,
|
|
originator().value.as_str(),
|
|
)
|
|
.into_iter()
|
|
.map(DiscoverableTool::from);
|
|
let discoverable_plugins = list_tool_suggest_discoverable_plugins(
|
|
config,
|
|
plugins_manager,
|
|
auth,
|
|
loaded_plugin_app_connector_ids,
|
|
)
|
|
.await?
|
|
.into_iter()
|
|
.map(DiscoverableTool::from);
|
|
Ok(discoverable_connectors
|
|
.chain(discoverable_plugins)
|
|
.collect())
|
|
}
|
|
|
|
pub async fn list_cached_accessible_connectors_from_mcp_tools(
|
|
config: &Config,
|
|
) -> Option<Vec<AppInfo>> {
|
|
let auth_manager =
|
|
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await;
|
|
let auth = auth_manager.auth().await;
|
|
if !config
|
|
.features
|
|
.apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend))
|
|
{
|
|
return Some(Vec::new());
|
|
}
|
|
let cache_key = accessible_connectors_cache_key(config, auth.as_ref());
|
|
read_cached_accessible_connectors(&cache_key).map(|connectors| {
|
|
codex_connectors::filter::filter_disallowed_connectors(
|
|
connectors,
|
|
originator().value.as_str(),
|
|
)
|
|
})
|
|
}
|
|
|
|
pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools(
|
|
config: &Config,
|
|
auth: Option<&CodexAuth>,
|
|
mcp_tools: &[ToolInfo],
|
|
) {
|
|
if !config.features.enabled(Feature::Apps) {
|
|
return;
|
|
}
|
|
|
|
let cache_key = accessible_connectors_cache_key(config, auth);
|
|
let accessible_connectors = codex_connectors::filter::filter_disallowed_connectors(
|
|
accessible_connectors_from_mcp_tools(mcp_tools),
|
|
originator().value.as_str(),
|
|
);
|
|
write_cached_accessible_connectors(cache_key, &accessible_connectors);
|
|
}
|
|
|
|
pub async fn list_accessible_connectors_from_mcp_tools_with_options(
|
|
config: &Config,
|
|
force_refetch: bool,
|
|
) -> anyhow::Result<Vec<AppInfo>> {
|
|
Ok(
|
|
list_accessible_connectors_from_mcp_tools_with_options_and_status(config, force_refetch)
|
|
.await?
|
|
.connectors,
|
|
)
|
|
}
|
|
|
|
pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
|
config: &Config,
|
|
force_refetch: bool,
|
|
) -> anyhow::Result<AccessibleConnectorsStatus> {
|
|
// TODO: Wire callers that already own an EnvironmentManager into
|
|
// list_accessible_connectors_from_mcp_tools_with_environment_manager instead
|
|
// of constructing a temporary manager here.
|
|
let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths(
|
|
config.codex_self_exe.clone(),
|
|
config.codex_linux_sandbox_exe.clone(),
|
|
)?;
|
|
let environment_manager =
|
|
EnvironmentManager::from_codex_home(config.codex_home.clone(), Some(local_runtime_paths))
|
|
.await?;
|
|
list_accessible_connectors_from_mcp_tools_with_environment_manager(
|
|
config,
|
|
force_refetch,
|
|
Arc::new(environment_manager),
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
|
|
config: &Config,
|
|
force_refetch: bool,
|
|
environment_manager: Arc<EnvironmentManager>,
|
|
) -> anyhow::Result<AccessibleConnectorsStatus> {
|
|
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf()));
|
|
let mcp_manager = Arc::new(McpManager::new(plugins_manager));
|
|
list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
|
config,
|
|
force_refetch,
|
|
environment_manager,
|
|
mcp_manager,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
|
config: &Config,
|
|
force_refetch: bool,
|
|
environment_manager: Arc<EnvironmentManager>,
|
|
mcp_manager: Arc<McpManager>,
|
|
) -> anyhow::Result<AccessibleConnectorsStatus> {
|
|
let auth_manager =
|
|
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await;
|
|
let auth = auth_manager.auth().await;
|
|
if !config
|
|
.features
|
|
.apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend))
|
|
{
|
|
return Ok(AccessibleConnectorsStatus {
|
|
connectors: Vec::new(),
|
|
codex_apps_ready: true,
|
|
});
|
|
}
|
|
let cache_key = accessible_connectors_cache_key(config, auth.as_ref());
|
|
let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config).await;
|
|
if !force_refetch && let Some(cached_connectors) = read_cached_accessible_connectors(&cache_key)
|
|
{
|
|
let cached_connectors = codex_connectors::filter::filter_disallowed_connectors(
|
|
cached_connectors,
|
|
originator().value.as_str(),
|
|
);
|
|
let cached_connectors = with_app_plugin_sources(cached_connectors, &tool_plugin_provenance);
|
|
return Ok(AccessibleConnectorsStatus {
|
|
connectors: cached_connectors,
|
|
codex_apps_ready: true,
|
|
});
|
|
}
|
|
|
|
let mcp_config = mcp_manager.runtime_config(config).await;
|
|
let mut mcp_servers = effective_mcp_servers(&mcp_config, auth.as_ref());
|
|
mcp_servers.retain(|name, _| name == CODEX_APPS_MCP_SERVER_NAME);
|
|
let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(&mcp_config, auth.as_ref());
|
|
if mcp_servers.is_empty() {
|
|
return Ok(AccessibleConnectorsStatus {
|
|
connectors: Vec::new(),
|
|
codex_apps_ready: true,
|
|
});
|
|
}
|
|
|
|
let auth_status_entries = compute_auth_statuses(
|
|
mcp_servers.iter(),
|
|
config.mcp_oauth_credentials_store_mode,
|
|
auth.as_ref(),
|
|
)
|
|
.await;
|
|
|
|
let (tx_event, rx_event) = unbounded();
|
|
drop(rx_event);
|
|
|
|
let cancel_token = CancellationToken::new();
|
|
let mcp_connection_manager = McpConnectionManager::new(
|
|
&mcp_servers,
|
|
config.mcp_oauth_credentials_store_mode,
|
|
auth_status_entries,
|
|
&config.permissions.approval_policy,
|
|
INITIAL_SUBMIT_ID.to_owned(),
|
|
tx_event,
|
|
cancel_token.clone(),
|
|
PermissionProfile::default(),
|
|
// Connector discovery is threadless. Use an actually configured env if
|
|
// one exists, but do not reintroduce the old hidden-local fallback.
|
|
McpRuntimeContext::new(environment_manager, config.cwd.to_path_buf()),
|
|
config.codex_home.to_path_buf(),
|
|
codex_apps_tools_cache_key(auth.as_ref()),
|
|
host_owned_codex_apps_enabled,
|
|
mcp_config.prefix_mcp_tool_names,
|
|
mcp_config.client_elicitation_capability,
|
|
ToolPluginProvenance::default(),
|
|
auth.as_ref(),
|
|
/*elicitation_reviewer*/ None,
|
|
)
|
|
.await;
|
|
|
|
let refreshed_tools = if force_refetch {
|
|
match mcp_connection_manager
|
|
.hard_refresh_codex_apps_tools_cache()
|
|
.await
|
|
{
|
|
Ok(tools) => Some(tools),
|
|
Err(err) => {
|
|
warn!(
|
|
"failed to force-refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}', using cached/startup tools: {err:#}"
|
|
);
|
|
None
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
let refreshed_tools_succeeded = refreshed_tools.is_some();
|
|
|
|
let mut tools = if let Some(tools) = refreshed_tools {
|
|
tools
|
|
} else {
|
|
mcp_connection_manager.list_all_tools().await
|
|
};
|
|
let mut should_reload_tools = false;
|
|
let codex_apps_ready = if refreshed_tools_succeeded {
|
|
true
|
|
} else if let Some(cfg) = mcp_servers.get(CODEX_APPS_MCP_SERVER_NAME) {
|
|
let immediate_ready = mcp_connection_manager
|
|
.wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, Duration::ZERO)
|
|
.await;
|
|
if immediate_ready {
|
|
true
|
|
} else if tools.is_empty() {
|
|
let timeout = cfg
|
|
.configured_config()
|
|
.and_then(|config| config.startup_timeout_sec)
|
|
.unwrap_or(CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS);
|
|
let ready = mcp_connection_manager
|
|
.wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, timeout)
|
|
.await;
|
|
should_reload_tools = ready;
|
|
ready
|
|
} else {
|
|
false
|
|
}
|
|
} else {
|
|
false
|
|
};
|
|
if should_reload_tools {
|
|
tools = mcp_connection_manager.list_all_tools().await;
|
|
}
|
|
if codex_apps_ready {
|
|
cancel_token.cancel();
|
|
}
|
|
|
|
let accessible_connectors = codex_connectors::filter::filter_disallowed_connectors(
|
|
accessible_connectors_from_mcp_tools(&tools),
|
|
originator().value.as_str(),
|
|
);
|
|
if codex_apps_ready || !accessible_connectors.is_empty() {
|
|
write_cached_accessible_connectors(cache_key, &accessible_connectors);
|
|
}
|
|
let accessible_connectors =
|
|
with_app_plugin_sources(accessible_connectors, &tool_plugin_provenance);
|
|
mcp_connection_manager.shutdown().await;
|
|
Ok(AccessibleConnectorsStatus {
|
|
connectors: accessible_connectors,
|
|
codex_apps_ready,
|
|
})
|
|
}
|
|
|
|
fn accessible_connectors_cache_key(
|
|
config: &Config,
|
|
auth: Option<&CodexAuth>,
|
|
) -> AccessibleConnectorsCacheKey {
|
|
let account_id = auth.and_then(CodexAuth::get_account_id);
|
|
let chatgpt_user_id = auth.and_then(CodexAuth::get_chatgpt_user_id);
|
|
let is_workspace_account = auth.is_some_and(CodexAuth::is_workspace_account);
|
|
AccessibleConnectorsCacheKey {
|
|
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
|
account_id,
|
|
chatgpt_user_id,
|
|
is_workspace_account,
|
|
}
|
|
}
|
|
|
|
fn read_cached_accessible_connectors(
|
|
cache_key: &AccessibleConnectorsCacheKey,
|
|
) -> Option<Vec<AppInfo>> {
|
|
let mut cache_guard = ACCESSIBLE_CONNECTORS_CACHE
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let now = Instant::now();
|
|
|
|
if let Some(cached) = cache_guard.as_ref() {
|
|
if now < cached.expires_at && cached.key == *cache_key {
|
|
return Some(cached.connectors.clone());
|
|
}
|
|
if now >= cached.expires_at {
|
|
*cache_guard = None;
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn write_cached_accessible_connectors(
|
|
cache_key: AccessibleConnectorsCacheKey,
|
|
connectors: &[AppInfo],
|
|
) {
|
|
let mut cache_guard = ACCESSIBLE_CONNECTORS_CACHE
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
*cache_guard = Some(CachedAccessibleConnectors {
|
|
key: cache_key,
|
|
expires_at: Instant::now() + codex_connectors::CONNECTORS_CACHE_TTL,
|
|
connectors: connectors.to_vec(),
|
|
});
|
|
}
|
|
|
|
fn tool_suggest_connector_ids(
|
|
config: &Config,
|
|
loaded_plugin_app_connector_ids: &[String],
|
|
) -> HashSet<String> {
|
|
let mut connector_ids = loaded_plugin_app_connector_ids
|
|
.iter()
|
|
.cloned()
|
|
.collect::<HashSet<_>>();
|
|
connector_ids.extend(
|
|
config
|
|
.tool_suggest
|
|
.discoverables
|
|
.iter()
|
|
.filter(|discoverable| discoverable.kind == ToolSuggestDiscoverableType::Connector)
|
|
.map(|discoverable| discoverable.id.clone()),
|
|
);
|
|
let disabled_connector_ids = config
|
|
.tool_suggest
|
|
.disabled_tools
|
|
.iter()
|
|
.filter(|disabled_tool| disabled_tool.kind == ToolSuggestDiscoverableType::Connector)
|
|
.map(|disabled_tool| disabled_tool.id.as_str())
|
|
.collect::<HashSet<_>>();
|
|
connector_ids.retain(|connector_id| !disabled_connector_ids.contains(connector_id.as_str()));
|
|
connector_ids
|
|
}
|
|
|
|
async fn cached_directory_connectors_for_tool_suggest_with_auth(
|
|
config: &Config,
|
|
auth: Option<&CodexAuth>,
|
|
) -> Vec<AppInfo> {
|
|
if !config.features.enabled(Feature::Apps) {
|
|
return Vec::new();
|
|
}
|
|
|
|
let loaded_auth;
|
|
let auth = if let Some(auth) = auth {
|
|
Some(auth)
|
|
} else {
|
|
let auth_manager =
|
|
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await;
|
|
loaded_auth = auth_manager.auth().await;
|
|
loaded_auth.as_ref()
|
|
};
|
|
let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let account_id = match auth.get_account_id() {
|
|
Some(account_id) if !account_id.is_empty() => account_id,
|
|
_ => return Vec::new(),
|
|
};
|
|
let is_workspace_account = auth.is_workspace_account();
|
|
let cache_context = ConnectorDirectoryCacheContext::new(
|
|
config.codex_home.to_path_buf(),
|
|
ConnectorDirectoryCacheKey::new(
|
|
config.chatgpt_base_url.clone(),
|
|
Some(account_id),
|
|
auth.get_chatgpt_user_id(),
|
|
is_workspace_account,
|
|
),
|
|
);
|
|
|
|
codex_connectors::cached_directory_connectors(&cache_context).unwrap_or_default()
|
|
}
|
|
|
|
pub(crate) fn accessible_connectors_from_mcp_tools(mcp_tools: &[ToolInfo]) -> Vec<AppInfo> {
|
|
// ToolInfo already carries plugin provenance, so app-level plugin sources
|
|
// can be derived here instead of requiring a separate enrichment pass.
|
|
let tools = mcp_tools.iter().filter_map(|tool| {
|
|
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
|
|
return None;
|
|
}
|
|
let connector_id = tool.connector_id.as_deref()?;
|
|
Some(codex_connectors::accessible::AccessibleConnectorTool {
|
|
connector_id: connector_id.to_string(),
|
|
connector_name: tool.connector_name.clone(),
|
|
connector_description: tool.namespace_description.clone(),
|
|
plugin_display_names: tool.plugin_display_names.clone(),
|
|
})
|
|
});
|
|
codex_connectors::accessible::collect_accessible_connectors(tools)
|
|
}
|
|
|
|
pub fn with_app_enabled_state(mut connectors: Vec<AppInfo>, config: &Config) -> Vec<AppInfo> {
|
|
let user_apps_config = read_user_apps_config(config);
|
|
let requirements_apps_config = config.config_layer_stack.requirements_toml().apps.as_ref();
|
|
if user_apps_config.is_none() && requirements_apps_config.is_none() {
|
|
return connectors;
|
|
}
|
|
|
|
for connector in &mut connectors {
|
|
if let Some(apps_config) = user_apps_config.as_ref()
|
|
&& (apps_config.default.is_some()
|
|
|| apps_config.apps.contains_key(connector.id.as_str()))
|
|
{
|
|
connector.is_enabled = app_is_enabled(apps_config, Some(connector.id.as_str()));
|
|
}
|
|
|
|
if requirements_apps_config
|
|
.and_then(|apps| apps.apps.get(connector.id.as_str()))
|
|
.is_some_and(|app| app.enabled == Some(false))
|
|
{
|
|
connector.is_enabled = false;
|
|
}
|
|
}
|
|
|
|
connectors
|
|
}
|
|
|
|
pub fn with_app_plugin_sources(
|
|
mut connectors: Vec<AppInfo>,
|
|
tool_plugin_provenance: &ToolPluginProvenance,
|
|
) -> Vec<AppInfo> {
|
|
for connector in &mut connectors {
|
|
connector.plugin_display_names = tool_plugin_provenance
|
|
.plugin_display_names_for_connector_id(connector.id.as_str())
|
|
.to_vec();
|
|
}
|
|
connectors
|
|
}
|
|
|
|
pub(crate) fn app_tool_policy(
|
|
config: &Config,
|
|
connector_id: Option<&str>,
|
|
tool_name: &str,
|
|
tool_title: Option<&str>,
|
|
annotations: Option<&ToolAnnotations>,
|
|
) -> AppToolPolicy {
|
|
let apps_config = read_apps_config(config);
|
|
let managed_approval = managed_app_tool_approval(
|
|
config.config_layer_stack.requirements_toml().apps.as_ref(),
|
|
connector_id,
|
|
tool_name,
|
|
);
|
|
app_tool_policy_from_apps_config(
|
|
apps_config.as_ref(),
|
|
connector_id,
|
|
tool_name,
|
|
tool_title,
|
|
annotations,
|
|
managed_approval,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn codex_app_tool_is_enabled(config: &Config, tool_info: &ToolInfo) -> bool {
|
|
if tool_info.server_name != CODEX_APPS_MCP_SERVER_NAME {
|
|
return true;
|
|
}
|
|
|
|
app_tool_policy(
|
|
config,
|
|
tool_info.connector_id.as_deref(),
|
|
&tool_info.tool.name,
|
|
tool_info.tool.title.as_deref(),
|
|
tool_info.tool.annotations.as_ref(),
|
|
)
|
|
.enabled
|
|
}
|
|
|
|
pub(crate) fn mcp_approvals_reviewer(
|
|
config: &Config,
|
|
server_name: &str,
|
|
connector_id: Option<&str>,
|
|
) -> ApprovalsReviewer {
|
|
let app_reviewer = if server_name == CODEX_APPS_MCP_SERVER_NAME {
|
|
read_user_apps_config(config).and_then(|apps_config| {
|
|
connector_id
|
|
.and_then(|connector_id| apps_config.apps.get(connector_id))
|
|
.and_then(|app| app.approvals_reviewer)
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
if let Some(reviewer) = app_reviewer
|
|
&& config
|
|
.config_layer_stack
|
|
.requirements()
|
|
.approvals_reviewer
|
|
.can_set(&reviewer)
|
|
.is_ok()
|
|
{
|
|
return reviewer;
|
|
}
|
|
|
|
config.approvals_reviewer
|
|
}
|
|
|
|
fn read_apps_config(config: &Config) -> Option<AppsConfigToml> {
|
|
let apps_config = read_user_apps_config(config);
|
|
let had_apps_config = apps_config.is_some();
|
|
let mut apps_config = apps_config.unwrap_or_default();
|
|
apply_requirements_apps_constraints(
|
|
&mut apps_config,
|
|
config.config_layer_stack.requirements_toml().apps.as_ref(),
|
|
);
|
|
if had_apps_config || apps_config.default.is_some() || !apps_config.apps.is_empty() {
|
|
Some(apps_config)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn read_user_apps_config(config: &Config) -> Option<AppsConfigToml> {
|
|
config
|
|
.config_layer_stack
|
|
.effective_config()
|
|
.as_table()
|
|
.and_then(|table| table.get("apps"))
|
|
.cloned()
|
|
.and_then(|value| AppsConfigToml::deserialize(value).ok())
|
|
}
|
|
|
|
fn apply_requirements_apps_constraints(
|
|
apps_config: &mut AppsConfigToml,
|
|
requirements_apps_config: Option<&AppsRequirementsToml>,
|
|
) {
|
|
let Some(requirements_apps_config) = requirements_apps_config else {
|
|
return;
|
|
};
|
|
|
|
for (app_id, requirement) in &requirements_apps_config.apps {
|
|
if requirement.enabled == Some(false) {
|
|
let app = apps_config.apps.entry(app_id.clone()).or_default();
|
|
app.enabled = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn managed_app_tool_approval(
|
|
requirements_apps_config: Option<&AppsRequirementsToml>,
|
|
connector_id: Option<&str>,
|
|
tool_name: &str,
|
|
) -> Option<AppToolApproval> {
|
|
let connector_id = connector_id?;
|
|
requirements_apps_config?
|
|
.apps
|
|
.get(connector_id)?
|
|
.tools
|
|
.as_ref()?
|
|
.tools
|
|
.get(tool_name)?
|
|
.approval_mode
|
|
}
|
|
|
|
fn app_is_enabled(apps_config: &AppsConfigToml, connector_id: Option<&str>) -> bool {
|
|
let default_enabled = apps_config
|
|
.default
|
|
.as_ref()
|
|
.map(|defaults| defaults.enabled)
|
|
.unwrap_or(true);
|
|
|
|
connector_id
|
|
.and_then(|connector_id| apps_config.apps.get(connector_id))
|
|
.map(|app| app.enabled)
|
|
.unwrap_or(default_enabled)
|
|
}
|
|
|
|
fn app_tool_policy_from_apps_config(
|
|
apps_config: Option<&AppsConfigToml>,
|
|
connector_id: Option<&str>,
|
|
tool_name: &str,
|
|
tool_title: Option<&str>,
|
|
annotations: Option<&ToolAnnotations>,
|
|
managed_approval: Option<AppToolApproval>,
|
|
) -> AppToolPolicy {
|
|
let Some(apps_config) = apps_config else {
|
|
return AppToolPolicy {
|
|
approval: managed_approval.unwrap_or(AppToolApproval::Auto),
|
|
..Default::default()
|
|
};
|
|
};
|
|
|
|
let app = connector_id.and_then(|connector_id| apps_config.apps.get(connector_id));
|
|
let tools = app.and_then(|app| app.tools.as_ref());
|
|
let tool_config = tools.and_then(|tools| {
|
|
tools
|
|
.tools
|
|
.get(tool_name)
|
|
.or_else(|| tool_title.and_then(|title| tools.tools.get(title)))
|
|
});
|
|
let approval = managed_approval
|
|
.or_else(|| tool_config.and_then(|tool| tool.approval_mode))
|
|
.or_else(|| app.and_then(|app| app.default_tools_approval_mode))
|
|
.unwrap_or(AppToolApproval::Auto);
|
|
|
|
if !app_is_enabled(apps_config, connector_id) {
|
|
return AppToolPolicy {
|
|
enabled: false,
|
|
approval,
|
|
};
|
|
}
|
|
|
|
if let Some(enabled) = tool_config.and_then(|tool| tool.enabled) {
|
|
return AppToolPolicy { enabled, approval };
|
|
}
|
|
|
|
if let Some(enabled) = app.and_then(|app| app.default_tools_enabled) {
|
|
return AppToolPolicy { enabled, approval };
|
|
}
|
|
|
|
let app_defaults = apps_config.default.as_ref();
|
|
let destructive_enabled = app
|
|
.and_then(|app| app.destructive_enabled)
|
|
.unwrap_or_else(|| {
|
|
app_defaults
|
|
.map(|defaults| defaults.destructive_enabled)
|
|
.unwrap_or(true)
|
|
});
|
|
let open_world_enabled = app
|
|
.and_then(|app| app.open_world_enabled)
|
|
.unwrap_or_else(|| {
|
|
app_defaults
|
|
.map(|defaults| defaults.open_world_enabled)
|
|
.unwrap_or(true)
|
|
});
|
|
let destructive_hint = annotations
|
|
.and_then(|annotations| annotations.destructive_hint)
|
|
.unwrap_or(true);
|
|
let open_world_hint = annotations
|
|
.and_then(|annotations| annotations.open_world_hint)
|
|
.unwrap_or(true);
|
|
let enabled =
|
|
(destructive_enabled || !destructive_hint) && (open_world_enabled || !open_world_hint);
|
|
|
|
AppToolPolicy { enabled, approval }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "connectors_tests.rs"]
|
|
mod tests;
|