diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 620a93872..81c382f5d 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -26,7 +26,9 @@ pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_o 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; use codex_core::connectors::merge_connectors; +use codex_core::connectors::merge_plugin_apps; pub use codex_core::connectors::with_app_enabled_state; +use codex_core::plugins::PluginsManager; #[derive(Debug, Deserialize)] struct DirectoryListResponse { @@ -106,7 +108,10 @@ pub async fn list_cached_all_connectors(config: &Config) -> Option> } let token_data = get_chatgpt_token_data()?; let cache_key = all_connectors_cache_key(config, &token_data); - read_cached_all_connectors(&cache_key).map(filter_disallowed_connectors) + read_cached_all_connectors(&cache_key).map(|connectors| { + let connectors = merge_plugin_apps(connectors, plugin_apps_for_config(config)); + filter_disallowed_connectors(connectors) + }) } pub async fn list_all_connectors_with_options( @@ -123,7 +128,8 @@ pub async fn list_all_connectors_with_options( get_chatgpt_token_data().ok_or_else(|| anyhow::anyhow!("ChatGPT token not available"))?; let cache_key = all_connectors_cache_key(config, &token_data); if !force_refetch && let Some(cached_connectors) = read_cached_all_connectors(&cache_key) { - return Ok(filter_disallowed_connectors(cached_connectors)); + let connectors = merge_plugin_apps(cached_connectors, plugin_apps_for_config(config)); + return Ok(filter_disallowed_connectors(connectors)); } let mut apps = list_directory_connectors(config).await?; @@ -151,7 +157,8 @@ pub async fn list_all_connectors_with_options( }); let connectors = filter_disallowed_connectors(connectors); write_cached_all_connectors(cache_key, &connectors); - Ok(connectors) + let connectors = merge_plugin_apps(connectors, plugin_apps_for_config(config)); + Ok(filter_disallowed_connectors(connectors)) } fn all_connectors_cache_key(config: &Config, token_data: &TokenData) -> AllConnectorsCacheKey { @@ -192,6 +199,12 @@ fn write_cached_all_connectors(cache_key: AllConnectorsCacheKey, connectors: &[A }); } +fn plugin_apps_for_config(config: &Config) -> Vec { + PluginsManager::new(config.codex_home.clone()) + .plugins_for_config(config) + .effective_apps() +} + pub fn merge_connectors_with_accessible( connectors: Vec, accessible_connectors: Vec, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7736aa128..1ead1871f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4891,10 +4891,15 @@ pub(crate) async fn run_turn( Ok(mcp_tools) => mcp_tools, Err(_) => return None, }; - connectors::with_app_enabled_state( + let plugin_apps = sess + .services + .plugins_manager + .plugins_for_config(&turn_context.config); + let connectors = connectors::merge_plugin_apps_with_accessible( + plugin_apps.effective_apps(), connectors::accessible_connectors_from_mcp_tools(&mcp_tools), - &turn_context.config, - ) + ); + connectors::with_app_enabled_state(connectors, &turn_context.config) } else { Vec::new() }; @@ -5614,8 +5619,16 @@ async fn built_tools( effective_explicitly_enabled_connectors.extend(sess.get_connector_selection().await); let connectors = if turn_context.features.enabled(Feature::Apps) { - Some(connectors::with_app_enabled_state( + let plugin_apps = sess + .services + .plugins_manager + .plugins_for_config(&turn_context.config); + let connectors = connectors::merge_plugin_apps_with_accessible( + plugin_apps.effective_apps(), connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + ); + Some(connectors::with_app_enabled_state( + connectors, &turn_context.config, )) } else { diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 33b003770..3ad48fa00 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::collections::HashSet; use std::env; use std::path::PathBuf; use std::sync::LazyLock; @@ -29,6 +30,7 @@ use crate::mcp::auth::compute_auth_statuses; use crate::mcp::with_codex_apps_mcp; use crate::mcp_connection_manager::McpConnectionManager; use crate::mcp_connection_manager::codex_apps_tools_cache_key; +use crate::plugins::AppConnectorId; use crate::token_data::TokenData; pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600); @@ -353,6 +355,48 @@ pub fn merge_connectors( merged } +pub fn merge_plugin_apps( + connectors: Vec, + plugin_apps: Vec, +) -> Vec { + let mut merged = connectors; + let mut connector_ids = merged + .iter() + .map(|connector| connector.id.clone()) + .collect::>(); + + for connector_id in plugin_apps { + if connector_ids.insert(connector_id.0.clone()) { + merged.push(plugin_app_to_app_info(connector_id)); + } + } + + merged.sort_by(|left, right| { + right + .is_accessible + .cmp(&left.is_accessible) + .then_with(|| left.name.cmp(&right.name)) + .then_with(|| left.id.cmp(&right.id)) + }); + merged +} + +pub fn merge_plugin_apps_with_accessible( + plugin_apps: Vec, + accessible_connectors: Vec, +) -> Vec { + let accessible_connector_ids: HashSet<&str> = accessible_connectors + .iter() + .map(|connector| connector.id.as_str()) + .collect(); + let plugin_connectors = plugin_apps + .into_iter() + .filter(|connector_id| accessible_connector_ids.contains(connector_id.0.as_str())) + .map(plugin_app_to_app_info) + .collect::>(); + merge_connectors(plugin_connectors, accessible_connectors) +} + pub fn with_app_enabled_state(mut connectors: Vec, config: &Config) -> Vec { let apps_config = read_apps_config(config); if let Some(apps_config) = apps_config.as_ref() { @@ -575,6 +619,28 @@ where accessible } +fn plugin_app_to_app_info(connector_id: AppConnectorId) -> AppInfo { + // Leave the placeholder name as the connector id so merge_connectors() can + // replace it with canonical app metadata from directory fetches or + // connector_name values from codex_apps tool discovery. + let connector_id = connector_id.0; + let name = connector_id.clone(); + AppInfo { + id: connector_id.clone(), + name: name.clone(), + 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(&name, &connector_id)), + is_accessible: false, + is_enabled: true, + } +} + fn normalize_connector_value(value: Option<&str>) -> Option { value .map(str::trim) @@ -647,6 +713,46 @@ mod tests { } } + #[test] + fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() { + let plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string())); + let accessible = AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Plan events".to_string()), + logo_url: Some("https://example.com/logo.png".to_string()), + logo_url_dark: Some("https://example.com/logo-dark.png".to_string()), + distribution_channel: Some("workspace".to_string()), + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: true, + is_enabled: true, + }; + + let merged = merge_connectors(vec![plugin], vec![accessible]); + + assert_eq!( + merged, + vec![AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Plan events".to_string()), + logo_url: Some("https://example.com/logo.png".to_string()), + logo_url_dark: Some("https://example.com/logo-dark.png".to_string()), + distribution_channel: Some("workspace".to_string()), + branding: None, + app_metadata: None, + labels: None, + install_url: Some(connector_install_url("calendar", "calendar")), + is_accessible: true, + is_enabled: true, + }] + ); + assert_eq!(connector_mention_slug(&merged[0]), "google-calendar"); + } + #[test] fn app_tool_policy_uses_global_defaults_for_destructive_hints() { let apps_config = AppsConfigToml { diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index d98c4379f..c5a7b894a 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -33,6 +33,10 @@ use tracing::warn; const DEFAULT_SKILLS_DIR_NAME: &str = "skills"; const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json"; +const DEFAULT_APP_CONFIG_FILE: &str = ".app.json"; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AppConnectorId(pub String); #[derive(Debug, Clone, PartialEq)] pub struct LoadedPlugin { @@ -42,6 +46,7 @@ pub struct LoadedPlugin { pub enabled: bool, pub skill_roots: Vec, pub mcp_servers: HashMap, + pub apps: Vec, pub error: Option, } @@ -80,6 +85,21 @@ impl PluginLoadOutcome { } mcp_servers } + + pub fn effective_apps(&self) -> Vec { + let mut apps = Vec::new(); + let mut seen_connector_ids = std::collections::HashSet::new(); + + for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) { + for connector_id in &plugin.apps { + if seen_connector_ids.insert(connector_id.clone()) { + apps.push(connector_id.clone()); + } + } + } + + apps + } } pub struct PluginsManager { @@ -227,6 +247,18 @@ struct PluginMcpFile { mcp_servers: HashMap, } +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PluginAppFile { + #[serde(default)] + apps: HashMap, +} + +#[derive(Debug, Default, Deserialize)] +struct PluginAppConfig { + id: String, +} + pub(crate) fn load_plugins_from_layer_stack( config_layer_stack: &ConfigLayerStack, store: &PluginStore, @@ -299,6 +331,7 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) enabled: plugin.enabled, skill_roots: Vec::new(), mcp_servers: HashMap::new(), + apps: Vec::new(), error: None, }; @@ -341,6 +374,10 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) } } loaded_plugin.mcp_servers = mcp_servers; + loaded_plugin.apps = load_apps_from_file( + plugin_root.as_path(), + &plugin_root.as_path().join(DEFAULT_APP_CONFIG_FILE), + ); loaded_plugin } @@ -364,6 +401,39 @@ fn default_mcp_config_paths(plugin_root: &Path) -> Vec { paths } +fn load_apps_from_file(plugin_root: &Path, app_config_path: &Path) -> Vec { + let Ok(contents) = fs::read_to_string(app_config_path) else { + return Vec::new(); + }; + let parsed = match serde_json::from_str::(&contents) { + Ok(parsed) => parsed, + Err(err) => { + warn!( + path = %app_config_path.display(), + "failed to parse plugin app config: {err}" + ); + return Vec::new(); + } + }; + + let mut apps: Vec = parsed.apps.into_values().collect(); + apps.sort_unstable_by(|left, right| left.id.cmp(&right.id)); + + apps.into_iter() + .filter_map(|app| { + if app.id.trim().is_empty() { + warn!( + plugin = %plugin_root.display(), + "plugin app config is missing an app id" + ); + None + } else { + Some(AppConnectorId(app.id)) + } + }) + .collect() +} + fn load_mcp_servers_from_file(plugin_root: &Path, mcp_config_path: &Path) -> PluginMcpDiscovery { let Ok(contents) = fs::read_to_string(mcp_config_path) else { return PluginMcpDiscovery::default(); @@ -550,6 +620,16 @@ mod tests { } }"#, ); + write_file( + &plugin_root.join(".app.json"), + r#"{ + "apps": { + "example": { + "id": "connector_example" + } + } +}"#, + ); let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()).await; @@ -582,6 +662,7 @@ mod tests { oauth_resource: None, }, )]), + apps: vec![AppConnectorId("connector_example".to_string())], error: None, }] ); @@ -590,6 +671,10 @@ mod tests { vec![plugin_root.join("skills")] ); assert_eq!(outcome.effective_mcp_servers().len(), 1); + assert_eq!( + outcome.effective_apps(), + vec![AppConnectorId("connector_example".to_string())] + ); } #[tokio::test] @@ -628,6 +713,7 @@ mod tests { enabled: false, skill_roots: Vec::new(), mcp_servers: HashMap::new(), + apps: Vec::new(), error: None, }] ); @@ -635,6 +721,80 @@ mod tests { assert!(outcome.effective_mcp_servers().is_empty()); } + #[tokio::test] + async fn effective_apps_dedupes_connector_ids_across_plugins() { + let codex_home = TempDir::new().unwrap(); + let plugin_a_root = codex_home + .path() + .join("plugins/cache") + .join("test/plugin-a/local"); + let plugin_b_root = codex_home + .path() + .join("plugins/cache") + .join("test/plugin-b/local"); + + write_file( + &plugin_a_root.join(".codex-plugin/plugin.json"), + r#"{"name":"plugin-a"}"#, + ); + write_file( + &plugin_a_root.join(".app.json"), + r#"{ + "apps": { + "example": { + "id": "connector_example" + } + } +}"#, + ); + write_file( + &plugin_b_root.join(".codex-plugin/plugin.json"), + r#"{"name":"plugin-b"}"#, + ); + write_file( + &plugin_b_root.join(".app.json"), + r#"{ + "apps": { + "chat": { + "id": "connector_example" + }, + "gmail": { + "id": "connector_gmail" + } + } +}"#, + ); + + let mut root = toml::map::Map::new(); + let mut features = toml::map::Map::new(); + features.insert("plugins".to_string(), Value::Boolean(true)); + root.insert("features".to_string(), Value::Table(features)); + + let mut plugins = toml::map::Map::new(); + + let mut plugin_a = toml::map::Map::new(); + plugin_a.insert("enabled".to_string(), Value::Boolean(true)); + plugins.insert("plugin-a@test".to_string(), Value::Table(plugin_a)); + + let mut plugin_b = toml::map::Map::new(); + plugin_b.insert("enabled".to_string(), Value::Boolean(true)); + plugins.insert("plugin-b@test".to_string(), Value::Table(plugin_b)); + + root.insert("plugins".to_string(), Value::Table(plugins)); + let config_toml = + toml::to_string(&Value::Table(root)).expect("plugin test config should serialize"); + + let outcome = load_plugins_from_config(&config_toml, codex_home.path()).await; + + assert_eq!( + outcome.effective_apps(), + vec![ + AppConnectorId("connector_example".to_string()), + AppConnectorId("connector_gmail".to_string()), + ] + ); + } + #[test] fn plugin_namespace_for_skill_path_uses_manifest_name() { let codex_home = TempDir::new().unwrap(); diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index faf0a8dd0..37b4009bc 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -2,6 +2,7 @@ mod manager; mod manifest; mod store; +pub use manager::AppConnectorId; pub use manager::LoadedPlugin; pub use manager::PluginInstallError; pub use manager::PluginLoadOutcome; diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index c562e390e..1160a125a 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -23,8 +23,15 @@ pub struct AppsTestServer { impl AppsTestServer { pub async fn mount(server: &MockServer) -> Result { + Self::mount_with_connector_name(server, CONNECTOR_NAME).await + } + + pub async fn mount_with_connector_name( + server: &MockServer, + connector_name: &str, + ) -> Result { mount_oauth_metadata(server).await; - mount_streamable_http_json_rpc(server).await; + mount_streamable_http_json_rpc(server, connector_name.to_string()).await; Ok(Self { chatgpt_base_url: server.uri(), }) @@ -43,15 +50,17 @@ async fn mount_oauth_metadata(server: &MockServer) { .await; } -async fn mount_streamable_http_json_rpc(server: &MockServer) { +async fn mount_streamable_http_json_rpc(server: &MockServer, connector_name: String) { Mock::given(method("POST")) .and(path_regex("^/api/codex/apps/?$")) - .respond_with(CodexAppsJsonRpcResponder) + .respond_with(CodexAppsJsonRpcResponder { connector_name }) .mount(server) .await; } -struct CodexAppsJsonRpcResponder; +struct CodexAppsJsonRpcResponder { + connector_name: String, +} impl Respond for CodexAppsJsonRpcResponder { fn respond(&self, request: &Request) -> ResponseTemplate { @@ -117,7 +126,7 @@ impl Respond for CodexAppsJsonRpcResponder { }, "_meta": { "connector_id": CONNECTOR_ID, - "connector_name": CONNECTOR_NAME + "connector_name": self.connector_name.clone() } }, { @@ -133,7 +142,7 @@ impl Respond for CodexAppsJsonRpcResponder { }, "_meta": { "connector_id": CONNECTOR_ID, - "connector_name": CONNECTOR_NAME + "connector_name": self.connector_name.clone() } } ], diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 3e641d010..12d81402c 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -7,11 +7,14 @@ use std::time::Instant; use anyhow::Result; use codex_core::CodexAuth; +use codex_core::features::Feature; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use core_test_support::apps_test_server::AppsTestServer; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; @@ -74,6 +77,35 @@ fn write_plugin_mcp_plugin(home: &TempDir, command: &str) { .expect("write config"); } +fn write_plugin_app_plugin(home: &TempDir) { + let plugin_root = home.path().join("plugins/sample"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir"); + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ) + .expect("write plugin manifest"); + std::fs::write( + plugin_root.join(".app.json"), + r#"{ + "apps": { + "calendar": { + "id": "calendar" + } + } +}"#, + ) + .expect("write plugin app config"); + std::fs::write( + home.path().join("config.toml"), + format!( + "[features]\nplugins = true\n\n[plugins.sample]\nenabled = true\npath = \"{}\"\n", + plugin_root.display() + ), + ) + .expect("write config"); +} + async fn build_plugin_test_codex( server: &MockServer, codex_home: Arc, @@ -88,6 +120,23 @@ async fn build_plugin_test_codex( .codex) } +fn tool_names(body: &serde_json::Value) -> Vec { + body.get("tools") + .and_then(serde_json::Value::as_array) + .map(|tools| { + tools + .iter() + .filter_map(|tool| { + tool.get("name") + .or_else(|| tool.get("type")) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .collect() + }) + .unwrap_or_default() +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn plugin_skills_append_to_instructions() -> Result<()> { skip_if_no_network!(Ok(())); @@ -138,6 +187,86 @@ async fn plugin_skills_append_to_instructions() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn plugin_apps_expose_tools_after_canonical_name_mention() -> Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + + let codex_home = Arc::new(TempDir::new()?); + write_plugin_app_plugin(codex_home.as_ref()); + let mut builder = test_codex() + .with_home(codex_home) + .with_auth(CodexAuth::from_api_key("Test API Key")) + .with_config(move |config| { + config.features.enable(Feature::Apps); + config.features.disable(Feature::AppsMcpGateway); + config.chatgpt_base_url = apps_server.chatgpt_base_url; + }); + let codex = builder + .build(&server) + .await + .expect("create new conversation") + .codex; + + codex + .submit(Op::UserInput { + items: vec![codex_protocol::user_input::UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + codex + .submit(Op::UserInput { + items: vec![codex_protocol::user_input::UserInput::Text { + text: "Use $google-calendar and then call tools.".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = mock.requests(); + assert_eq!(requests.len(), 2, "expected two model requests"); + + let first_tools = tool_names(&requests[0].body_json()); + assert!( + !first_tools + .iter() + .any(|name| name == "mcp__codex_apps__calendar_create_event"), + "app tools should stay hidden before plugin app mention: {first_tools:?}" + ); + + let second_tools = tool_names(&requests[1].body_json()); + assert!( + second_tools + .iter() + .any(|name| name == "mcp__codex_apps__calendar_create_event"), + "calendar create tool should be available after plugin app mention: {second_tools:?}" + ); + assert!( + second_tools + .iter() + .any(|name| name == "mcp__codex_apps__calendar_list_events"), + "calendar list tool should be available after plugin app mention: {second_tools:?}" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn plugin_mcp_tools_are_listed() -> Result<()> { skip_if_no_network!(Ok(()));