1use std::collections::HashMap;
2use std::collections::HashSet;
3use std::time::Duration;
4
5use crate::chatgpt_client::chatgpt_get_request_with_timeout;
6use crate::chatgpt_client::chatgpt_post_request_with_timeout;
7
8use codex_connectors::AppInfo;
9use codex_connectors::ConnectorDirectoryCacheContext;
10use codex_connectors::ConnectorDirectoryCacheKey;
11use codex_connectors::ConnectorMetadata;
12use codex_connectors::ConnectorMetadataStore;
13use codex_connectors::ConnectorToolSummary;
14use codex_connectors::DirectoryListResponse;
15use codex_connectors::merge::merge_connectors;
16use codex_connectors::merge::merge_plugin_connectors;
17use codex_core::config::Config;
18pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools;
19pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager;
20pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_mcp_manager;
21pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options;
22pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status;
23pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools;
24pub use codex_core::connectors::with_app_enabled_state;
25use codex_login::AuthManager;
26use codex_login::CodexAuth;
27use codex_plugin::AppConnectorId;
28use serde::Deserialize;
29use serde::Serialize;
30
31const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
32const CONNECTOR_METADATA_TIMEOUT: Duration = Duration::from_secs(60);
33const DEFAULT_APPS_PRODUCT_SKU: &str = "codex";
34
35async fn apps_enabled(config: &Config) -> bool {
36 let auth_manager =
37 AuthManager::shared_from_config(config, false).await;
38 let auth = auth_manager.auth().await;
39 config
40 .features
41 .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend))
42}
43
44async fn connector_auth(config: &Config) -> anyhow::Result<CodexAuth> {
45 let auth_manager =
46 AuthManager::shared_from_config(config, false).await;
47 let auth = auth_manager
48 .auth()
49 .await
50 .ok_or_else(|| anyhow::anyhow!("ChatGPT auth not available"))?;
51 anyhow::ensure!(
52 auth.uses_codex_backend(),
53 "ChatGPT connectors require Codex backend auth"
54 );
55 Ok(auth)
56}
57
58pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
59 if !apps_enabled(config).await {
60 return Ok(Vec::new());
61 }
62 let (connectors_result, accessible_result) = tokio::join!(
63 list_all_connectors(config),
64 list_accessible_connectors_from_mcp_tools(config),
65 );
66 let connectors = connectors_result?;
67 let accessible = accessible_result?;
68 Ok(with_app_enabled_state(
69 merge_connectors_with_accessible(
70 connectors, accessible, true,
71 ),
72 config,
73 ))
74}
75
76pub async fn list_all_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
77 list_all_connectors_with_options(config, false, &[]).await
78}
79
80pub async fn list_cached_all_connectors(
81 config: &Config,
82 plugin_apps: &[AppConnectorId],
83) -> Option<Vec<AppInfo>> {
84 if !apps_enabled(config).await {
85 return Some(Vec::new());
86 }
87
88 let auth = connector_auth(config).await.ok()?;
89 let cache_context = connector_directory_cache_context(config, &auth);
90 let connectors = codex_connectors::cached_directory_connectors(&cache_context)?;
91 Some(merge_directory_and_plugin_connectors(
92 connectors,
93 plugin_apps,
94 ))
95}
96
97pub async fn list_all_connectors_with_options(
98 config: &Config,
99 force_refetch: bool,
100 plugin_apps: &[AppConnectorId],
101) -> anyhow::Result<Vec<AppInfo>> {
102 if !apps_enabled(config).await {
103 return Ok(Vec::new());
104 }
105 let auth = connector_auth(config).await?;
106 let cache_context = connector_directory_cache_context(config, &auth);
107 let connectors = codex_connectors::list_all_connectors_with_options(
108 cache_context,
109 auth.is_workspace_account(),
110 force_refetch,
111 |path| async move {
112 chatgpt_get_request_with_timeout::<DirectoryListResponse>(
113 config,
114 path,
115 Some(DIRECTORY_CONNECTORS_TIMEOUT),
116 )
117 .await
118 },
119 )
120 .await?;
121 Ok(merge_directory_and_plugin_connectors(
122 connectors,
123 plugin_apps,
124 ))
125}
126
127pub struct ConnectorMetadataReadResult {
128 pub apps: Vec<ConnectorMetadata>,
129 pub missing_app_ids: Vec<String>,
130}
131
132pub async fn read_connector_metadata(
137 config: &Config,
138 auth: &CodexAuth,
139 app_ids: &[String],
140 include_tools: bool,
141) -> anyhow::Result<ConnectorMetadataReadResult> {
142 anyhow::ensure!(
143 auth.uses_codex_backend(),
144 "ChatGPT backend requests require Codex backend auth"
145 );
146 anyhow::ensure!(
147 auth.get_account_id().is_some(),
148 "ChatGPT account ID not available, please re-run codex login"
149 );
150
151 let store = ConnectorMetadataStore::new(
152 config.chatgpt_base_url.clone(),
153 auth.get_account_id(),
154 auth.get_chatgpt_user_id(),
155 auth.is_workspace_account(),
156 );
157 let mut metadata_by_id = store.fresh_records(app_ids, include_tools);
158 let missing_ids = app_ids
159 .iter()
160 .filter(|app_id| !metadata_by_id.contains_key(app_id.as_str()))
161 .cloned()
162 .collect::<Vec<_>>();
163
164 if !missing_ids.is_empty() {
165 let product_sku = config
166 .apps_mcp_product_sku
167 .as_deref()
168 .unwrap_or(DEFAULT_APPS_PRODUCT_SKU);
169 let response: GetAppsResponse = chatgpt_post_request_with_timeout(
170 config,
171 auth,
172 "/ps/apps/batch".to_string(),
173 &GetAppsRequest {
174 app_ids: &missing_ids,
175 include_tools,
176 },
177 CONNECTOR_METADATA_TIMEOUT,
178 product_sku,
179 )
180 .await?;
181 let mut requested_ids = missing_ids.iter().cloned().collect::<HashSet<_>>();
182 let fetched = response
183 .apps
184 .into_iter()
185 .map(batch_app_to_metadata)
186 .filter(|metadata| requested_ids.remove(&metadata.id))
187 .collect::<Vec<_>>();
188 store.commit(&fetched);
189 metadata_by_id.extend(
190 fetched
191 .into_iter()
192 .map(|metadata| (metadata.id.clone(), metadata)),
193 );
194 }
195
196 let mut apps = Vec::new();
197 let mut missing_app_ids = Vec::new();
198 for app_id in app_ids {
199 if let Some(mut metadata) = metadata_by_id.remove(app_id) {
200 if !include_tools {
201 metadata.tool_summaries = None;
202 }
203 apps.push(metadata);
204 } else {
205 missing_app_ids.push(app_id.clone());
206 }
207 }
208
209 Ok(ConnectorMetadataReadResult {
210 apps,
211 missing_app_ids,
212 })
213}
214
215#[derive(Serialize)]
216struct GetAppsRequest<'a> {
217 app_ids: &'a [String],
218 include_tools: bool,
219}
220
221#[derive(Deserialize)]
222struct GetAppsResponse {
223 apps: Vec<BatchApp>,
224}
225
226#[derive(Deserialize)]
231struct BatchApp {
232 id: String,
233 name: String,
234 description: Option<String>,
235 icon_url: Option<String>,
236 #[serde(default, rename = "icon_dark_url", alias = "icon_url_dark")]
237 icon_url_dark: Option<String>,
238 #[serde(default)]
239 distribution_channel: Option<String>,
240 #[serde(default)]
241 tools: Option<Vec<BatchAppToolSummary>>,
242}
243
244#[derive(Deserialize)]
245struct BatchAppToolSummary {
246 name: String,
247 title: Option<String>,
248 description: String,
249}
250
251fn batch_app_to_metadata(app: BatchApp) -> ConnectorMetadata {
252 let BatchApp {
253 id,
254 name,
255 description,
256 icon_url,
257 icon_url_dark,
258 distribution_channel,
259 tools,
260 } = app;
261 ConnectorMetadata {
262 id,
263 name,
264 description,
265 icon_url,
266 icon_url_dark,
267 distribution_channel,
268 tool_summaries: tools.map(|tools| {
269 tools
270 .into_iter()
271 .map(|tool| {
272 let BatchAppToolSummary {
273 name,
274 title,
275 description,
276 } = tool;
277 ConnectorToolSummary {
278 name,
279 title,
280 description,
281 }
282 })
283 .collect()
284 }),
285 }
286}
287
288fn connector_directory_cache_context(
289 config: &Config,
290 auth: &CodexAuth,
291) -> ConnectorDirectoryCacheContext {
292 ConnectorDirectoryCacheContext::new(
293 config.codex_home.to_path_buf(),
294 ConnectorDirectoryCacheKey::new(
295 config.chatgpt_base_url.clone(),
296 auth.get_account_id(),
297 auth.get_chatgpt_user_id(),
298 auth.is_workspace_account(),
299 ),
300 )
301}
302
303fn merge_directory_and_plugin_connectors(
304 connectors: Vec<AppInfo>,
305 plugin_apps: &[AppConnectorId],
306) -> Vec<AppInfo> {
307 merge_plugin_connectors(
308 connectors,
309 plugin_apps
310 .iter()
311 .map(|connector_id| connector_id.0.clone()),
312 )
313}
314
315pub fn connectors_for_plugin_apps(
316 connectors: Vec<AppInfo>,
317 plugin_apps: &[AppConnectorId],
318) -> Vec<AppInfo> {
319 let connectors = merge_plugin_connectors(
320 connectors,
321 plugin_apps
322 .iter()
323 .map(|connector_id| connector_id.0.clone()),
324 );
325 let mut connectors_by_id = connectors
326 .into_iter()
327 .map(|connector| (connector.id.clone(), connector))
328 .collect::<HashMap<_, _>>();
329
330 plugin_apps
331 .iter()
332 .filter_map(|connector_id| connectors_by_id.remove(connector_id.0.as_str()))
333 .collect()
334}
335
336pub fn merge_connectors_with_accessible(
337 connectors: Vec<AppInfo>,
338 accessible_connectors: Vec<AppInfo>,
339 all_connectors_loaded: bool,
340) -> Vec<AppInfo> {
341 let accessible_connectors = if all_connectors_loaded {
342 let connector_ids: HashSet<&str> = connectors
343 .iter()
344 .map(|connector| connector.id.as_str())
345 .collect();
346 accessible_connectors
347 .into_iter()
348 .filter(|connector| connector_ids.contains(connector.id.as_str()))
349 .collect()
350 } else {
351 accessible_connectors
352 };
353 merge_connectors(connectors, accessible_connectors)
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use codex_connectors::metadata::connector_install_url;
360 use codex_plugin::AppConnectorId;
361 use pretty_assertions::assert_eq;
362 use serde_json::json;
363
364 #[test]
365 fn batch_app_accepts_missing_optional_metadata() {
366 let app = serde_json::from_value::<BatchApp>(json!({
367 "id": "alpha",
368 "name": "Alpha",
369 "description": "Alpha description",
370 "icon_url": null,
371 "tools": null,
372 }))
373 .expect("valid legacy batch app");
374
375 assert_eq!(
376 batch_app_to_metadata(app),
377 ConnectorMetadata {
378 id: "alpha".to_string(),
379 name: "Alpha".to_string(),
380 description: Some("Alpha description".to_string()),
381 icon_url: None,
382 icon_url_dark: None,
383 distribution_channel: None,
384 tool_summaries: None,
385 }
386 );
387 }
388
389 fn app(id: &str) -> AppInfo {
390 AppInfo {
391 id: id.to_string(),
392 name: id.to_string(),
393 description: None,
394 logo_url: None,
395 logo_url_dark: None,
396 icon_assets: None,
397 icon_dark_assets: None,
398 distribution_channel: None,
399 branding: None,
400 app_metadata: None,
401 labels: None,
402 install_url: None,
403 is_accessible: false,
404 is_enabled: true,
405 plugin_display_names: Vec::new(),
406 }
407 }
408
409 fn merged_app(id: &str, is_accessible: bool) -> AppInfo {
410 AppInfo {
411 id: id.to_string(),
412 name: id.to_string(),
413 description: None,
414 logo_url: None,
415 logo_url_dark: None,
416 icon_assets: None,
417 icon_dark_assets: None,
418 distribution_channel: None,
419 branding: None,
420 app_metadata: None,
421 labels: None,
422 install_url: Some(connector_install_url(id, id)),
423 is_accessible,
424 is_enabled: true,
425 plugin_display_names: Vec::new(),
426 }
427 }
428
429 #[test]
430 fn excludes_accessible_connectors_not_in_all_when_all_loaded() {
431 let merged = merge_connectors_with_accessible(
432 vec![app("alpha")],
433 vec![app("alpha"), app("beta")],
434 true,
435 );
436 assert_eq!(merged, vec![merged_app("alpha", true)]);
437 }
438
439 #[test]
440 fn keeps_accessible_connectors_not_in_all_while_all_loading() {
441 let merged = merge_connectors_with_accessible(
442 vec![app("alpha")],
443 vec![app("alpha"), app("beta")],
444 false,
445 );
446 assert_eq!(
447 merged,
448 vec![
449 merged_app("alpha", true),
450 merged_app("beta", true)
451 ]
452 );
453 }
454
455 #[test]
456 fn connectors_for_plugin_apps_returns_only_requested_plugin_apps() {
457 let connectors = connectors_for_plugin_apps(
458 vec![app("alpha"), app("beta")],
459 &[
460 AppConnectorId("gmail".to_string()),
461 AppConnectorId("alpha".to_string()),
462 AppConnectorId("gmail".to_string()),
463 ],
464 );
465 assert_eq!(
466 connectors,
467 vec![merged_app("gmail", false), app("alpha")]
468 );
469 }
470
471 #[test]
472 fn connectors_for_plugin_apps_preserves_formerly_disallowed_plugin_apps() {
473 let connector_id = "asdk_app_6938a94a61d881918ef32cb999ff937c";
474 let connectors =
475 connectors_for_plugin_apps(Vec::new(), &[AppConnectorId(connector_id.to_string())]);
476 assert_eq!(
477 connectors,
478 vec![merged_app(connector_id, false)]
479 );
480 }
481}