Codex Handbook
chatgpt/src/connectors.rs 281 lines
use std::collections::HashMap;use std::collections::HashSet;use std::time::Duration;use crate::chatgpt_client::chatgpt_get_request_with_timeout;use codex_app_server_protocol::AppInfo;use codex_connectors::ConnectorDirectoryCacheContext;use codex_connectors::ConnectorDirectoryCacheKey;use codex_connectors::DirectoryListResponse;use codex_connectors::filter::filter_disallowed_connectors;use codex_connectors::merge::merge_connectors;use codex_connectors::merge::merge_plugin_connectors;use codex_core::config::Config;pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools;pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager;pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_mcp_manager;pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options;pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status;pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools;pub use codex_core::connectors::with_app_enabled_state;use codex_login::AuthManager;use codex_login::CodexAuth;use codex_login::default_client::originator;use codex_plugin::AppConnectorId;const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);async fn apps_enabled(config: &Config) -> bool {    let auth_manager =        AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await;    let auth = auth_manager.auth().await;    config        .features        .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend))}async fn connector_auth(config: &Config) -> anyhow::Result<CodexAuth> {    let auth_manager =        AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await;    let auth = auth_manager        .auth()        .await        .ok_or_else(|| anyhow::anyhow!("ChatGPT auth not available"))?;    anyhow::ensure!(        auth.uses_codex_backend(),        "ChatGPT connectors require Codex backend auth"    );    Ok(auth)}pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {    if !apps_enabled(config).await {        return Ok(Vec::new());    }    let (connectors_result, accessible_result) = tokio::join!(        list_all_connectors(config),        list_accessible_connectors_from_mcp_tools(config),    );    let connectors = connectors_result?;    let accessible = accessible_result?;    Ok(with_app_enabled_state(        merge_connectors_with_accessible(            connectors, accessible, /*all_connectors_loaded*/ true,        ),        config,    ))}pub async fn list_all_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {    list_all_connectors_with_options(config, /*force_refetch*/ false, &[]).await}pub async fn list_cached_all_connectors(    config: &Config,    plugin_apps: &[AppConnectorId],) -> Option<Vec<AppInfo>> {    if !apps_enabled(config).await {        return Some(Vec::new());    }    let auth = connector_auth(config).await.ok()?;    let cache_context = connector_directory_cache_context(config, &auth);    let connectors = codex_connectors::cached_directory_connectors(&cache_context)?;    Some(merge_and_filter_plugin_connectors(connectors, plugin_apps))}pub async fn list_all_connectors_with_options(    config: &Config,    force_refetch: bool,    plugin_apps: &[AppConnectorId],) -> anyhow::Result<Vec<AppInfo>> {    if !apps_enabled(config).await {        return Ok(Vec::new());    }    let auth = connector_auth(config).await?;    let cache_context = connector_directory_cache_context(config, &auth);    let connectors = codex_connectors::list_all_connectors_with_options(        cache_context,        auth.is_workspace_account(),        force_refetch,        |path| async move {            chatgpt_get_request_with_timeout::<DirectoryListResponse>(                config,                path,                Some(DIRECTORY_CONNECTORS_TIMEOUT),            )            .await        },    )    .await?;    Ok(merge_and_filter_plugin_connectors(connectors, plugin_apps))}fn connector_directory_cache_context(    config: &Config,    auth: &CodexAuth,) -> ConnectorDirectoryCacheContext {    ConnectorDirectoryCacheContext::new(        config.codex_home.to_path_buf(),        ConnectorDirectoryCacheKey::new(            config.chatgpt_base_url.clone(),            auth.get_account_id(),            auth.get_chatgpt_user_id(),            auth.is_workspace_account(),        ),    )}fn merge_and_filter_plugin_connectors(    connectors: Vec<AppInfo>,    plugin_apps: &[AppConnectorId],) -> Vec<AppInfo> {    let connectors = merge_plugin_connectors(        connectors,        plugin_apps            .iter()            .map(|connector_id| connector_id.0.clone()),    );    filter_disallowed_connectors(connectors, originator().value.as_str())}pub fn connectors_for_plugin_apps(    connectors: Vec<AppInfo>,    plugin_apps: &[AppConnectorId],) -> Vec<AppInfo> {    let connectors = merge_plugin_connectors(        connectors,        plugin_apps            .iter()            .map(|connector_id| connector_id.0.clone()),    );    let mut connectors_by_id =        filter_disallowed_connectors(connectors, originator().value.as_str())            .into_iter()            .map(|connector| (connector.id.clone(), connector))            .collect::<HashMap<_, _>>();    plugin_apps        .iter()        .filter_map(|connector_id| connectors_by_id.remove(connector_id.0.as_str()))        .collect()}pub fn merge_connectors_with_accessible(    connectors: Vec<AppInfo>,    accessible_connectors: Vec<AppInfo>,    all_connectors_loaded: bool,) -> Vec<AppInfo> {    let accessible_connectors = if all_connectors_loaded {        let connector_ids: HashSet<&str> = connectors            .iter()            .map(|connector| connector.id.as_str())            .collect();        accessible_connectors            .into_iter()            .filter(|connector| connector_ids.contains(connector.id.as_str()))            .collect()    } else {        accessible_connectors    };    let merged = merge_connectors(connectors, accessible_connectors);    filter_disallowed_connectors(merged, originator().value.as_str())}#[cfg(test)]mod tests {    use super::*;    use codex_connectors::metadata::connector_install_url;    use codex_plugin::AppConnectorId;    use pretty_assertions::assert_eq;    fn app(id: &str) -> AppInfo {        AppInfo {            id: id.to_string(),            name: id.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(),        }    }    fn merged_app(id: &str, is_accessible: bool) -> AppInfo {        AppInfo {            id: id.to_string(),            name: id.to_string(),            description: None,            logo_url: None,            logo_url_dark: None,            distribution_channel: None,            branding: None,            app_metadata: None,            labels: None,            install_url: Some(connector_install_url(id, id)),            is_accessible,            is_enabled: true,            plugin_display_names: Vec::new(),        }    }    #[test]    fn excludes_accessible_connectors_not_in_all_when_all_loaded() {        let merged = merge_connectors_with_accessible(            vec![app("alpha")],            vec![app("alpha"), app("beta")],            /*all_connectors_loaded*/ true,        );        assert_eq!(merged, vec![merged_app("alpha", /*is_accessible*/ true)]);    }    #[test]    fn keeps_accessible_connectors_not_in_all_while_all_loading() {        let merged = merge_connectors_with_accessible(            vec![app("alpha")],            vec![app("alpha"), app("beta")],            /*all_connectors_loaded*/ false,        );        assert_eq!(            merged,            vec![                merged_app("alpha", /*is_accessible*/ true),                merged_app("beta", /*is_accessible*/ true)            ]        );    }    #[test]    fn connectors_for_plugin_apps_returns_only_requested_plugin_apps() {        let connectors = connectors_for_plugin_apps(            vec![app("alpha"), app("beta")],            &[                AppConnectorId("gmail".to_string()),                AppConnectorId("alpha".to_string()),                AppConnectorId("gmail".to_string()),            ],        );        assert_eq!(            connectors,            vec![merged_app("gmail", /*is_accessible*/ false), app("alpha")]        );    }    #[test]    fn connectors_for_plugin_apps_filters_disallowed_plugin_apps() {        let connectors = connectors_for_plugin_apps(            Vec::new(),            &[AppConnectorId(                "asdk_app_6938a94a61d881918ef32cb999ff937c".to_string(),            )],        );        assert_eq!(connectors, Vec::<AppInfo>::new());    }}