Files
codex/codex-rs/core/src/connectors.rs
T
efrazer-oai 2009f6e894 refactor: make auth loading async (#19762)
## Summary

Auth loading used to expose synchronous construction helpers in several
places even though some auth sources now need async work. This PR makes
the auth-loading surface async and updates the callers to await it.

This is intentionally only plumbing. It does not change how
AgentIdentity tokens are decoded, how task runtime ids are allocated, or
how JWT signatures are verified.

## Stack

1. **This PR:** [refactor: make auth loading
async](https://github.com/openai/codex/pull/19762)
2. [refactor: load AgentIdentity runtime
eagerly](https://github.com/openai/codex/pull/19763)
3. [feat: verify AgentIdentity JWTs with
JWKS](https://github.com/openai/codex/pull/19764)

## Important call sites

| Area | Change |
| --- | --- |
| `codex-login` auth loading | `CodexAuth` and `AuthManager`
construction paths now await auth loading. |
| app-server startup | Auth manager construction is awaited during
initialization. |
| CLI/TUI/exec/MCP/chatgpt callers | Existing auth-loading calls now
await the same behavior. |
| cloud requirements storage loader | The loader becomes async so it can
share the same auth construction path. |
| auth tests | Tests that load auth now run in async contexts. |

## Testing

Tests: targeted Rust auth test compilation, formatter, scoped Clippy
fix, and Bazel lock check.
2026-04-27 11:00:27 -07:00

719 lines
23 KiB
Rust

use std::collections::HashMap;
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 anyhow::Context;
use async_channel::unbounded;
use codex_api::SharedAuthProvider;
pub use codex_app_server_protocol::AppBranding;
pub use codex_app_server_protocol::AppInfo;
pub use codex_app_server_protocol::AppMetadata;
use codex_connectors::AllConnectorsCacheKey;
use codex_connectors::DirectoryListResponse;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::EnvironmentManagerArgs;
use codex_exec_server::ExecServerRuntimePaths;
use codex_protocol::models::PermissionProfile;
use codex_tools::DiscoverableTool;
use rmcp::model::ToolAnnotations;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use tracing::warn;
use crate::config::Config;
use crate::mcp::McpManager;
use crate::plugins::PluginsManager;
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::AppsConfigToml;
use codex_config::types::ToolSuggestDiscoverableType;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::default_client::create_client;
use codex_login::default_client::originator;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::McpConnectionManager;
use codex_mcp::McpRuntimeEnvironment;
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::with_codex_apps_mcp;
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
#[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,
auth: Option<&CodexAuth>,
accessible_connectors: &[AppInfo],
) -> anyhow::Result<Vec<DiscoverableTool>> {
let directory_connectors =
list_directory_connectors_for_tool_suggest_with_auth(config, auth).await?;
let connector_ids = tool_suggest_connector_ids(config).await;
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)
.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::is_chatgpt_auth))
{
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: &HashMap<String, 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::new(EnvironmentManagerArgs::from_env(local_runtime_paths));
list_accessible_connectors_from_mcp_tools_with_environment_manager(
config,
force_refetch,
&environment_manager,
)
.await
}
pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
config: &Config,
force_refetch: bool,
environment_manager: &EnvironmentManager,
) -> 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::is_chatgpt_auth))
{
return Ok(AccessibleConnectorsStatus {
connectors: Vec::new(),
codex_apps_ready: true,
});
}
let cache_key = accessible_connectors_cache_key(config, auth.as_ref());
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf()));
let mcp_manager = McpManager::new(Arc::clone(&plugins_manager));
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 = config.to_mcp_config(plugins_manager.as_ref()).await;
let mcp_servers = with_codex_apps_mcp(HashMap::new(), auth.as_ref(), &mcp_config);
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 environment = environment_manager
.default_environment()
.unwrap_or_else(|| environment_manager.local_environment());
let (mcp_connection_manager, cancel_token) = McpConnectionManager::new(
&mcp_servers,
config.mcp_oauth_credentials_store_mode,
auth_status_entries,
&config.permissions.approval_policy,
INITIAL_SUBMIT_ID.to_owned(),
tx_event,
PermissionProfile::default(),
McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()),
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth.as_ref()),
ToolPluginProvenance::default(),
auth.as_ref(),
)
.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
.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);
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(),
});
}
async fn tool_suggest_connector_ids(config: &Config) -> HashSet<String> {
let mut connector_ids = PluginsManager::new(config.codex_home.to_path_buf())
.plugins_for_config(config)
.await
.capability_summaries()
.iter()
.flat_map(|plugin| plugin.app_connector_ids.iter())
.map(|connector_id| connector_id.0.clone())
.collect::<HashSet<_>>();
connector_ids.extend(
config
.tool_suggest
.discoverables
.iter()
.filter(|discoverable| discoverable.kind == ToolSuggestDiscoverableType::Connector)
.map(|discoverable| discoverable.id.clone()),
);
connector_ids
}
async fn list_directory_connectors_for_tool_suggest_with_auth(
config: &Config,
auth: Option<&CodexAuth>,
) -> anyhow::Result<Vec<AppInfo>> {
if !config.features.enabled(Feature::Apps) {
return Ok(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 Ok(Vec::new());
};
let account_id = match auth.get_account_id() {
Some(account_id) if !account_id.is_empty() => account_id,
_ => return Ok(Vec::new()),
};
let auth_provider = codex_model_provider::auth_provider_from_auth(auth);
let is_workspace_account = auth.is_workspace_account();
let cache_key = AllConnectorsCacheKey::new(
config.chatgpt_base_url.clone(),
Some(account_id.clone()),
auth.get_chatgpt_user_id(),
is_workspace_account,
);
codex_connectors::list_all_connectors_with_options(
cache_key,
is_workspace_account,
/*force_refetch*/ false,
|path| {
let auth_provider = auth_provider.clone();
async move {
chatgpt_get_request_with_auth_provider::<DirectoryListResponse>(
config,
path,
auth_provider,
)
.await
}
},
)
.await
}
async fn chatgpt_get_request_with_auth_provider<T: DeserializeOwned>(
config: &Config,
path: String,
auth_provider: SharedAuthProvider,
) -> anyhow::Result<T> {
let client = create_client();
let url = format!("{}{}", config.chatgpt_base_url, path);
let response = client
.get(&url)
.headers(auth_provider.to_auth_headers())
.header("Content-Type", "application/json")
.timeout(DIRECTORY_CONNECTORS_TIMEOUT)
.send()
.await
.context("failed to send request")?;
if response.status().is_success() {
response
.json()
.await
.context("failed to parse JSON response")
} else {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("request failed with status {status}: {body}");
}
}
pub(crate) fn accessible_connectors_from_mcp_tools(
mcp_tools: &HashMap<String, 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.values().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.connector_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);
app_tool_policy_from_apps_config(
apps_config.as_ref(),
connector_id,
tool_name,
tool_title,
annotations,
)
}
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
}
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) {
continue;
}
let app = apps_config.apps.entry(app_id.clone()).or_default();
app.enabled = false;
}
}
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>,
) -> AppToolPolicy {
let Some(apps_config) = apps_config else {
return AppToolPolicy::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 = 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;