use std::collections::HashMap;use std::future::Future;use std::sync::LazyLock;use std::sync::Mutex as StdMutex;use std::time::Duration;use std::time::Instant;use codex_app_server_protocol::AppBranding;use codex_app_server_protocol::AppInfo;use codex_app_server_protocol::AppMetadata;use serde::Deserialize;use serde::Serialize;pub mod accessible;mod app_tool_policy;mod directory_cache;pub mod filter;pub mod merge;pub mod metadata;pub use app_tool_policy::AppToolPolicy;pub use app_tool_policy::AppToolPolicyEvaluator;pub use app_tool_policy::AppToolPolicyInput;pub use app_tool_policy::app_is_enabled;pub use app_tool_policy::apps_config_from_layer_stack;pub use directory_cache::ConnectorDirectoryCacheContext;pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600);#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]pub struct ConnectorDirectoryCacheKey { chatgpt_base_url: String, account_id: Option<String>, chatgpt_user_id: Option<String>, is_workspace_account: bool,}impl ConnectorDirectoryCacheKey { pub fn new( chatgpt_base_url: String, account_id: Option<String>, chatgpt_user_id: Option<String>, is_workspace_account: bool, ) -> Self { Self { chatgpt_base_url, account_id, chatgpt_user_id, is_workspace_account, } }}#[derive(Clone)]struct CachedConnectorDirectory { key: ConnectorDirectoryCacheKey, expires_at: Instant, connectors: Vec<AppInfo>,}static CONNECTOR_DIRECTORY_CACHE: LazyLock<StdMutex<Option<CachedConnectorDirectory>>> = LazyLock::new(|| StdMutex::new(None));#[derive(Debug, Deserialize)]pub struct DirectoryListResponse { apps: Vec<DirectoryApp>, #[serde(alias = "nextToken")] next_token: Option<String>,}#[derive(Debug, Deserialize, Clone)]pub struct DirectoryApp { id: String, name: String, description: Option<String>, #[serde(alias = "appMetadata")] app_metadata: Option<AppMetadata>, branding: Option<AppBranding>, labels: Option<HashMap<String, String>>, #[serde(alias = "logoUrl")] logo_url: Option<String>, #[serde(alias = "logoUrlDark")] logo_url_dark: Option<String>, #[serde(alias = "distributionChannel")] distribution_channel: Option<String>, visibility: Option<String>,}pub fn cached_directory_connectors( cache_context: &ConnectorDirectoryCacheContext,) -> Option<Vec<AppInfo>> { if let Some(cached_connectors) = cached_directory_connectors_in_memory(&cache_context.cache_key) { return Some(cached_connectors); } let directory_cache::CachedConnectorDirectoryDiskLoad::Hit { connectors } = directory_cache::load_cached_directory_connectors_from_disk(cache_context) else { return None; }; write_cached_directory_connectors_in_memory( cache_context.cache_key.clone(), &connectors, Duration::ZERO, ); Some(connectors)}fn cached_directory_connectors_in_memory( cache_key: &ConnectorDirectoryCacheKey,) -> Option<Vec<AppInfo>> { let cache_guard = CONNECTOR_DIRECTORY_CACHE .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); cache_guard .as_ref() .filter(|cached| cached.key == *cache_key) .map(|cached| cached.connectors.clone())}fn unexpired_directory_connectors_in_memory( cache_key: &ConnectorDirectoryCacheKey,) -> Option<Vec<AppInfo>> { let cache_guard = CONNECTOR_DIRECTORY_CACHE .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let cached = cache_guard.as_ref()?; if cached.key == *cache_key && Instant::now() < cached.expires_at { return Some(cached.connectors.clone()); } None}pub async fn list_all_connectors_with_options<F, Fut>( cache_context: ConnectorDirectoryCacheContext, is_workspace_account: bool, force_refetch: bool, mut fetch_page: F,) -> anyhow::Result<Vec<AppInfo>>where F: FnMut(String) -> Fut, Fut: Future<Output = anyhow::Result<DirectoryListResponse>>,{ if !force_refetch && let Some(cached_connectors) = unexpired_directory_connectors_in_memory(&cache_context.cache_key) { return Ok(cached_connectors); } let mut apps = list_directory_connectors(&mut fetch_page).await?; if is_workspace_account { apps.extend(list_workspace_connectors(&mut fetch_page).await?); } let mut connectors = merge_directory_apps(apps) .into_iter() .map(directory_app_to_app_info) .collect::<Vec<_>>(); for connector in &mut connectors { let install_url = match connector.install_url.take() { Some(install_url) => install_url, None => connector_install_url(&connector.name, &connector.id), }; connector.name = normalize_connector_name(&connector.name, &connector.id); connector.description = normalize_connector_value(connector.description.as_deref()); connector.install_url = Some(install_url); connector.is_accessible = false; } connectors.sort_by(|left, right| { left.name .cmp(&right.name) .then_with(|| left.id.cmp(&right.id)) }); write_cached_directory_connectors(&cache_context, &connectors); Ok(connectors)}fn write_cached_directory_connectors( cache_context: &ConnectorDirectoryCacheContext, connectors: &[AppInfo],) { write_cached_directory_connectors_in_memory( cache_context.cache_key.clone(), connectors, CONNECTORS_CACHE_TTL, ); directory_cache::write_cached_directory_connectors_to_disk(cache_context, connectors);}fn write_cached_directory_connectors_in_memory( cache_key: ConnectorDirectoryCacheKey, connectors: &[AppInfo], ttl: Duration,) { let mut cache_guard = CONNECTOR_DIRECTORY_CACHE .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); *cache_guard = Some(CachedConnectorDirectory { key: cache_key, expires_at: Instant::now() + ttl, connectors: connectors.to_vec(), });}async fn list_directory_connectors<F, Fut>(fetch_page: &mut F) -> anyhow::Result<Vec<DirectoryApp>>where F: FnMut(String) -> Fut, Fut: Future<Output = anyhow::Result<DirectoryListResponse>>,{ let mut apps = Vec::new(); let mut next_token: Option<String> = None; loop { let path = match next_token.as_deref() { Some(token) => { let encoded_token = urlencoding::encode(token); format!("/connectors/directory/list?token={encoded_token}&external_logos=true") } None => "/connectors/directory/list?external_logos=true".to_string(), }; let response = fetch_page(path).await?; apps.extend( response .apps .into_iter() .filter(|app| !is_hidden_directory_app(app)), ); next_token = response .next_token .map(|token| token.trim().to_string()) .filter(|token| !token.is_empty()); if next_token.is_none() { break; } } Ok(apps)}async fn list_workspace_connectors<F, Fut>(fetch_page: &mut F) -> anyhow::Result<Vec<DirectoryApp>>where F: FnMut(String) -> Fut, Fut: Future<Output = anyhow::Result<DirectoryListResponse>>,{ let response = fetch_page("/connectors/directory/list_workspace?external_logos=true".to_string()).await; match response { Ok(response) => Ok(response .apps .into_iter() .filter(|app| !is_hidden_directory_app(app)) .collect()), Err(_) => Ok(Vec::new()), }}fn merge_directory_apps(apps: Vec<DirectoryApp>) -> Vec<DirectoryApp> { let mut merged: HashMap<String, DirectoryApp> = HashMap::new(); for app in apps { if let Some(existing) = merged.get_mut(&app.id) { merge_directory_app(existing, app); } else { merged.insert(app.id.clone(), app); } } merged.into_values().collect()}fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) { let DirectoryApp { id: _, name, description, app_metadata, branding, labels, logo_url, logo_url_dark, distribution_channel, visibility: _, } = incoming; let incoming_name_is_empty = name.trim().is_empty(); if existing.name.trim().is_empty() && !incoming_name_is_empty { existing.name = name; } let incoming_description_present = description .as_deref() .map(|value| !value.trim().is_empty()) .unwrap_or(false); if incoming_description_present { existing.description = description; } if existing.logo_url.is_none() && logo_url.is_some() { existing.logo_url = logo_url; } if existing.logo_url_dark.is_none() && logo_url_dark.is_some() { existing.logo_url_dark = logo_url_dark; } if existing.distribution_channel.is_none() && distribution_channel.is_some() { existing.distribution_channel = distribution_channel; } if let Some(incoming_branding) = branding { if let Some(existing_branding) = existing.branding.as_mut() { if existing_branding.category.is_none() && incoming_branding.category.is_some() { existing_branding.category = incoming_branding.category; } if existing_branding.developer.is_none() && incoming_branding.developer.is_some() { existing_branding.developer = incoming_branding.developer; } if existing_branding.website.is_none() && incoming_branding.website.is_some() { existing_branding.website = incoming_branding.website; } if existing_branding.privacy_policy.is_none() && incoming_branding.privacy_policy.is_some() { existing_branding.privacy_policy = incoming_branding.privacy_policy; } if existing_branding.terms_of_service.is_none() && incoming_branding.terms_of_service.is_some() { existing_branding.terms_of_service = incoming_branding.terms_of_service; } if !existing_branding.is_discoverable_app && incoming_branding.is_discoverable_app { existing_branding.is_discoverable_app = true; } } else { existing.branding = Some(incoming_branding); } } if let Some(incoming_app_metadata) = app_metadata { if let Some(existing_app_metadata) = existing.app_metadata.as_mut() { if existing_app_metadata.review.is_none() && incoming_app_metadata.review.is_some() { existing_app_metadata.review = incoming_app_metadata.review; } if existing_app_metadata.categories.is_none() && incoming_app_metadata.categories.is_some() { existing_app_metadata.categories = incoming_app_metadata.categories; } if existing_app_metadata.sub_categories.is_none() && incoming_app_metadata.sub_categories.is_some() { existing_app_metadata.sub_categories = incoming_app_metadata.sub_categories; } if existing_app_metadata.seo_description.is_none() && incoming_app_metadata.seo_description.is_some() { existing_app_metadata.seo_description = incoming_app_metadata.seo_description; } if existing_app_metadata.screenshots.is_none() && incoming_app_metadata.screenshots.is_some() { existing_app_metadata.screenshots = incoming_app_metadata.screenshots; } if existing_app_metadata.developer.is_none() && incoming_app_metadata.developer.is_some() { existing_app_metadata.developer = incoming_app_metadata.developer; } if existing_app_metadata.version.is_none() && incoming_app_metadata.version.is_some() { existing_app_metadata.version = incoming_app_metadata.version; } if existing_app_metadata.version_id.is_none() && incoming_app_metadata.version_id.is_some() { existing_app_metadata.version_id = incoming_app_metadata.version_id; } if existing_app_metadata.version_notes.is_none() && incoming_app_metadata.version_notes.is_some() { existing_app_metadata.version_notes = incoming_app_metadata.version_notes; } if existing_app_metadata.first_party_type.is_none() && incoming_app_metadata.first_party_type.is_some() { existing_app_metadata.first_party_type = incoming_app_metadata.first_party_type; } if existing_app_metadata.first_party_requires_install.is_none() && incoming_app_metadata.first_party_requires_install.is_some() { existing_app_metadata.first_party_requires_install = incoming_app_metadata.first_party_requires_install; } if existing_app_metadata .show_in_composer_when_unlinked .is_none() && incoming_app_metadata .show_in_composer_when_unlinked .is_some() { existing_app_metadata.show_in_composer_when_unlinked = incoming_app_metadata.show_in_composer_when_unlinked; } } else { existing.app_metadata = Some(incoming_app_metadata); } } if existing.labels.is_none() && labels.is_some() { existing.labels = labels; }}fn is_hidden_directory_app(app: &DirectoryApp) -> bool { matches!(app.visibility.as_deref(), Some("HIDDEN"))}fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo { AppInfo { id: app.id, name: app.name, description: app.description, logo_url: app.logo_url, logo_url_dark: app.logo_url_dark, distribution_channel: app.distribution_channel, branding: app.branding, app_metadata: app.app_metadata, labels: app.labels, install_url: None, is_accessible: false, is_enabled: true, plugin_display_names: Vec::new(), }}fn connector_install_url(name: &str, connector_id: &str) -> String { let slug = connector_name_slug(name); format!("https://chatgpt.com/apps/{slug}/{connector_id}")}fn connector_name_slug(name: &str) -> String { let mut normalized = String::with_capacity(name.len()); for character in name.chars() { if character.is_ascii_alphanumeric() { normalized.push(character.to_ascii_lowercase()); } else { normalized.push('-'); } } let normalized = normalized.trim_matches('-'); if normalized.is_empty() { "app".to_string() } else { normalized.to_string() }}fn normalize_connector_name(name: &str, connector_id: &str) -> String { let trimmed = name.trim(); if trimmed.is_empty() { connector_id.to_string() } else { trimmed.to_string() }}fn normalize_connector_value(value: Option<&str>) -> Option<String> { value .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string)}#[cfg(test)]mod tests { use super::*; use pretty_assertions::assert_eq; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; use tempfile::TempDir; static CONNECTOR_DIRECTORY_CACHE_TEST_LOCK: LazyLock<tokio::sync::Mutex<()>> = LazyLock::new(|| tokio::sync::Mutex::new(())); fn cache_key(id: &str) -> ConnectorDirectoryCacheKey { ConnectorDirectoryCacheKey::new( "https://chatgpt.example".to_string(), Some(format!("account-{id}")), Some(format!("user-{id}")), /*is_workspace_account*/ true, ) } fn cache_context(codex_home: &TempDir, id: &str) -> ConnectorDirectoryCacheContext { ConnectorDirectoryCacheContext::new(codex_home.path().to_path_buf(), cache_key(id)) } fn clear_directory_memory_cache() { let mut cache_guard = CONNECTOR_DIRECTORY_CACHE .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); *cache_guard = None; } fn app(id: &str, name: &str) -> DirectoryApp { DirectoryApp { id: id.to_string(), name: name.to_string(), description: None, app_metadata: None, branding: None, labels: None, logo_url: None, logo_url_dark: None, distribution_channel: None, visibility: None, } } #[tokio::test] #[expect( clippy::await_holding_invalid_type, reason = "test serializes access to the shared connector cache for its full duration" )] async fn list_all_connectors_uses_shared_directory_cache() -> anyhow::Result<()> { let _cache_guard = CONNECTOR_DIRECTORY_CACHE_TEST_LOCK.lock().await; let calls = Arc::new(AtomicUsize::new(0)); let call_counter = Arc::clone(&calls); let codex_home = TempDir::new()?; let cache_context = cache_context(&codex_home, "shared"); let first = list_all_connectors_with_options( cache_context.clone(), /*is_workspace_account*/ false, /*force_refetch*/ false, move |_path| { let call_counter = Arc::clone(&call_counter); async move { call_counter.fetch_add(1, Ordering::SeqCst); Ok(DirectoryListResponse { apps: vec![app("alpha", "Alpha")], next_token: None, }) } }, ) .await?; let second = list_all_connectors_with_options( cache_context, /*is_workspace_account*/ false, /*force_refetch*/ false, move |_path| async move { anyhow::bail!("cache should have been used"); }, ) .await?; assert_eq!(calls.load(Ordering::SeqCst), 1); assert_eq!(first, second); Ok(()) } #[tokio::test] #[expect( clippy::await_holding_invalid_type, reason = "test serializes access to the shared connector cache for its full duration" )] async fn list_all_connectors_merges_and_normalizes_directory_apps() -> anyhow::Result<()> { let _cache_guard = CONNECTOR_DIRECTORY_CACHE_TEST_LOCK.lock().await; let codex_home = TempDir::new()?; let cache_context = cache_context(&codex_home, "merged"); let calls = Arc::new(AtomicUsize::new(0)); let call_counter = Arc::clone(&calls); let connectors = list_all_connectors_with_options( cache_context, /*is_workspace_account*/ true, /*force_refetch*/ true, move |path| { let call_counter = Arc::clone(&call_counter); async move { call_counter.fetch_add(1, Ordering::SeqCst); if path.starts_with("/connectors/directory/list_workspace") { Ok(DirectoryListResponse { apps: vec![ DirectoryApp { description: Some("Merged description".to_string()), branding: Some(AppBranding { category: Some("calendar".to_string()), developer: None, website: None, privacy_policy: None, terms_of_service: None, is_discoverable_app: true, }), ..app("alpha", "") }, DirectoryApp { visibility: Some("HIDDEN".to_string()), ..app("hidden", "Hidden") }, ], next_token: None, }) } else { Ok(DirectoryListResponse { apps: vec![app("alpha", " Alpha "), app("beta", "Beta")], next_token: None, }) } } }, ) .await?; assert_eq!(calls.load(Ordering::SeqCst), 2); assert_eq!(connectors.len(), 2); assert_eq!(connectors[0].id, "alpha"); assert_eq!(connectors[0].name, "Alpha"); assert_eq!( connectors[0].description.as_deref(), Some("Merged description") ); assert_eq!( connectors[0].install_url.as_deref(), Some("https://chatgpt.com/apps/alpha/alpha") ); assert_eq!( connectors[0] .branding .as_ref() .and_then(|branding| branding.category.as_deref()), Some("calendar") ); assert_eq!(connectors[1].id, "beta"); assert_eq!(connectors[1].name, "Beta"); Ok(()) } #[tokio::test] #[expect( clippy::await_holding_invalid_type, reason = "test serializes access to the shared connector cache for its full duration" )] async fn cached_directory_connectors_reads_directory_disk_cache() -> anyhow::Result<()> { let _cache_guard = CONNECTOR_DIRECTORY_CACHE_TEST_LOCK.lock().await; let codex_home = TempDir::new()?; let cache_context = cache_context(&codex_home, "disk"); let calls = Arc::new(AtomicUsize::new(0)); let call_counter = Arc::clone(&calls); let first = list_all_connectors_with_options( cache_context.clone(), /*is_workspace_account*/ false, /*force_refetch*/ false, move |_path| { let call_counter = Arc::clone(&call_counter); async move { call_counter.fetch_add(1, Ordering::SeqCst); Ok(DirectoryListResponse { apps: vec![app("alpha", "Alpha")], next_token: None, }) } }, ) .await?; clear_directory_memory_cache(); let second = cached_directory_connectors(&cache_context).expect("disk cache should load"); assert_eq!(calls.load(Ordering::SeqCst), 1); assert_eq!(first, second); Ok(()) } #[tokio::test] #[expect( clippy::await_holding_invalid_type, reason = "test serializes access to the shared connector cache for its full duration" )] async fn list_all_connectors_refreshes_when_only_directory_disk_cache_exists() -> anyhow::Result<()> { let _cache_guard = CONNECTOR_DIRECTORY_CACHE_TEST_LOCK.lock().await; let codex_home = TempDir::new()?; let cache_context = cache_context(&codex_home, "disk-refresh"); let calls = Arc::new(AtomicUsize::new(0)); let call_counter = Arc::clone(&calls); list_all_connectors_with_options( cache_context.clone(), /*is_workspace_account*/ false, /*force_refetch*/ false, move |_path| { let call_counter = Arc::clone(&call_counter); async move { call_counter.fetch_add(1, Ordering::SeqCst); Ok(DirectoryListResponse { apps: vec![app("alpha", "Alpha")], next_token: None, }) } }, ) .await?; clear_directory_memory_cache(); let mut cached_expected = directory_app_to_app_info(app("alpha", "Alpha")); cached_expected.install_url = Some(connector_install_url( &cached_expected.name, &cached_expected.id, )); assert_eq!( cached_directory_connectors(&cache_context), Some(vec![cached_expected]) ); let refreshed_calls = Arc::clone(&calls); let refreshed = list_all_connectors_with_options( cache_context, /*is_workspace_account*/ false, /*force_refetch*/ false, move |_path| { let call_counter = Arc::clone(&refreshed_calls); async move { call_counter.fetch_add(1, Ordering::SeqCst); Ok(DirectoryListResponse { apps: vec![app("beta", "Beta")], next_token: None, }) } }, ) .await?; let mut expected = directory_app_to_app_info(app("beta", "Beta")); expected.install_url = Some(connector_install_url(&expected.name, &expected.id)); assert_eq!(calls.load(Ordering::SeqCst), 2); assert_eq!(refreshed, vec![expected]); Ok(()) } #[tokio::test] async fn cached_directory_connectors_drops_stale_disk_schema() -> anyhow::Result<()> { let _cache_guard = CONNECTOR_DIRECTORY_CACHE_TEST_LOCK.lock().await; clear_directory_memory_cache(); let codex_home = TempDir::new()?; let cache_context = cache_context(&codex_home, "stale-schema"); let cache_path = cache_context.cache_path(); std::fs::create_dir_all(cache_path.parent().expect("cache parent"))?; std::fs::write( &cache_path, serde_json::to_vec_pretty(&serde_json::json!({ "schema_version": 0, "connectors": [], }))?, )?; assert_eq!(cached_directory_connectors(&cache_context), None); assert!(!cache_path.exists()); Ok(()) } #[tokio::test] async fn list_directory_connectors_omits_tier_for_all_pages() -> anyhow::Result<()> { let requested_paths: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new())); let paths = Arc::clone(&requested_paths); let apps = list_directory_connectors(&mut move |path| { let paths = Arc::clone(&paths); async move { paths .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .push(path.clone()); if path == "/connectors/directory/list?external_logos=true" { Ok(DirectoryListResponse { apps: vec![app("alpha", "Alpha")], next_token: Some("page 2".to_string()), }) } else { assert_eq!( path, "/connectors/directory/list?token=page%202&external_logos=true" ); Ok(DirectoryListResponse { apps: vec![app("beta", "Beta")], next_token: None, }) } } }) .await?; assert_eq!( apps.iter().map(|app| app.id.as_str()).collect::<Vec<_>>(), vec!["alpha", "beta"] ); assert_eq!( requested_paths .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .as_slice(), &[ "/connectors/directory/list?external_logos=true".to_string(), "/connectors/directory/list?token=page%202&external_logos=true".to_string(), ] ); Ok(()) }}