From b6d9c5a8089b07230b894f19c210238223bf9cdb Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Tue, 10 Feb 2026 22:57:23 -0800 Subject: [PATCH] update --- .../schema/json/ServerNotification.json | 5 ++ .../codex_app_server_protocol.schemas.json | 5 ++ .../json/v2/AppListUpdatedNotification.json | 5 ++ .../schema/json/v2/AppsListResponse.json | 5 ++ .../schema/typescript/v2/AppInfo.ts | 6 +- .../app-server-protocol/src/protocol/v2.rs | 3 + codex-rs/app-server/README.md | 8 +- .../app-server/src/codex_message_processor.rs | 12 ++- .../app-server/tests/suite/v2/app_list.rs | 89 +++++++++++++++++++ codex-rs/chatgpt/src/connectors.rs | 8 +- codex-rs/core/src/codex.rs | 35 +++++++- codex-rs/core/src/connectors.rs | 20 +++++ 12 files changed, 190 insertions(+), 11 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 69c6c2cb9b..6d313e7e6d 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -193,6 +193,11 @@ "default": false, "type": "boolean" }, + "isEnabled": { + "default": true, + "description": "Whether this app is enabled in the loaded config.", + "type": "boolean" + }, "logoUrl": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 88fb5ddbdb..e80150ce5e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -10058,6 +10058,11 @@ "default": false, "type": "boolean" }, + "isEnabled": { + "default": true, + "description": "Whether this app is enabled in the loaded config.", + "type": "boolean" + }, "logoUrl": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json index cf75f10ce0..8161db4bc8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json @@ -29,6 +29,11 @@ "default": false, "type": "boolean" }, + "isEnabled": { + "default": true, + "description": "Whether this app is enabled in the loaded config.", + "type": "boolean" + }, "logoUrl": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json index d2b5cc5ef9..fbbe219646 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json @@ -29,6 +29,11 @@ "default": false, "type": "boolean" }, + "isEnabled": { + "default": true, + "description": "Whether this app is enabled in the loaded config.", + "type": "boolean" + }, "logoUrl": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts index 0957c0dd4f..7e88e1890b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts @@ -5,4 +5,8 @@ /** * EXPERIMENTAL - app metadata returned by app-list APIs. */ -export type AppInfo = { id: string, name: string, description: string | null, logoUrl: string | null, logoUrlDark: string | null, distributionChannel: string | null, installUrl: string | null, isAccessible: boolean, }; +export type AppInfo = { id: string, name: string, description: string | null, logoUrl: string | null, logoUrlDark: string | null, distributionChannel: string | null, installUrl: string | null, isAccessible: boolean, +/** + * Whether this app is enabled in the loaded config. + */ +isEnabled: boolean, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 679dc4ab23..260aa3d0e2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1223,6 +1223,9 @@ pub struct AppInfo { pub install_url: Option, #[serde(default)] pub is_accessible: bool, + /// Whether this app is enabled in the loaded config. + #[serde(default = "default_enabled")] + pub is_enabled: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 7608262077..69587a6a9c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -748,7 +748,7 @@ To enable or disable a skill by path: ## Apps -Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, and whether it is currently accessible. +Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, whether it is currently accessible, and whether it is enabled in config. ```json { "method": "app/list", "id": 50, "params": { @@ -767,7 +767,8 @@ Use `app/list` to fetch available apps (connectors). Each entry includes metadat "logoUrlDark": null, "distributionChannel": null, "installUrl": "https://chatgpt.com/apps/demo-app/demo-app", - "isAccessible": true + "isAccessible": true, + "isEnabled": true } ], "nextCursor": null @@ -793,7 +794,8 @@ The server also emits `app/list/updated` notifications whenever either source (a "logoUrlDark": null, "distributionChannel": null, "installUrl": "https://chatgpt.com/apps/demo-app/demo-app", - "isAccessible": true + "isAccessible": true, + "isEnabled": true } ] } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e8b921980f..a40ad22570 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -4347,8 +4347,9 @@ impl CodexMessageProcessor { let _ = accessible_tx.send(AppListLoadResult::Accessible(result)); }); + let all_config = config.clone(); tokio::spawn(async move { - let result = connectors::list_all_connectors_with_options(&config, force_refetch) + let result = connectors::list_all_connectors_with_options(&all_config, force_refetch) .await .map_err(|err| format!("failed to list apps: {err}")); let _ = tx.send(AppListLoadResult::Directory(result)); @@ -4411,9 +4412,12 @@ impl CodexMessageProcessor { } } - let merged = Self::merge_loaded_apps( - all_connectors.as_deref(), - accessible_connectors.as_deref(), + let merged = connectors::with_app_enabled_state( + Self::merge_loaded_apps( + all_connectors.as_deref(), + accessible_connectors.as_deref(), + ), + &config, ); Self::send_app_list_updated_notification(&outgoing, merged.clone()).await; diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 9290bae29c..433e7aafb4 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -86,6 +86,7 @@ async fn list_apps_uses_thread_feature_flag_when_thread_id_is_provided() -> Resu distribution_channel: None, install_url: None, is_accessible: false, + is_enabled: true, }]; let tools = vec![connector_tool("beta", "Beta App")?]; let (server_url, server_handle) = @@ -172,6 +173,78 @@ connectors = false Ok(()) } +#[tokio::test] +async fn list_apps_reports_is_enabled_from_config() -> Result<()> { + let connectors = vec![AppInfo { + id: "beta".to_string(), + name: "Beta".to_string(), + description: Some("Beta connector".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: None, + is_accessible: false, + is_enabled: true, + }]; + let tools = vec![connector_tool("beta", "Beta App")?]; + let (server_url, server_handle) = + start_apps_server_with_delays(connectors, tools, Duration::ZERO, Duration::ZERO).await?; + + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +chatgpt_base_url = "{server_url}" + +[features] +connectors = true + +[apps.beta] +enabled = false +"# + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_apps_list_request(AppsListParams { + limit: None, + cursor: None, + thread_id: None, + force_refetch: false, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let AppsListResponse { + data: response_data, + next_cursor, + } = to_response(response)?; + assert!(next_cursor.is_none()); + assert_eq!(response_data.len(), 1); + assert_eq!(response_data[0].id, "beta"); + assert!(!response_data[0].is_enabled); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + #[tokio::test] async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<()> { let connectors = vec![ @@ -184,6 +257,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( distribution_channel: None, install_url: None, is_accessible: false, + is_enabled: true, }, AppInfo { id: "beta".to_string(), @@ -194,6 +268,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( distribution_channel: None, install_url: None, is_accessible: false, + is_enabled: true, }, ]; @@ -238,6 +313,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( distribution_channel: None, install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), is_accessible: true, + is_enabled: true, }]; let first_update = read_app_list_updated_notification(&mut mcp).await?; @@ -253,6 +329,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( distribution_channel: None, install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, + is_enabled: true, }, AppInfo { id: "alpha".to_string(), @@ -263,6 +340,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( distribution_channel: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, + is_enabled: true, }, ]; @@ -299,6 +377,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { distribution_channel: None, install_url: None, is_accessible: false, + is_enabled: true, }, AppInfo { id: "beta".to_string(), @@ -309,6 +388,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { distribution_channel: None, install_url: None, is_accessible: false, + is_enabled: true, }, ]; @@ -357,6 +437,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { distribution_channel: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, + is_enabled: true, }, AppInfo { id: "beta".to_string(), @@ -367,6 +448,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { distribution_channel: None, install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: false, + is_enabled: true, }, ] ); @@ -381,6 +463,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { distribution_channel: None, install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, + is_enabled: true, }, AppInfo { id: "alpha".to_string(), @@ -391,6 +474,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { distribution_channel: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, + is_enabled: true, }, ]; @@ -422,6 +506,7 @@ async fn list_apps_paginates_results() -> Result<()> { distribution_channel: None, install_url: None, is_accessible: false, + is_enabled: true, }, AppInfo { id: "beta".to_string(), @@ -432,6 +517,7 @@ async fn list_apps_paginates_results() -> Result<()> { distribution_channel: None, install_url: None, is_accessible: false, + is_enabled: true, }, ]; @@ -485,6 +571,7 @@ async fn list_apps_paginates_results() -> Result<()> { distribution_channel: None, install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, + is_enabled: true, }]; assert_eq!(first_page, expected_first); @@ -524,6 +611,7 @@ async fn list_apps_paginates_results() -> Result<()> { distribution_channel: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, + is_enabled: true, }]; assert_eq!(second_page, expected_second); @@ -544,6 +632,7 @@ async fn list_apps_force_refetch_preserves_previous_cache_on_failure() -> Result distribution_channel: None, install_url: None, is_accessible: false, + is_enabled: true, }]; let tools = vec![connector_tool("beta", "Beta App")?]; let (server_url, server_handle) = diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 396286efdb..a228a51058 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -20,6 +20,7 @@ use codex_core::connectors::connector_install_url; pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools; pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options; use codex_core::connectors::merge_connectors; +pub use codex_core::connectors::with_app_enabled_state; #[derive(Debug, Deserialize)] struct DirectoryListResponse { @@ -72,7 +73,10 @@ pub async fn list_connectors(config: &Config) -> anyhow::Result> { ); let connectors = connectors_result?; let accessible = accessible_result?; - Ok(merge_connectors_with_accessible(connectors, accessible)) + Ok(with_app_enabled_state( + merge_connectors_with_accessible(connectors, accessible), + config, + )) } pub async fn list_all_connectors(config: &Config) -> anyhow::Result> { @@ -283,6 +287,7 @@ fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo { distribution_channel: app.distribution_channel, install_url: None, is_accessible: false, + is_enabled: true, } } @@ -346,6 +351,7 @@ mod tests { distribution_channel: None, install_url: None, is_accessible: false, + is_enabled: true, } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7ee094b566..69fc49c7e9 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3899,7 +3899,10 @@ pub(crate) async fn run_turn( Ok(mcp_tools) => mcp_tools, Err(_) => return None, }; - let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools); + let connectors = connectors::with_app_enabled_state( + connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + &turn_context.config, + ); build_connector_slug_counts(&connectors) } else { HashMap::new() @@ -4129,6 +4132,14 @@ fn filter_connectors_for_input( explicitly_enabled_connectors: &HashSet, skill_name_counts_lower: &HashMap, ) -> Vec { + let connectors = connectors + .into_iter() + .filter(|connector| connector.is_enabled) + .collect::>(); + if connectors.is_empty() { + return Vec::new(); + } + let user_messages = collect_user_messages(input); if user_messages.is_empty() && explicitly_enabled_connectors.is_empty() { return Vec::new(); @@ -4360,7 +4371,10 @@ async fn built_tools( let skill_name_counts_lower = skills_outcome.map_or_else(HashMap::new, |outcome| { build_skill_name_counts(&outcome.skills, &outcome.disabled_paths).1 }); - let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools); + let connectors = connectors::with_app_enabled_state( + connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + &turn_context.config, + ); Some(filter_connectors_for_input( connectors, input, @@ -5211,6 +5225,7 @@ mod tests { distribution_channel: None, install_url: None, is_accessible: true, + is_enabled: true, } } @@ -5332,6 +5347,22 @@ mod tests { assert_eq!(selected, Vec::new()); } + #[test] + fn filter_connectors_for_input_skips_disabled_connectors() { + let mut connector = make_connector("calendar", "Calendar"); + connector.is_enabled = false; + let input = vec![user_message("use $calendar")]; + let explicitly_enabled_connectors = HashSet::new(); + let selected = filter_connectors_for_input( + vec![connector], + &input, + &explicitly_enabled_connectors, + &HashMap::new(), + ); + + assert_eq!(selected, Vec::new()); + } + #[test] fn search_tool_selection_keeps_codex_apps_tools_without_mentions() { let selected_tool_names = vec![ diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index b451d21b57..60c7456028 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -9,6 +9,7 @@ use std::time::Instant; use async_channel::unbounded; pub use codex_app_server_protocol::AppInfo; use codex_protocol::protocol::SandboxPolicy; +use serde::Deserialize; use tokio_util::sync::CancellationToken; use tracing::warn; @@ -16,6 +17,7 @@ use crate::AuthManager; use crate::CodexAuth; use crate::SandboxState; use crate::config::Config; +use crate::config::types::AppsConfigToml; use crate::features::Feature; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::auth::compute_auth_statuses; @@ -265,6 +267,23 @@ pub fn merge_connectors( merged } +pub fn with_app_enabled_state(mut connectors: Vec, config: &Config) -> Vec { + let apps = read_apps_config(config).map(|apps_config| apps_config.apps); + for connector in &mut connectors { + connector.is_enabled = apps + .as_ref() + .and_then(|apps| apps.get(&connector.id)) + .is_none_or(|app| app.enabled); + } + connectors +} + +fn read_apps_config(config: &Config) -> Option { + let effective_config = config.config_layer_stack.effective_config(); + let apps_config = effective_config.as_table()?.get("apps")?.clone(); + AppsConfigToml::deserialize(apps_config).ok() +} + fn collect_accessible_connectors(tools: I) -> Vec where I: IntoIterator)>, @@ -291,6 +310,7 @@ where distribution_channel: None, install_url: Some(connector_install_url(&connector_name, &connector_id)), is_accessible: true, + is_enabled: true, }) .collect(); accessible.sort_by(|left, right| {